mirror of
https://github.com/alibaba/open-code-review.git
synced 2026-07-09 17:28:58 +00:00
feat(action): extract reusable composite PR-review GitHub Action (#337)
* feat(action): extract reusable OpenCodeReview PR review GitHub Action
Consolidate the reusable-action work into one commit:
- Add composite action (action.yml at repo root for GitHub Marketplace;
helper at scripts/github-actions/post-review-comments.js) porting the
sticky summary, incremental posting, and retry idempotency logic.
- Add unit tests covering the ported idempotency behavior.
- Switch the in-repo CI workflow to use the reusable action.
- Add and refine example reusable workflows for consumers.
* ci(workflow): point ocr-review at root action.yml and quote boolean inputs
- Fix uses: to ./ now that action.yml lives at the repo root.
- Quote sticky_summary/incremental/upload_artifacts as strings to
match action.yml's input declarations (composite-action inputs are
always strings) and silence actionlint.
- Enable upload_artifacts for this workflow.
* docs(examples): point reusable demo at root action.yml
The example workflow referenced alibaba/open-code-review/action@v1,
but action.yml now lives at the repo root, so the /action subpath no
longer resolves. Use alibaba/open-code-review@v1 and update the stale
action/README.md comment to point at the root action.yml.
* docs(examples): sync README to root action.yml references
The example README still pointed at the relocated/deleted locations:
action.yml is now at the repo root, so update all 11
alibaba/open-code-review/action@v1 references to
alibaba/open-code-review@v1, and repoint the action/ directory and
action/README.md links to the root action.yml.
* fix(examples): prevent unrelated PR comments from canceling ocr-review
GitHub Actions evaluates concurrency before the job-level if-condition.
The flat group mapped every issue_comment event on a PR into the review's
group, so any comment (even a skipped conversation reply) canceled any
in-progress review.
Match the reusable demo's conditional group: PR events and human-authored
/open-code-review/@open-code-review comments share a per-PR group, while
non-matching comments fall back to a unique noop-<run_id> group that can
never collide with a real review.
* fix(action): address code-review findings across reusable PR review
- post-review-comments: parse retry delays via parseNonNegInt (0/negative fix);
paginate findExistingSummaryComment through readAllPages; remove dead
rangeOf and hasIssueCommentWithId (plus duplicated comment block)
- action.yml: move ${{ }} interpolations into env: (resolve refs, PR_NUM,
ocr_version); fail fast on PR head fetch instead of swallowing errors
- workflows: add timeout-minutes: 30; gate issue_comment on
author_association; tighten pr-context if to == 'issue_comment'
* fix(action): harden review posting after code review
- pass incremental_overlap_threshold via env to avoid github-script injection
- capture ocr review exit code directly instead of &&/|| chain
- drop redundant SUMMARY_MARKER prepend in postSummary (callers already add it)
- align example job if-condition bot check with its concurrency group
* fix(action): always upload review artifacts and capture ocr exit code
* fix(action): merge posting statistics into the summary header
The PR summary issue comment used to present two overlapping breakdowns:
a leading "posted as inline / posted as summary" header and a trailing
"📊 Posting Statistics" block. Their definitions overlapped (the header's
"summary" count included failures the trailer also listed as failed), and
when incremental filtering skipped comments the header counts no longer
summed to the total, making the summary hard to interpret.
Merge them into a single header whose four counts (inline / summary /
skipped / failed) are mutually exclusive and sum to the total, and drop
the trailing Posting Statistics section. buildSummaryBody now takes an
options object.
* fix(action): support local action resolution in container/self-hosted setups
- Checkout trusted base + mark workspace safe for pull_request_target so
the local `uses: ./` action can be resolved and loaded
- Check for git/Node.js and install git when missing, making the
composite action resilient across runner images
- Move Setup Node.js earlier and make it conditional on availability
- Resolve post-review-comments helper at runtime via
GITHUB_ACTION_PATH falling back to GITHUB_WORKSPACE, fixing helper
lookup for local actions where the action path is a host path
invisible inside containers
* refactor(examples): consolidate github_actions demo to reusable action
Drop the inline-script full-control demo; the renamed ocr-review.yml
(from ocr-review-reusable.yml) is now the single demo, invoking
alibaba/open-code-review@main.
Sync the README to the current implementation:
- normalize action refs to @main; point self-hosted-runner users to the
repo's own workflow (noting uses: ./ is internal-only)
- document config via action inputs (posting modes: sticky/incremental)
- update the comment-trigger if with defensive bot/author_association
guards and the concurrency mirror
- fix Example Output to cover the summary comment + inline comments
- replace the non-existent OCR_DEBUG debugging with
artifacts/outputs/ACTIONS_STEP_DEBUG
- use --replace-all for safe.directory
* fix(action): harden withRetry against silent undefined return
withRetry's for loop had no terminal return/throw after the loop body.
Although the current loop invariant (last attempt always throws, and
parseNonNegInt guards against negative MAX_RETRIES) makes fall-through
unreachable, an async function that falls through resolves to undefined,
which would surface as a confusing downstream TypeError for the read-API
callers that rely on it.
Capture lastErr in the loop and add an explicit terminal throw so any
future break of the invariant fails loudly instead of silently returning
undefined.
* docs(readme): document the reusable GitHub Action in CI/CD section
* fix(action): restore language config via a language input
The old inline workflow ran `ocr config set language English`, but the
composite action's Configure OCR step only set llm.extra_body, with no
language input. Add a language input (default English) and write it via
`ocr config set language` so review output language is no longer left
to the tool's default.
Addresses #337 (discussion_r3550069843).
* fix(action): warn when incremental comment listing hits page cap
listExistingReviewComments silently dropped comments beyond its 10-page
cap, unlike readAllPages which logs when truncation occurs. Add the
same max-page-limit warning after the loop so a partial walk during
incremental dedup is visible in the logs.
Addresses #337 (discussion_r3550069871).
* docs(readme): sync GitHub Action section to localized READMEs
This commit is contained in:
parent
9c1121691a
commit
d9159276af
11 changed files with 3132 additions and 1887 deletions
864
.github/workflows/ocr-review.yml
vendored
864
.github/workflows/ocr-review.yml
vendored
|
|
@ -1,58 +1,19 @@
|
|||
# OpenCodeReview - GitHub Actions PR Auto-Review Pipeline
|
||||
#
|
||||
# This workflow automatically reviews pull requests using OpenCodeReview
|
||||
# and posts review comments directly on the PR.
|
||||
# Reviews pull requests using the reusable composite action defined in
|
||||
# action.yml at the repo root. See that file for the full list of inputs,
|
||||
# outputs, and implementation details (retry, idempotency, artifact upload, etc.).
|
||||
#
|
||||
# Triggers:
|
||||
# - PR opened (uses pull_request_target for fork secret access)
|
||||
# - Comment on PR containing '/open-code-review' or '@open-code-review'
|
||||
#
|
||||
# Required secrets:
|
||||
# OCR_LLM_URL - LLM API endpoint (e.g., https://api.openai.com/v1/chat/completions)
|
||||
# OCR_LLM_AUTH_TOKEN - Authentication token for the LLM API
|
||||
#
|
||||
# Optional secrets:
|
||||
# OCR_LLM_MODEL - Model name (default: gpt-4o)
|
||||
# OCR_LLM_USE_ANTHROPIC - Set to 'true' if using Anthropic Claude models
|
||||
#
|
||||
# Optional variables (for retry/delay tuning):
|
||||
# The retry strategy follows GitHub's documented guidance for REST API rate limits:
|
||||
# https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api
|
||||
# - Primary rate limit exhausted (x-ratelimit-remaining=0): wait until x-ratelimit-reset.
|
||||
# - Secondary rate limit with a retry-after header: wait exactly that long.
|
||||
# - Secondary rate limit with NO header: wait at least one minute, then use
|
||||
# exponential backoff on continued failures.
|
||||
#
|
||||
# OCR_RETRY_BASE_DELAY - Base delay (ms) for exponential backoff when no retry
|
||||
# header is present (default: 60000, per GitHub's
|
||||
# "at least one minute" recommendation for secondary limits).
|
||||
# OCR_RETRY_MAX_DELAY - Maximum delay (ms) cap applied to EVERY computed wait,
|
||||
# including retry-after and x-ratelimit-reset, so a far-future
|
||||
# reset cannot stall the job past its timeout (default: 300000 = 5 min).
|
||||
# OCR_MAX_RETRIES - Max retry attempts per comment when rate-limited (default: 3).
|
||||
# OCR_SUCCESS_DELAY - Delay (ms) between successful comment posts to pace requests (default: 2000).
|
||||
# OCR_FAILURE_DELAY - Delay (ms) after a non-retryable failure to pace subsequent requests (default: 1000).
|
||||
# OCR_LOW_REMAINING_THRESHOLD - When x-ratelimit-remaining is at or below this value,
|
||||
# proactively increase request spacing to avoid hitting the limit
|
||||
# (default: 3; GitHub best practice is to watch the header and slow down).
|
||||
# OCR_LOW_REMAINING_SPACING - Request spacing (ms) used when remaining quota is low
|
||||
# (default: 10000 = 10s).
|
||||
# OCR_READ_SUCCESS_DELAY - Delay (ms) after a successful read API call (listReviews /
|
||||
# listReviewComments / listIssueComments) used for the
|
||||
# idempotency check. Reads are cheaper than writes, so the
|
||||
# default is shorter (default: 500).
|
||||
# OCR_READ_LOW_REMAINING_SPACING - Request spacing (ms) for read calls when remaining
|
||||
# quota is low (default: 5000 = 5s).
|
||||
#
|
||||
# Idempotency:
|
||||
# When the batch createReview fails with a 5xx, the request may still have landed on
|
||||
# the server. Before retrying per-comment, the workflow queries existing reviews and
|
||||
# review comments (tagged with a per-run HTML comment) and only retries the comments
|
||||
# that are actually missing. This prevents duplicate review posts.
|
||||
# Required secrets (mapped to action inputs):
|
||||
# OCR_LLM_URL - LLM API endpoint (e.g., https://api.openai.com/v1/chat/completions)
|
||||
# OCR_LLM_AUTH_TOKEN - Authentication token for the LLM API
|
||||
# OCR_LLM_MODEL - Model name
|
||||
# OCR_LLM_USE_ANTHROPIC - 'true' for Anthropic Claude, 'false' for OpenAI-compatible
|
||||
#
|
||||
# Note: GITHUB_TOKEN is automatically provided by GitHub Actions.
|
||||
# Note: The workflow also configures llm.extra_body to '{"thinking": {"type": "disabled"}}'
|
||||
# to disable thinking mode for compatibility with various LLM providers.
|
||||
|
||||
name: OpenCodeReview PR Review
|
||||
|
||||
|
|
@ -74,805 +35,30 @@ permissions:
|
|||
jobs:
|
||||
code-review:
|
||||
runs-on: self-hosted
|
||||
timeout-minutes: 30
|
||||
container:
|
||||
image: node:24
|
||||
if: github.event_name == 'pull_request_target'
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
# Materialize action.yml + scripts/ into the workspace so the local
|
||||
# `uses: ./` action below can be resolved and loaded. For
|
||||
# pull_request_target this checks out the trusted base branch; the
|
||||
# composite action performs its own full checkout (fetch-depth: 0) later.
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # Full history needed for merge-base diff
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Mark repository as safe directory
|
||||
- name: Trust workspace
|
||||
run: git config --global --replace-all safe.directory '*'
|
||||
|
||||
- name: Fetch PR head ref (ensures fork commits are available)
|
||||
run: git fetch origin pull/${{ github.event.pull_request.number }}/head
|
||||
|
||||
- name: Install OpenCodeReview
|
||||
run: npm install -g @alibaba-group/open-code-review
|
||||
|
||||
- name: Configure OCR
|
||||
run: |
|
||||
ocr config set llm.url ${{ secrets.OCR_LLM_URL }}
|
||||
ocr config set llm.auth_token ${{ secrets.OCR_LLM_AUTH_TOKEN }}
|
||||
ocr config set llm.model ${{ secrets.OCR_LLM_MODEL }}
|
||||
ocr config set llm.use_anthropic ${{ secrets.OCR_LLM_USE_ANTHROPIC }}
|
||||
ocr config set llm.extra_body '{"enable_thinking": false}'
|
||||
ocr config set language English
|
||||
|
||||
- name: Run OpenCodeReview
|
||||
id: review
|
||||
run: |
|
||||
BASE_REF="${{ github.event.pull_request.base.ref }}"
|
||||
HEAD_SHA="${{ github.event.pull_request.head.sha }}"
|
||||
|
||||
echo "Reviewing PR: ${HEAD_SHA} against origin/${BASE_REF}"
|
||||
|
||||
# Run OCR in range mode with JSON output
|
||||
ocr review \
|
||||
--from "origin/${BASE_REF}" \
|
||||
--to "${HEAD_SHA}" \
|
||||
--format json \
|
||||
> /tmp/ocr-result.json 2>/tmp/ocr-stderr.log || true
|
||||
|
||||
echo "OCR review completed. Output:"
|
||||
cat /tmp/ocr-result.json
|
||||
echo "OCR review completed. Error log:"
|
||||
cat /tmp/ocr-stderr.log
|
||||
|
||||
- name: Post review comments to PR
|
||||
uses: actions/github-script@v7
|
||||
uses: ./
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
const path = '/tmp/ocr-result.json';
|
||||
|
||||
// Unique tag for this workflow run + attempt. Embedded in review/comment
|
||||
// bodies as an HTML comment so the idempotency check can detect whether
|
||||
// a batch createReview actually landed on the server before retrying.
|
||||
// context.runId / context.runAttempt are numbers from @actions/github's
|
||||
// Context (parsed from GITHUB_RUN_ID / GITHUB_RUN_ATTEMPT). Use
|
||||
// Number.isFinite to guard against NaN when the env vars are missing,
|
||||
// falling back to safe defaults.
|
||||
const runId = Number.isFinite(context.runId) ? context.runId : 0;
|
||||
const runAttempt = Number.isFinite(context.runAttempt) ? context.runAttempt : 1;
|
||||
const RUN_TAG = `${runId}-${runAttempt}`;
|
||||
const REVIEW_TAG = `<!-- ocr-review-run:${RUN_TAG} -->`;
|
||||
const SUMMARY_TAG = `<!-- ocr-summary-run:${RUN_TAG} -->`;
|
||||
|
||||
// Read OCR output
|
||||
let result;
|
||||
try {
|
||||
const raw = fs.readFileSync(path, 'utf8');
|
||||
result = JSON.parse(raw);
|
||||
} catch (e) {
|
||||
console.log('Failed to parse OCR output:', e.message);
|
||||
// Post a simple comment if parsing fails
|
||||
const stderr = fs.readFileSync('/tmp/ocr-stderr.log', 'utf8').trim();
|
||||
if (stderr) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: `⚠️ **OpenCodeReview** encountered an error:\n${fencedBlock(stderr)}`
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const comments = result.comments || [];
|
||||
const warnings = result.warnings || [];
|
||||
|
||||
// If no comments, post a summary
|
||||
if (comments.length === 0) {
|
||||
const message = result.message || 'No comments generated. Looks good to me.';
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: `✅ **OpenCodeReview**: ${message}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare PR review with inline comments
|
||||
const prNumber = context.issue.number;
|
||||
let commitSha = context.payload.pull_request.head.sha;
|
||||
|
||||
// Build review comments array for the PR review API
|
||||
// Only inline comments with line info can be posted via createReview
|
||||
const reviewComments = [];
|
||||
const commentsWithoutLine = [];
|
||||
|
||||
for (const comment of comments) {
|
||||
// Check if comment has valid line information for inline comment (line >= 1)
|
||||
const hasValidLine = (comment.start_line >= 1) || (comment.end_line >= 1);
|
||||
if (!hasValidLine) {
|
||||
commentsWithoutLine.push({ comment });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Each inline comment becomes an item carrying a random ID
|
||||
// (assigned once) and its resolved line targeting. The body is
|
||||
// built from item.id only at API-call time (see toReviewPayload),
|
||||
// so retry/idempotency logic reads item.id directly instead of
|
||||
// recomputing it, and distinct comments never share an ID.
|
||||
reviewComments.push({
|
||||
comment,
|
||||
id: newCommentId(),
|
||||
lines: resolveLines(comment)
|
||||
});
|
||||
}
|
||||
|
||||
// Submit as a single PR review with all comments
|
||||
const totalCount = comments.length;
|
||||
const inlineCount = reviewComments.length;
|
||||
const summaryCount = commentsWithoutLine.length;
|
||||
let summaryBody = buildSummaryBody(totalCount, inlineCount, summaryCount, warnings);
|
||||
|
||||
// Add comments without line info to summary body
|
||||
summaryBody += formatSummaryComments(commentsWithoutLine);
|
||||
|
||||
// Prepend the run tag so the idempotency check can detect whether the
|
||||
// batch review actually landed on the server before retrying.
|
||||
summaryBody = REVIEW_TAG + '\n' + summaryBody;
|
||||
|
||||
// Statistics tracking
|
||||
let successCount = 0;
|
||||
let failedCount = 0;
|
||||
const failedComments = [];
|
||||
|
||||
// Retry/pacing configuration (shared by write and read API calls).
|
||||
// parseNonNegInt guards against nonsensical env values (negative,
|
||||
// NaN, non-numeric) that `parseInt(...) || default` would let
|
||||
// through for negative numbers, since a negative parseInt result
|
||||
// is truthy and would bypass the `|| default` fallback.
|
||||
function parseNonNegInt(val, defaultVal) {
|
||||
const n = parseInt(val, 10);
|
||||
return Number.isFinite(n) && n >= 0 ? n : defaultVal;
|
||||
}
|
||||
const MAX_RETRIES = parseNonNegInt(process.env.OCR_MAX_RETRIES, 3);
|
||||
const SUCCESS_DELAY = parseNonNegInt(process.env.OCR_SUCCESS_DELAY, 2000); // delay after successful write
|
||||
const FAILURE_DELAY = parseNonNegInt(process.env.OCR_FAILURE_DELAY, 1000); // delay after non-retryable failure
|
||||
const LOW_REMAINING_THRESHOLD = parseNonNegInt(process.env.OCR_LOW_REMAINING_THRESHOLD, 3);
|
||||
const LOW_REMAINING_SPACING = parseNonNegInt(process.env.OCR_LOW_REMAINING_SPACING, 10000);
|
||||
// Read APIs are cheaper and have higher thresholds; use shorter pacing.
|
||||
const READ_SUCCESS_DELAY = parseNonNegInt(process.env.OCR_READ_SUCCESS_DELAY, 500);
|
||||
const READ_LOW_REMAINING_SPACING = parseNonNegInt(process.env.OCR_READ_LOW_REMAINING_SPACING, 5000);
|
||||
|
||||
try {
|
||||
const batchRes = await github.rest.pulls.createReview({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: prNumber,
|
||||
commit_id: commitSha,
|
||||
body: summaryBody,
|
||||
event: 'COMMENT',
|
||||
comments: reviewComments.map(toReviewPayload)
|
||||
});
|
||||
successCount = reviewComments.length;
|
||||
console.log(`Successfully posted review with ${successCount} inline comments (${commentsWithoutLine.length} in summary)`);
|
||||
logRateLimitQuota(batchRes, 'after batch createReview');
|
||||
} catch (e) {
|
||||
console.log('Failed to post review with inline comments:', e.message);
|
||||
console.log('Checking whether the batch review actually landed on the server before retrying...');
|
||||
|
||||
// Idempotency check: the batch createReview may have succeeded on the
|
||||
// server even though we got a 5xx. Query existing reviews to find out,
|
||||
// so we only retry the comments that are actually missing.
|
||||
let existingReview = null;
|
||||
try {
|
||||
existingReview = await findExistingBatchReview({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
prNumber,
|
||||
tag: REVIEW_TAG
|
||||
});
|
||||
} catch (checkErr) {
|
||||
console.log(`Idempotency check failed (${checkErr.message}). ` +
|
||||
`Degrading to original fallback (accepting duplicate risk).`);
|
||||
}
|
||||
|
||||
// Compute the list of inline comments that still need to be posted.
|
||||
// If the batch review landed, only retry the missing ones; otherwise
|
||||
// retry all of them.
|
||||
let toRetry = reviewComments;
|
||||
if (existingReview && existingReview.found) {
|
||||
const postedIds = await getPostedCommentIds({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
prNumber
|
||||
});
|
||||
toRetry = reviewComments.filter((item) =>
|
||||
!postedIds.has(item.id)
|
||||
);
|
||||
successCount = reviewComments.length - toRetry.length;
|
||||
console.log(`Batch review already exists (review_id=${existingReview.review.id}). ` +
|
||||
`${successCount}/${reviewComments.length} inline comments already posted. ` +
|
||||
`${toRetry.length} missing, will retry only those.`);
|
||||
} else {
|
||||
console.log('Batch review not found on server. Falling back to per-comment posting...');
|
||||
}
|
||||
|
||||
// If the batch itself was rate-limited, honor its rate-limit headers
|
||||
// (retry-after / x-ratelimit-reset) before retrying per-comment,
|
||||
// otherwise the first per-comment call re-hits the same wall immediately.
|
||||
const batchRetry = computeRetryDelayMs(e, 0);
|
||||
if (batchRetry != null) {
|
||||
const secs = (batchRetry.delayMs / 1000).toFixed(1);
|
||||
console.log(
|
||||
`Batch createReview was rate-limited (HTTP ${e.status}). ` +
|
||||
`Cooling down ${secs}s via '${batchRetry.source}' (${batchRetry.detail}) before per-comment retry.`
|
||||
);
|
||||
await sleep(batchRetry.delayMs);
|
||||
}
|
||||
|
||||
for (const item of toRetry) {
|
||||
const { comment, id } = item;
|
||||
let posted = false;
|
||||
for (let attempt = 0; attempt <= MAX_RETRIES && !posted; attempt++) {
|
||||
try {
|
||||
const res = await github.rest.pulls.createReview({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: prNumber,
|
||||
commit_id: commitSha,
|
||||
body: '',
|
||||
event: 'COMMENT',
|
||||
comments: [toReviewPayload(item)]
|
||||
});
|
||||
successCount++;
|
||||
posted = true;
|
||||
console.log(`Successfully posted comment for ${comment.path}`);
|
||||
// Proactive throttle: if remaining quota is low, slow down to
|
||||
// avoid hitting the limit (GitHub best practice: watch the header).
|
||||
const remaining = logRateLimitQuota(res, `after ${comment.path}`);
|
||||
const lowQuota = remaining != null && remaining <= LOW_REMAINING_THRESHOLD;
|
||||
if (lowQuota) {
|
||||
console.log(`[rate-limit] quota low (remaining=${remaining} <= ${LOW_REMAINING_THRESHOLD}); increasing spacing to ${LOW_REMAINING_SPACING}ms.`);
|
||||
await sleep(LOW_REMAINING_SPACING);
|
||||
} else {
|
||||
await sleep(SUCCESS_DELAY);
|
||||
}
|
||||
} catch (innerE) {
|
||||
// Decide whether to retry and how long to wait, based on GitHub's
|
||||
// rate-limit documentation (retry-after / x-ratelimit-* headers).
|
||||
const retryInfo = computeRetryDelayMs(innerE, attempt);
|
||||
const willRetry = retryInfo != null && attempt < MAX_RETRIES;
|
||||
// Any error whose request may have reached GitHub (5xx server
|
||||
// errors, 408 timeout, or network-layer errors with no status)
|
||||
// can mean the comment was actually created but the response was
|
||||
// lost. Before retrying (which would post a duplicate) or before
|
||||
// giving up (which would wrongly list it as failed in the summary),
|
||||
// we must check whether it already landed.
|
||||
//
|
||||
// IMPORTANT: do the check AFTER cooling down, not immediately.
|
||||
// If the error is rate-limit-related (5xx under load, or a
|
||||
// network blip), firing read requests right away further
|
||||
// pressures the already-struggling API. Honor the computed
|
||||
// retry delay first, then query.
|
||||
const status = innerE.status;
|
||||
const maybeReachedServer =
|
||||
(typeof status === 'number' && (status >= 500 || status === 408)) ||
|
||||
status == null; // network errors (ECONNRESET, ETIMEDOUT, ...)
|
||||
if (maybeReachedServer) {
|
||||
// Cool down first: even read requests count against rate
|
||||
// limits, and querying during an ongoing 5xx/rate-limit
|
||||
// episode can worsen the situation. Use the retry delay when
|
||||
// available; for non-retryable errors (retryInfo == null)
|
||||
// there is no header-derived wait, so use a short fixed cool
|
||||
// down before the read.
|
||||
const coolDownMs = retryInfo != null ? retryInfo.delayMs : FAILURE_DELAY;
|
||||
if (coolDownMs > 0) {
|
||||
const secs = (coolDownMs / 1000).toFixed(1);
|
||||
console.log(
|
||||
`Cooling down ${secs}s before idempotency check for ${comment.path} ` +
|
||||
`(HTTP ${innerE.status || 'n/a'}, attempt ${attempt + 1}/${MAX_RETRIES + 1}).`
|
||||
);
|
||||
await sleep(coolDownMs);
|
||||
}
|
||||
const alreadyPosted = await isCommentAlreadyPosted({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
prNumber,
|
||||
id
|
||||
});
|
||||
if (alreadyPosted === true) {
|
||||
successCount++;
|
||||
posted = true;
|
||||
console.log(`Comment for ${comment.path} already posted (id=${id}); treating as success.`);
|
||||
await sleep(SUCCESS_DELAY);
|
||||
continue;
|
||||
}
|
||||
// Unknown (null): the read API is unavailable, so we
|
||||
// cannot tell whether the comment landed. To avoid a
|
||||
// duplicate, do NOT retry posting; record as failed so
|
||||
// the summary surfaces the uncertainty rather than
|
||||
// silently risking a duplicate.
|
||||
if (alreadyPosted === null) {
|
||||
failedCount++;
|
||||
const reason = 'idempotency check unavailable (read API failed)';
|
||||
failedComments.push({ comment, error: `${innerE.message} [${reason}]` });
|
||||
console.log(`Cannot verify whether comment for ${comment.path} was posted (${reason}, HTTP ${innerE.status || 'n/a'}); skipping retry to avoid duplicate.`);
|
||||
await sleep(SUCCESS_DELAY);
|
||||
break;
|
||||
}
|
||||
// Not found on server. If retries are exhausted or the
|
||||
// error is non-retryable, this is a real failure.
|
||||
if (!willRetry) {
|
||||
failedCount++;
|
||||
failedComments.push({ comment, error: innerE.message });
|
||||
const reason = retryInfo == null ? 'non-retryable error' : 'rate-limit retries exhausted';
|
||||
console.log(`Failed to post comment for ${comment.path} (${reason}, HTTP ${innerE.status || 'n/a'}): ${innerE.message}`);
|
||||
await sleep(SUCCESS_DELAY);
|
||||
break;
|
||||
}
|
||||
// willRetry: cool down already consumed above, loop back.
|
||||
} else if (willRetry) {
|
||||
// Pure 429/403 rate-limit: the request never reached the
|
||||
// server, so no duplicate is possible and the idempotency
|
||||
// check can be skipped. Just honor the retry delay.
|
||||
const secs = (retryInfo.delayMs / 1000).toFixed(1);
|
||||
console.log(
|
||||
`Rate-limited on ${comment.path} ` +
|
||||
`(HTTP ${innerE.status}, attempt ${attempt + 1}/${MAX_RETRIES}). ` +
|
||||
`Waiting ${secs}s via '${retryInfo.source}' (${retryInfo.detail}). ` +
|
||||
`Error: ${innerE.message}`
|
||||
);
|
||||
await sleep(retryInfo.delayMs);
|
||||
} else {
|
||||
// Non-retryable error that definitely did not reach the
|
||||
// server (e.g. 4xx validation error): record as failed.
|
||||
failedCount++;
|
||||
failedComments.push({ comment, error: innerE.message });
|
||||
console.log(`Failed to post comment for ${comment.path} (non-retryable error, HTTP ${innerE.status || 'n/a'}): ${innerE.message}`);
|
||||
await sleep(FAILURE_DELAY);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Post summary comment with statistics
|
||||
let finalBody = buildSummaryBody(totalCount, successCount, commentsWithoutLine.length + failedComments.length, warnings);
|
||||
finalBody += formatSummaryComments(commentsWithoutLine);
|
||||
finalBody += `\n\n---\n\n📊 **Posting Statistics:**`;
|
||||
finalBody += `\n- ✅ Successfully posted: ${successCount} comment(s)`;
|
||||
if (failedCount > 0) {
|
||||
finalBody += `\n- ❌ Failed to post: ${failedCount} comment(s)`;
|
||||
}
|
||||
|
||||
// Add failed comments as summary content so review feedback is not lost.
|
||||
if (failedComments.length > 0) {
|
||||
finalBody += '\n\n---\n\n### ⚠️ Inline comments shown in summary';
|
||||
for (const { comment, error } of failedComments) {
|
||||
finalBody += '\n\n---\n\n';
|
||||
finalBody += formatCommentMarkdown(comment, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Prepend the summary tag and post only if no summary with this tag
|
||||
// already exists (idempotency: the batch review may have carried the
|
||||
// same summary body, in which case we must not duplicate it).
|
||||
finalBody = SUMMARY_TAG + '\n' + finalBody;
|
||||
const summaryAlreadyPosted = await hasIssueCommentWithId({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issueNumber: prNumber,
|
||||
id: SUMMARY_TAG
|
||||
});
|
||||
if (summaryAlreadyPosted === true) {
|
||||
console.log('Summary comment with this run tag already exists; skipping.');
|
||||
} else if (summaryAlreadyPosted === null) {
|
||||
// Read API unavailable: cannot tell whether the summary already
|
||||
// landed. Skip posting to avoid a duplicate; the review content
|
||||
// is still available via inline comments / batch review.
|
||||
console.log('Cannot verify whether summary comment already exists (read API failed); skipping to avoid duplicate.');
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
body: finalBody
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// Retry wrapper shared by write and read API calls. Reuses
|
||||
// computeRetryDelayMs so rate-limit headers (retry-after /
|
||||
// x-ratelimit-*) are honored uniformly. Throws on final failure
|
||||
// so the caller can decide how to degrade.
|
||||
async function withRetry(tag, fn) {
|
||||
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (e) {
|
||||
const retryInfo = computeRetryDelayMs(e, attempt);
|
||||
const willRetry = retryInfo != null && attempt < MAX_RETRIES;
|
||||
if (willRetry) {
|
||||
const secs = (retryInfo.delayMs / 1000).toFixed(1);
|
||||
console.log(
|
||||
`[${tag}] transient/rate-limited (HTTP ${e.status}, attempt ${attempt + 1}/${MAX_RETRIES}). ` +
|
||||
`Waiting ${secs}s via '${retryInfo.source}' (${retryInfo.detail}). ${e.message}`
|
||||
);
|
||||
await sleep(retryInfo.delayMs);
|
||||
} else {
|
||||
console.log(`[${tag}] failed after ${attempt + 1} attempts: ${e.message}`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Read API wrapper with retry + proactive pacing. Read requests are
|
||||
// cheaper than writes but still consume the primary rate limit and can
|
||||
// trigger the secondary limit when issued in a tight loop. Use shorter
|
||||
// delays than writes (READ_SUCCESS_DELAY / READ_LOW_REMAINING_SPACING).
|
||||
async function readWithPacing(tag, fn) {
|
||||
const res = await withRetry(tag, fn);
|
||||
const remaining = logRateLimitQuota(res, tag);
|
||||
const lowQuota = remaining != null && remaining <= LOW_REMAINING_THRESHOLD;
|
||||
if (lowQuota) {
|
||||
console.log(`[rate-limit] quota low after read (${remaining} <= ${LOW_REMAINING_THRESHOLD}); spacing ${READ_LOW_REMAINING_SPACING}ms.`);
|
||||
await sleep(READ_LOW_REMAINING_SPACING);
|
||||
} else {
|
||||
await sleep(READ_SUCCESS_DELAY);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
// Paginated helper that walks all pages of a list endpoint with retry
|
||||
// and pacing. Returns the concatenated array of items.
|
||||
async function readAllPages(tag, pageFn, maxPages = 50) {
|
||||
if (!Number.isFinite(maxPages) || maxPages < 1) {
|
||||
throw new Error(`readAllPages: maxPages must be a positive integer, got ${maxPages}`);
|
||||
}
|
||||
const all = [];
|
||||
let page = 1;
|
||||
const PER_PAGE = 100;
|
||||
while (page <= maxPages) {
|
||||
const res = await readWithPacing(`${tag} (page ${page})`, () => pageFn(page, PER_PAGE));
|
||||
const items = res.data || [];
|
||||
all.push(...items);
|
||||
if (items.length < PER_PAGE) break;
|
||||
page++;
|
||||
}
|
||||
// NOTE: Truncation here is intentional and acts as a safety
|
||||
// valve against unbounded loops (e.g. a bug or malicious
|
||||
// activity), not as a normal operating mode. A PR accumulating
|
||||
// >5000 review comments is far outside expected usage; in that
|
||||
// rare case we log a warning and proceed with partial data
|
||||
// rather than failing the whole review.
|
||||
//
|
||||
// Caveat: this is NOT the same as a read failure. When the read
|
||||
// API throws (rate limit, 5xx), isCommentAlreadyPosted and
|
||||
// hasIssueCommentWithId catch it and return null (unknown), so
|
||||
// the caller skips retrying and creates no duplicate. A
|
||||
// truncated walk does not throw; it returns a partial set
|
||||
// silently, so isCommentAlreadyPosted returns false (definitively
|
||||
// "not posted") for any comment beyond the cap, and the retry
|
||||
// loop will repost it, producing a duplicate. This tradeoff is
|
||||
// accepted because the trigger is far outside expected usage; if
|
||||
// that ceiling ever needs to rise, make maxPages configurable.
|
||||
if (page > maxPages) {
|
||||
console.log(`[${tag}] reached max page limit (${maxPages}); results may be incomplete.`);
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
// Idempotency check: find whether a batch review with this run tag
|
||||
// already exists on the PR. Returns { found, review } or throws on
|
||||
// final failure (caller degrades to original fallback).
|
||||
async function findExistingBatchReview({ owner, repo, prNumber, tag }) {
|
||||
const reviews = await readAllPages('listReviews', (page, per_page) =>
|
||||
github.rest.pulls.listReviews({ owner, repo, pull_number: prNumber, per_page, page })
|
||||
);
|
||||
for (const r of reviews) {
|
||||
if ((r.body || '').includes(tag)) {
|
||||
return { found: true, review: r };
|
||||
}
|
||||
}
|
||||
return { found: false };
|
||||
}
|
||||
|
||||
// Collect the set of comment-level IDs already posted on the PR
|
||||
// (across all reviews). Uses listReviewComments (PR-level, cross-review)
|
||||
// so a single paginated walk covers everything, avoiding the O(missing)
|
||||
// amplification of per-comment lookups.
|
||||
async function getPostedCommentIds({ owner, repo, prNumber }) {
|
||||
const comments = await readAllPages('listReviewComments', (page, per_page) =>
|
||||
github.rest.pulls.listReviewComments({ owner, repo, pull_number: prNumber, per_page, page })
|
||||
);
|
||||
const ids = new Set();
|
||||
// Anchor the regex to the HTML comment wrapper (<!-- ocr-... -->)
|
||||
// so user-generated content or code suggestions cannot trigger
|
||||
// false positives in the idempotency check. The ID format is
|
||||
// `ocr-<RUN_TAG>-<random>` where RUN_TAG is `<runId>-<runAttempt>`
|
||||
// and <random> is a per-comment random hex token. Capture group 1
|
||||
// holds the bare ID (ocr-<RUN_TAG>-<random>), so we can add it
|
||||
// directly without stripping comment markers.
|
||||
const ID_RE = /<!--\s*(ocr-\d+-\d+-[a-f0-9]+)\s*-->/g;
|
||||
for (const c of comments) {
|
||||
const body = c.body || '';
|
||||
let m;
|
||||
while ((m = ID_RE.exec(body)) !== null) {
|
||||
ids.add(m[1]);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
// Check whether a specific comment-level ID has already landed on the
|
||||
// server. Used by the per-comment retry loop: when a createReview call
|
||||
// fails with a transient 5xx/408, the request may have reached GitHub
|
||||
// and succeeded even though the response was lost. Querying before
|
||||
// retrying prevents posting a duplicate inline comment.
|
||||
// Returns true/false when the check succeeds, or null when the
|
||||
// read API is unavailable (rate limit, 5xx, etc.). Returning null
|
||||
// (rather than defaulting to false) prevents the caller from
|
||||
// assuming the comment was not posted and risking a duplicate on
|
||||
// retry.
|
||||
//
|
||||
// Each call walks listReviewComments fresh — no cached snapshot.
|
||||
// A snapshot reused across retries would go stale as comments land
|
||||
// during the loop, and a stale miss for a 5xx-landed comment would
|
||||
// trigger a retry that posts a duplicate. Read calls are paced via
|
||||
// readAllPages/readWithPacing and degrade to null (skip retry) if the
|
||||
// read API itself fails, so the extra walks cannot produce duplicates.
|
||||
async function isCommentAlreadyPosted({ owner, repo, prNumber, id }) {
|
||||
try {
|
||||
const posted = await getPostedCommentIds({ owner, repo, prNumber });
|
||||
return posted.has(id);
|
||||
} catch (e) {
|
||||
console.log(`[isCommentAlreadyPosted] check failed for ${id} (${e.message}); treating as unknown to avoid duplicates.`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Check whether an issue comment with the given tag already exists.
|
||||
// Used to avoid posting a duplicate summary comment when the batch
|
||||
// review already carried the same summary body.
|
||||
// Returns true/false when the check succeeds, or null when the
|
||||
// read API is unavailable. Returning null (rather than defaulting
|
||||
// to false) lets the caller decide whether to skip posting or
|
||||
// degrade gracefully, instead of silently risking a duplicate
|
||||
// summary comment.
|
||||
async function hasIssueCommentWithId({ owner, repo, issueNumber, id }) {
|
||||
try {
|
||||
const comments = await readAllPages('listIssueComments', (page, per_page) =>
|
||||
github.rest.issues.listComments({ owner, repo, issue_number: issueNumber, per_page, page })
|
||||
);
|
||||
// Match the tag anchored to its HTML comment wrapper for
|
||||
// consistency with getPostedCommentIds and to defend against
|
||||
// user content that happens to contain the bare tag string.
|
||||
// `id` is an opaque tag like `<!-- ocr-summary-run:... -->`,
|
||||
// so escape any regex metacharacters before embedding it.
|
||||
const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const tagRe = new RegExp('<!--\\s*' + escaped + '\\s*-->');
|
||||
return comments.some(c => tagRe.test(c.body || ''));
|
||||
} catch (e) {
|
||||
console.log(`[listIssueComments] check failed (${e.message}); treating as unknown to avoid duplicates.`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Case-insensitive header lookup. Octokit normalizes response headers to
|
||||
// lowercase, but this defensive check also handles original casing so that
|
||||
// quota logging and retry delay computation never silently miss a header.
|
||||
function getHeader(headers, name) {
|
||||
const v = headers[name] != null ? headers[name] : headers[name.toLowerCase()];
|
||||
return v != null ? String(v).trim() : undefined;
|
||||
}
|
||||
|
||||
// Decide whether an error is worth retrying and, if so, how long to wait.
|
||||
// Implements GitHub's documented rate-limit retry strategy using the
|
||||
// response headers (retry-after, x-ratelimit-remaining, x-ratelimit-reset).
|
||||
// Returns { delayMs, source, detail } when retryable, or null otherwise.
|
||||
// See: https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api
|
||||
function computeRetryDelayMs(error, attempt) {
|
||||
if (!error) return null;
|
||||
const status = error.status;
|
||||
const message = String(error.message || '');
|
||||
const isRateLimit = status === 429 || (status === 403 && /rate limit|abuse|secondary/i.test(message));
|
||||
const isTransient = (status >= 500 && status < 600) || status === 408;
|
||||
if (!isRateLimit && !isTransient) return null;
|
||||
|
||||
const headers = ((error.response || {}).headers) || {};
|
||||
const header = (name) => getHeader(headers, name);
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
|
||||
// The absolute maximum wait for any single retry. Header-derived waits
|
||||
// (retry-after / x-ratelimit-reset) are GitHub's recommended durations,
|
||||
// but capping them prevents a far-future reset from stalling the CI job
|
||||
// past its timeout. When we cap, the next retry may re-hit the limit.
|
||||
const cap = parseInt(process.env.OCR_RETRY_MAX_DELAY, 10) || 300000;
|
||||
const base = parseInt(process.env.OCR_RETRY_BASE_DELAY, 10) || 60000;
|
||||
|
||||
// { rawMs, source, detail } describing the recommended wait before cap.
|
||||
let info = null;
|
||||
|
||||
if (isRateLimit) {
|
||||
// (1) Honor "retry-after" when present (seconds, or an HTTP-date).
|
||||
const retryAfter = header('retry-after');
|
||||
if (retryAfter) {
|
||||
const secs = Number(retryAfter);
|
||||
if (!isNaN(secs) && secs >= 0) {
|
||||
info = { rawMs: secs * 1000, source: 'retry-after', detail: `${secs}s (from header)` };
|
||||
} else {
|
||||
const dateMs = Date.parse(retryAfter);
|
||||
if (!isNaN(dateMs)) {
|
||||
info = { rawMs: Math.max(0, dateMs - Date.now()), source: 'retry-after (HTTP-date)', detail: retryAfter };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// (2) Primary limit exhausted (x-ratelimit-remaining=0): wait until reset.
|
||||
if (!info) {
|
||||
const remaining = header('x-ratelimit-remaining');
|
||||
const reset = header('x-ratelimit-reset');
|
||||
if (reset != null && Number(remaining) === 0) {
|
||||
const rawMs = Math.max(0, Number(reset) - nowSec) * 1000;
|
||||
info = { rawMs, source: 'x-ratelimit-reset', detail: `remaining=0, reset epoch=${reset} (in ${Math.ceil(rawMs / 1000)}s)` };
|
||||
}
|
||||
}
|
||||
|
||||
// (3) Secondary limit with no retry hint: docs say wait at least one
|
||||
// minute, then increase exponentially between retries.
|
||||
if (!info) {
|
||||
const backoff = Math.min(base * Math.pow(2, attempt), cap);
|
||||
const jitter = Math.floor(Math.random() * 1000);
|
||||
info = { rawMs: backoff + jitter, source: 'exponential-backoff', detail: `base=${base}ms*2^${attempt} (cap ${cap}ms) +${jitter}ms jitter` };
|
||||
}
|
||||
} else {
|
||||
// Transient server error (5xx / 408): back off without the 60s floor.
|
||||
// Use a shorter base than the rate-limit path: server hiccups are
|
||||
// typically short-lived, so a 2s initial wait (doubling per retry)
|
||||
// is sufficient and avoids stalling the CI job unnecessarily.
|
||||
const transientBase = 2000;
|
||||
const backoff = Math.min(transientBase * Math.pow(2, attempt), cap);
|
||||
const jitter = Math.floor(Math.random() * 1000);
|
||||
info = { rawMs: backoff + jitter, source: 'transient-backoff', detail: `base=${transientBase}ms*2^${attempt} (cap ${cap}ms) +${jitter}ms jitter (HTTP ${status})` };
|
||||
}
|
||||
|
||||
// Apply the universal cap to header-derived waits too.
|
||||
const delayMs = Math.min(info.rawMs, cap);
|
||||
if (delayMs < info.rawMs) {
|
||||
info.detail += ` [CAPPED to ${cap}ms; GitHub recommended ${Math.ceil(info.rawMs / 1000)}s]`;
|
||||
}
|
||||
return { delayMs, source: info.source, detail: info.detail };
|
||||
}
|
||||
|
||||
// Best-effort logging of remaining rate-limit quota from a successful response.
|
||||
// Returns the parsed x-ratelimit-remaining value (or null) for proactive throttling.
|
||||
function logRateLimitQuota(response, tag) {
|
||||
try {
|
||||
const h = (response && response.headers) || {};
|
||||
const header = (name) => getHeader(h, name);
|
||||
const remaining = header('x-ratelimit-remaining');
|
||||
const limit = header('x-ratelimit-limit');
|
||||
const reset = header('x-ratelimit-reset');
|
||||
if (remaining != null) {
|
||||
console.log(
|
||||
`[rate-limit] ${tag}: remaining=${remaining}/${limit != null ? limit : '?'}` +
|
||||
(reset != null ? `, reset epoch=${reset}` : '')
|
||||
);
|
||||
}
|
||||
return remaining != null ? Number(remaining) : null;
|
||||
} catch (_) { return null; }
|
||||
}
|
||||
|
||||
// Random per-comment ID, assigned once when the inline-comment item
|
||||
// is built and carried on the item struct. Random (rather than
|
||||
// content-derived) so two distinct comments that share the same
|
||||
// path/line/content still get different IDs and the idempotency
|
||||
// check never mistakes one for the other (which would silently drop
|
||||
// the second). Embedded in the comment body as an HTML comment so
|
||||
// getPostedCommentIds can match it back on retry.
|
||||
function newCommentId() {
|
||||
return `ocr-${RUN_TAG}-${crypto.randomBytes(8).toString('hex')}`;
|
||||
}
|
||||
|
||||
// Resolve the line-targeting fields for a createReview comment
|
||||
// payload (start_line/line/start_side/side) from the comment's line
|
||||
// range. Returned object is spread into the payload in toReviewPayload.
|
||||
function resolveLines(comment) {
|
||||
const start = comment.start_line;
|
||||
const end = comment.end_line;
|
||||
if (start >= 1 && end >= 1 && start !== end) {
|
||||
return { start_line: start, line: end, start_side: 'RIGHT', side: 'RIGHT' };
|
||||
} else if (end >= 1) {
|
||||
return { line: end, side: 'RIGHT' };
|
||||
} else if (start >= 1) {
|
||||
return { line: start, side: 'RIGHT' };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
// Build the createReview payload for an inline-comment item. The
|
||||
// body is assembled here (at call time) from the item's precomputed
|
||||
// ID, so retry/idempotency logic works directly off item.id instead
|
||||
// of recomputing an ID each time it needs to check posting status.
|
||||
function toReviewPayload(item) {
|
||||
return {
|
||||
path: item.comment.path,
|
||||
body: buildBody(item.comment, item.id),
|
||||
...item.lines
|
||||
};
|
||||
}
|
||||
|
||||
// Assemble the visible comment body: the per-comment ID tag (HTML
|
||||
// comment, invisible when rendered) prepended for idempotency
|
||||
// matching, plus the code suggestion block if present.
|
||||
function buildBody(comment, id) {
|
||||
let body = `<!-- ${id} -->\n`;
|
||||
body += comment.content || '';
|
||||
if (comment.suggestion_code && comment.existing_code) {
|
||||
body += '\n\n**Suggestion:**\n';
|
||||
body += fencedBlock(comment.suggestion_code, 'suggestion');
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
function formatCommentMarkdown(comment, error) {
|
||||
let md = `### 📄 \`${comment.path}\``;
|
||||
if (comment.start_line && comment.end_line) {
|
||||
md += ` (L${comment.start_line}-L${comment.end_line})`;
|
||||
}
|
||||
md += '\n\n';
|
||||
if (error) {
|
||||
md += `⚠️ GitHub could not post this as an inline comment: ${error}\n\n`;
|
||||
}
|
||||
md += comment.content || '';
|
||||
|
||||
if (comment.suggestion_code && comment.existing_code) {
|
||||
md += '\n\n<details><summary>💡 Suggested Change</summary>\n\n';
|
||||
md += '**Before:**\n' + fencedBlock(comment.existing_code) + '\n\n';
|
||||
md += '**After:**\n' + fencedBlock(comment.suggestion_code) + '\n\n';
|
||||
md += '</details>';
|
||||
}
|
||||
|
||||
return md;
|
||||
}
|
||||
|
||||
function buildSummaryBody(totalCount, inlineCount, summaryCount, warnings) {
|
||||
let body = `🔍 **OpenCodeReview** found **${totalCount}** issue(s) in this PR.`;
|
||||
if (totalCount > 0) {
|
||||
body += `\n- ✅ ${inlineCount} posted as inline comment(s)`;
|
||||
body += `\n- 📝 ${summaryCount} posted as summary`;
|
||||
}
|
||||
if (warnings.length > 0) {
|
||||
body += `\n\n⚠️ ${warnings.length} warning(s) occurred during review.`;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
function formatSummaryComments(summaryComments) {
|
||||
let body = '';
|
||||
for (const { comment } of summaryComments) {
|
||||
body += '\n\n---\n\n';
|
||||
body += formatCommentMarkdown(comment);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
function fencedBlock(content, language = '') {
|
||||
const text = String(content || '');
|
||||
const fence = safeFence(text);
|
||||
let block = fence + language + '\n' + text;
|
||||
if (!text.endsWith('\n')) block += '\n';
|
||||
return block + fence;
|
||||
}
|
||||
|
||||
function safeFence(content) {
|
||||
const matches = String(content || '').match(/`+/g) || [];
|
||||
const maxTicks = matches.reduce((max, ticks) => Math.max(max, ticks.length), 0);
|
||||
return '`'.repeat(Math.max(3, maxTicks + 1));
|
||||
}
|
||||
llm_url: ${{ secrets.OCR_LLM_URL }}
|
||||
llm_auth_token: ${{ secrets.OCR_LLM_AUTH_TOKEN }}
|
||||
llm_model: ${{ secrets.OCR_LLM_MODEL }}
|
||||
llm_use_anthropic: ${{ secrets.OCR_LLM_USE_ANTHROPIC }}
|
||||
llm_extra_body: '{"enable_thinking": false}'
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
sticky_summary: 'true'
|
||||
incremental: 'false'
|
||||
upload_artifacts: 'true'
|
||||
|
|
|
|||
|
|
@ -434,6 +434,21 @@ JSON出力ではこの2つのフィールドは`content`や`start_line`などと
|
|||
- [`gitlab_ci/`](./examples/gitlab_ci/) — GitLab CI統合の例
|
||||
- [`gitflic_ci/`](./examples/gitflic_ci/) — GitFlic CI統合の例
|
||||
|
||||
#### GitHub Action
|
||||
|
||||
GitHub 向けに、本リポジトリはリポジトリルートにすぐ使える composite Action([`action.yml`](./action.yml))を同梱しています。自分で `ocr review` をスクリプト化する代わりに、これを直接参照するだけで、checkout、OCR のインストール、レビューの実行、インラインコメントとサマリーコメントの投稿、アーティファクトのアップロード、再試行・冪等性までの全パイプラインを処理できます:
|
||||
|
||||
```yaml
|
||||
- uses: alibaba/open-code-review@main
|
||||
with:
|
||||
llm_url: ${{ secrets.OCR_LLM_URL }}
|
||||
llm_auth_token: ${{ secrets.OCR_LLM_AUTH_TOKEN }}
|
||||
llm_model: ${{ vars.OCR_LLM_MODEL }}
|
||||
llm_use_anthropic: ${{ vars.OCR_LLM_USE_ANTHROPIC }}
|
||||
```
|
||||
|
||||
再現性を高めるため、バージョンタグまたはコミット SHA に固定してください。完全なワークフローデモ、inputs/outputs の全一覧、コメント投稿モード(スティッキーサマリー、非破壊的なインクリメンタル投稿)については [`examples/github_actions/`](./examples/github_actions/) ディレクトリを参照してください。
|
||||
|
||||
## コマンド
|
||||
|
||||
| コマンド | エイリアス | 説明 |
|
||||
|
|
|
|||
|
|
@ -434,6 +434,21 @@ JSON 출력에서 두 field는 `content`, `start_line` 등과 같은 수준의 s
|
|||
- [`gitlab_ci/`](./examples/gitlab_ci/): GitLab CI 통합 예시
|
||||
- [`gitflic_ci/`](./examples/gitflic_ci/): GitFlic CI 통합 예시
|
||||
|
||||
#### GitHub Action
|
||||
|
||||
GitHub의 경우, 이 리포지터리는 루트에 바로 사용할 수 있는 composite Action([`action.yml`](./action.yml))을 제공합니다. 직접 `ocr review` 스크립트를 작성하는 대신 이를 참조하기만 하면 전체 파이프라인 — checkout, OCR 설치, review 실행, inline/summary comment 게시, artifact 업로드, 재시도 및 멱등성 — 을 모두 처리합니다:
|
||||
|
||||
```yaml
|
||||
- uses: alibaba/open-code-review@main
|
||||
with:
|
||||
llm_url: ${{ secrets.OCR_LLM_URL }}
|
||||
llm_auth_token: ${{ secrets.OCR_LLM_AUTH_TOKEN }}
|
||||
llm_model: ${{ vars.OCR_LLM_MODEL }}
|
||||
llm_use_anthropic: ${{ vars.OCR_LLM_USE_ANTHROPIC }}
|
||||
```
|
||||
|
||||
재현성을 위해 version tag나 commit SHA에 고정하세요. 전체 workflow 데모와 inputs/outputs, comment 게시 모드(sticky summary, incremental non-destructive posting)의 전체 목록은 [`examples/github_actions/`](./examples/github_actions/) 디렉터리를 참고하세요.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Alias | Description |
|
||||
|
|
|
|||
15
README.md
15
README.md
|
|
@ -436,6 +436,21 @@ See the [`examples/`](./examples/) directory for integration examples:
|
|||
- [`gitlab_ci/`](./examples/gitlab_ci/) — GitLab CI integration example
|
||||
- [`gitflic_ci/`](./examples/gitflic_ci/) — GitFlic CI integration example
|
||||
|
||||
#### GitHub Action
|
||||
|
||||
For GitHub, this repository also ships a ready-to-use composite Action at the repo root ([`action.yml`](./action.yml)). Instead of scripting `ocr review` yourself, reference it directly and it handles the full pipeline — checkout, OCR install, running the review, posting inline and summary comments, uploading artifacts, and retry/idempotency:
|
||||
|
||||
```yaml
|
||||
- uses: alibaba/open-code-review@main
|
||||
with:
|
||||
llm_url: ${{ secrets.OCR_LLM_URL }}
|
||||
llm_auth_token: ${{ secrets.OCR_LLM_AUTH_TOKEN }}
|
||||
llm_model: ${{ vars.OCR_LLM_MODEL }}
|
||||
llm_use_anthropic: ${{ vars.OCR_LLM_USE_ANTHROPIC }}
|
||||
```
|
||||
|
||||
Pin to a version tag or commit SHA for reproducibility. See the [`examples/github_actions/`](./examples/github_actions/) directory for a complete workflow demo and the full list of inputs, outputs, and comment-posting modes (sticky summary, incremental non-destructive posting).
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Alias | Description |
|
||||
|
|
|
|||
|
|
@ -436,6 +436,21 @@ ocr review \
|
|||
- [`gitlab_ci/`](./examples/gitlab_ci/) — пример интеграции с GitLab CI
|
||||
- [`gitflic_ci/`](./examples/gitflic_ci/) — пример интеграции с GitFlic CI
|
||||
|
||||
#### GitHub Action
|
||||
|
||||
Для GitHub в корне репозитория также поставляется готовая к использованию composite Action ([`action.yml`](./action.yml)). Вместо того чтобы вручную скриптовать `ocr review`, просто подключите её — она берёт на себя весь конвейер: checkout, установку OCR, запуск ревью, публикацию инлайн- и сводных комментариев, загрузку артефактов, а также повтор и идемпотентность:
|
||||
|
||||
```yaml
|
||||
- uses: alibaba/open-code-review@main
|
||||
with:
|
||||
llm_url: ${{ secrets.OCR_LLM_URL }}
|
||||
llm_auth_token: ${{ secrets.OCR_LLM_AUTH_TOKEN }}
|
||||
llm_model: ${{ vars.OCR_LLM_MODEL }}
|
||||
llm_use_anthropic: ${{ vars.OCR_LLM_USE_ANTHROPIC }}
|
||||
```
|
||||
|
||||
Для воспроизводимости зафиксируйте тег версии или SHA коммита. Полный демо-воркфлоу, а также полный список входов, выходов и режимов публикации комментариев (закреплённая сводка, инкрементальная неразрушающая публикация) см. в каталоге [`examples/github_actions/`](./examples/github_actions/).
|
||||
|
||||
## Команды
|
||||
|
||||
| Команда | Алиас | Описание |
|
||||
|
|
|
|||
|
|
@ -434,6 +434,21 @@ ocr review \
|
|||
- [`gitlab_ci/`](./examples/gitlab_ci/) — GitLab CI 集成示例
|
||||
- [`gitflic_ci/`](./examples/gitflic_ci/) — GitFlic CI 集成示例
|
||||
|
||||
#### GitHub Action
|
||||
|
||||
对于 GitHub,本仓库还在仓库根目录提供了一个开箱即用的 composite Action([`action.yml`](./action.yml))。你无需自己编写 `ocr review` 脚本,直接引用它即可完成完整流程——checkout、安装 OCR、执行审查、发布行内评论与汇总评论、上传 artifacts,以及重试与幂等处理:
|
||||
|
||||
```yaml
|
||||
- uses: alibaba/open-code-review@main
|
||||
with:
|
||||
llm_url: ${{ secrets.OCR_LLM_URL }}
|
||||
llm_auth_token: ${{ secrets.OCR_LLM_AUTH_TOKEN }}
|
||||
llm_model: ${{ vars.OCR_LLM_MODEL }}
|
||||
llm_use_anthropic: ${{ vars.OCR_LLM_USE_ANTHROPIC }}
|
||||
```
|
||||
|
||||
为保障可复现性,请固定到某个版本标签或 commit SHA。完整的 workflow 示例以及 inputs、outputs 与评论发布模式(置顶汇总、增量非破坏式发布)的完整列表,请参见 [`examples/github_actions/`](./examples/github_actions/) 目录。
|
||||
|
||||
## 命令
|
||||
|
||||
| 命令 | 别名 | 描述 |
|
||||
|
|
|
|||
305
action.yml
Normal file
305
action.yml
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
name: OpenCodeReview PR Review
|
||||
description: >-
|
||||
AI-powered GitHub PR review with inline comments, sticky summary, and
|
||||
incremental non-destructive posting.
|
||||
author: stay-foolish-forever
|
||||
branding:
|
||||
icon: eye
|
||||
color: green
|
||||
|
||||
inputs:
|
||||
llm_url:
|
||||
description: LLM API endpoint URL (mapped to env OCR_LLM_URL).
|
||||
required: true
|
||||
llm_auth_token:
|
||||
description: LLM auth token (mapped to env OCR_LLM_TOKEN).
|
||||
required: true
|
||||
llm_model:
|
||||
description: Model name (mapped to env OCR_LLM_MODEL).
|
||||
required: true
|
||||
llm_use_anthropic:
|
||||
description: "'true' for Anthropic Claude, 'false' for OpenAI-compatible APIs
|
||||
(mapped to env OCR_USE_ANTHROPIC). Required to force an explicit choice."
|
||||
required: true
|
||||
llm_auth_header:
|
||||
description: Custom auth header name (mapped to env OCR_LLM_AUTH_HEADER).
|
||||
required: false
|
||||
llm_extra_headers:
|
||||
description: Extra headers "K=V,K=V" (mapped to env OCR_LLM_EXTRA_HEADERS).
|
||||
required: false
|
||||
llm_extra_body:
|
||||
description: >-
|
||||
extra_body JSON for LLM requests. No env var exists for this, so it is
|
||||
written via `ocr config set llm.extra_body`.
|
||||
required: false
|
||||
default: '{"thinking": {"type": "disabled"}}'
|
||||
language:
|
||||
description: >-
|
||||
Review output language, written via `ocr config set language`
|
||||
(e.g. English, 中文). No env var exists for this.
|
||||
required: false
|
||||
default: 'English'
|
||||
llm_timeout:
|
||||
description: LLM request timeout in seconds (mapped to env OCR_LLM_TIMEOUT).
|
||||
required: false
|
||||
github_token:
|
||||
description: GitHub token used to post review comments.
|
||||
required: false
|
||||
default: ${{ github.token }}
|
||||
ocr_version:
|
||||
description: npm version spec for @alibaba-group/open-code-review.
|
||||
required: false
|
||||
default: latest
|
||||
review_concurrency:
|
||||
description: Value passed to `ocr review --concurrency`.
|
||||
required: false
|
||||
background:
|
||||
description: Value passed to `ocr review --background`.
|
||||
required: false
|
||||
rule:
|
||||
description: Path to a custom rules JSON file passed to `ocr review --rule`.
|
||||
required: false
|
||||
upload_artifacts:
|
||||
description: >-
|
||||
Upload raw JSON result and stderr as workflow artifacts. Must be the
|
||||
literal string 'true' or 'false' (quoted); the step gates on a string
|
||||
comparison, so an unquoted YAML boolean will not match.
|
||||
required: false
|
||||
default: 'true'
|
||||
sticky_summary:
|
||||
description: >-
|
||||
Summary dimension. true = update an existing summary comment in place
|
||||
(sticky) instead of posting a new one each run.
|
||||
required: false
|
||||
default: 'true'
|
||||
incremental:
|
||||
description: >-
|
||||
Incremental dimension. true = only append inline comments whose (path,
|
||||
line range) does not overlap an existing bot review comment. History is
|
||||
never deleted (non-destructive).
|
||||
required: false
|
||||
default: 'false'
|
||||
incremental_overlap_threshold:
|
||||
description: >-
|
||||
IoU (intersection-over-union) threshold used by incremental mode to decide
|
||||
whether a new multi-line comment overlaps an existing one. Two single-line
|
||||
comments match when on the same line; single- vs multi-line never match.
|
||||
Value in (0, 1]; ignored unless incremental is true.
|
||||
required: false
|
||||
default: '0.6'
|
||||
base_ref:
|
||||
description: >-
|
||||
Override the base ref. Provide this (and head_sha) when invoking from a
|
||||
non-PR event such as issue_comment.
|
||||
required: false
|
||||
head_sha:
|
||||
description: Override the head commit SHA (use with base_ref for comment triggers).
|
||||
required: false
|
||||
node_version:
|
||||
description: Node.js version for actions/setup-node.
|
||||
required: false
|
||||
default: '24'
|
||||
|
||||
outputs:
|
||||
comments_total:
|
||||
description: Total number of review comments generated by OCR.
|
||||
value: ${{ steps.post.outputs.comments_total }}
|
||||
comments_inline:
|
||||
description: Number of inline comments successfully posted.
|
||||
value: ${{ steps.post.outputs.comments_inline }}
|
||||
comments_skipped:
|
||||
description: Number of inline comments skipped by incremental mode (overlap with history).
|
||||
value: ${{ steps.post.outputs.comments_skipped }}
|
||||
comments_failed:
|
||||
description: Number of inline comments that failed to post.
|
||||
value: ${{ steps.post.outputs.comments_failed }}
|
||||
summary_comment_url:
|
||||
description: URL of the posted/updated summary comment, if any.
|
||||
value: ${{ steps.post.outputs.summary_comment_url }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Check git and Node.js
|
||||
id: check_deps
|
||||
shell: bash
|
||||
run: |
|
||||
if command -v git >/dev/null 2>&1; then
|
||||
echo "git_installed=true" >> "$GITHUB_OUTPUT"
|
||||
echo "git is already installed: $(git --version)"
|
||||
else
|
||||
echo "git_installed=false" >> "$GITHUB_OUTPUT"
|
||||
echo "git is not installed"
|
||||
fi
|
||||
if command -v node >/dev/null 2>&1; then
|
||||
echo "node_installed=true" >> "$GITHUB_OUTPUT"
|
||||
echo "node is already installed: $(node --version)"
|
||||
else
|
||||
echo "node_installed=false" >> "$GITHUB_OUTPUT"
|
||||
echo "node is not installed"
|
||||
fi
|
||||
|
||||
- name: Install git
|
||||
if: steps.check_deps.outputs.git_installed != 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y git
|
||||
elif command -v brew >/dev/null 2>&1; then
|
||||
brew install git
|
||||
elif command -v yum >/dev/null 2>&1; then
|
||||
sudo yum install -y git
|
||||
elif command -v apk >/dev/null 2>&1; then
|
||||
sudo apk add --no-cache git
|
||||
else
|
||||
echo "::error::Unable to install git: no supported package manager found"
|
||||
exit 1
|
||||
fi
|
||||
git --version
|
||||
|
||||
- name: Setup Node.js
|
||||
if: steps.check_deps.outputs.node_installed != 'true'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ inputs.node_version }}
|
||||
|
||||
- name: Resolve PR refs
|
||||
shell: bash
|
||||
env:
|
||||
INPUT_BASE_REF: ${{ inputs.base_ref }}
|
||||
INPUT_HEAD_SHA: ${{ inputs.head_sha }}
|
||||
EVENT_BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||
EVENT_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
BASE_REF="${INPUT_BASE_REF:-$EVENT_BASE_REF}"
|
||||
HEAD_SHA="${INPUT_HEAD_SHA:-$EVENT_HEAD_SHA}"
|
||||
echo "BASE_REF=$BASE_REF" >> "$GITHUB_ENV"
|
||||
echo "HEAD_SHA=$HEAD_SHA" >> "$GITHUB_ENV"
|
||||
echo "PR base ref: $BASE_REF"
|
||||
echo "PR head sha: $HEAD_SHA"
|
||||
|
||||
- name: Checkout base
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# Checkout the trusted base, not the PR head. OCR reviews the
|
||||
# base-to-head diff from git objects; the head commit's blobs are
|
||||
# fetched separately so they are resolvable without materializing
|
||||
# untrusted PR files into the working tree.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Fetch PR head (fork-safe)
|
||||
if: env.HEAD_SHA != ''
|
||||
shell: bash
|
||||
env:
|
||||
PR_NUM: ${{ github.event.pull_request.number || github.event.issue.number }}
|
||||
run: |
|
||||
if [ -n "$PR_NUM" ]; then
|
||||
git fetch origin "pull/${PR_NUM}/head"
|
||||
fi
|
||||
|
||||
- name: Compute merge-base
|
||||
shell: bash
|
||||
run: |
|
||||
git fetch origin "${BASE_REF}" 2>/dev/null || true
|
||||
MERGE_BASE=$(git merge-base "origin/${BASE_REF}" "${HEAD_SHA}" 2>/dev/null || echo "${HEAD_SHA}")
|
||||
echo "MERGE_BASE=$MERGE_BASE" >> "$GITHUB_ENV"
|
||||
echo "Reviewing ${HEAD_SHA} from merge-base ${MERGE_BASE} (base origin/${BASE_REF})"
|
||||
|
||||
- name: Install OpenCodeReview
|
||||
shell: bash
|
||||
env:
|
||||
OCR_VERSION: ${{ inputs.ocr_version }}
|
||||
run: |
|
||||
npm install -g "@alibaba-group/open-code-review@${OCR_VERSION}"
|
||||
echo "OpenCodeReview installed:"
|
||||
ocr version || true
|
||||
|
||||
- name: Configure OCR
|
||||
env:
|
||||
OCR_EXTRA_BODY: ${{ inputs.llm_extra_body }}
|
||||
OCR_LANGUAGE: ${{ inputs.language }}
|
||||
shell: bash
|
||||
run: |
|
||||
ocr config set llm.extra_body "$OCR_EXTRA_BODY"
|
||||
ocr config set language "$OCR_LANGUAGE"
|
||||
|
||||
- name: Run OpenCodeReview
|
||||
env:
|
||||
OCR_LLM_URL: ${{ inputs.llm_url }}
|
||||
OCR_LLM_TOKEN: ${{ inputs.llm_auth_token }}
|
||||
OCR_LLM_MODEL: ${{ inputs.llm_model }}
|
||||
OCR_USE_ANTHROPIC: ${{ inputs.llm_use_anthropic }}
|
||||
OCR_LLM_AUTH_HEADER: ${{ inputs.llm_auth_header }}
|
||||
OCR_LLM_EXTRA_HEADERS: ${{ inputs.llm_extra_headers }}
|
||||
OCR_LLM_TIMEOUT: ${{ inputs.llm_timeout }}
|
||||
OCR_REVIEW_CONCURRENCY: ${{ inputs.review_concurrency }}
|
||||
OCR_BACKGROUND: ${{ inputs.background }}
|
||||
OCR_RULE: ${{ inputs.rule }}
|
||||
shell: bash
|
||||
run: |
|
||||
ARGS=(--from "${MERGE_BASE}" --to "${HEAD_SHA}" --format json)
|
||||
[ -n "$OCR_REVIEW_CONCURRENCY" ] && ARGS+=(--concurrency "$OCR_REVIEW_CONCURRENCY")
|
||||
[ -n "$OCR_BACKGROUND" ] && ARGS+=(--background "$OCR_BACKGROUND")
|
||||
[ -n "$OCR_RULE" ] && ARGS+=(--rule "$OCR_RULE")
|
||||
set +e
|
||||
ocr review "${ARGS[@]}" > /tmp/ocr-result.json 2>/tmp/ocr-stderr.log
|
||||
OCR_EXIT_CODE=$?
|
||||
set -e
|
||||
echo "OCR_EXIT_CODE=$OCR_EXIT_CODE" >> "$GITHUB_ENV"
|
||||
echo "=== OCR result ==="
|
||||
cat /tmp/ocr-result.json
|
||||
echo "=== OCR stderr ==="
|
||||
cat /tmp/ocr-stderr.log
|
||||
|
||||
- name: Upload review artifacts
|
||||
if: ${{ always() && inputs.upload_artifacts == 'true' }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ocr-review-result-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: |
|
||||
/tmp/ocr-result.json
|
||||
/tmp/ocr-stderr.log
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Fail job on OCR error
|
||||
if: env.OCR_EXIT_CODE != '0'
|
||||
shell: bash
|
||||
run: |
|
||||
echo "ocr review exited with code ${OCR_EXIT_CODE}; see uploaded artifacts for details."
|
||||
exit "${OCR_EXIT_CODE}"
|
||||
|
||||
- name: Post review comments
|
||||
if: env.OCR_EXIT_CODE == '0'
|
||||
id: post
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
OCR_INCREMENTAL_OVERLAP_THRESHOLD: ${{ inputs.incremental_overlap_threshold }}
|
||||
with:
|
||||
github-token: ${{ inputs.github_token }}
|
||||
script: |
|
||||
// Locate the helper shipped alongside action.yml at runtime.
|
||||
// GITHUB_ACTION_PATH: correct for published (remote) actions and for
|
||||
// local actions when not running in a container.
|
||||
// GITHUB_WORKSPACE: correct for local `uses: ./` actions — under
|
||||
// self-hosted + container setups, GITHUB_ACTION_PATH points to the
|
||||
// host path (invisible inside the container), whereas GITHUB_WORKSPACE
|
||||
// is correctly mapped to /__w.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const REL = 'scripts/github-actions/post-review-comments.js';
|
||||
const roots = [process.env.GITHUB_ACTION_PATH, process.env.GITHUB_WORKSPACE].filter(Boolean);
|
||||
const helper = roots.map(r => path.resolve(r, REL)).find(p => fs.existsSync(p));
|
||||
if (!helper) throw new Error(`Could not locate ${REL}; searched roots: ${roots.join(', ')}`);
|
||||
const { runPostReviewComments } = require(helper);
|
||||
await runPostReviewComments({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
fs,
|
||||
resultPath: '/tmp/ocr-result.json',
|
||||
stderrPath: '/tmp/ocr-stderr.log',
|
||||
stickySummary: ${{ inputs.sticky_summary == 'true' }},
|
||||
incremental: ${{ inputs.incremental == 'true' }},
|
||||
incrementalOverlapThreshold: parseFloat(process.env.OCR_INCREMENTAL_OVERLAP_THRESHOLD),
|
||||
});
|
||||
|
|
@ -1,6 +1,40 @@
|
|||
# OpenCodeReview - GitHub Actions Demo
|
||||
# OpenCodeReview - GitHub Actions Workflow
|
||||
|
||||
This demo shows how to integrate OpenCodeReview into your GitHub Actions workflow to automatically review Pull Requests and post review comments.
|
||||
This directory provides a ready-to-use GitHub Actions workflow demo that integrates OpenCodeReview into your repository to automatically review Pull Requests and post inline review comments. Copy it into `.github/workflows/` and configure the required secrets/vars.
|
||||
|
||||
## Quick Start: `ocr-review.yml`
|
||||
|
||||
The simplest adoption path: this demo delegates every step — checkout, OCR install, review, comment posting, artifact upload — to the official reusable composite action at [`action.yml`](../../action.yml) via a single `uses: alibaba/open-code-review@main` step. It covers both automatic PR review (`pull_request_target: opened/synchronize/reopened`) and on-demand re-review via comments (`/open-code-review` or `@open-code-review`). No inline scripts to maintain — `@main` always runs the latest action; pin to a version tag or commit SHA when reproducibility matters.
|
||||
|
||||
```bash
|
||||
mkdir -p .github/workflows
|
||||
cp ocr-review.yml .github/workflows/ocr-review.yml
|
||||
```
|
||||
|
||||
The core of the demo is a single action step:
|
||||
|
||||
```yaml
|
||||
- uses: alibaba/open-code-review@main
|
||||
with:
|
||||
llm_url: ${{ secrets.OCR_LLM_URL }}
|
||||
llm_auth_token: ${{ secrets.OCR_LLM_AUTH_TOKEN }}
|
||||
llm_model: ${{ vars.OCR_LLM_MODEL }}
|
||||
llm_use_anthropic: ${{ vars.OCR_LLM_USE_ANTHROPIC }}
|
||||
```
|
||||
|
||||
See [`action.yml`](../../action.yml) for the full list of inputs, outputs, security guidance, and the four comment-posting modes (sticky summary + incremental).
|
||||
|
||||
## Running on a self-hosted runner
|
||||
|
||||
The demo above runs on GitHub-hosted runners (`runs-on: ubuntu-latest`) and pulls the action from `alibaba/open-code-review@main`. If you prefer to run OCR on your own self-hosted runner — to reach private network resources, keep LLM traffic on-prem, or avoid runner-minute costs — the OCR project itself does exactly this in its own CI.
|
||||
|
||||
See [`.github/workflows/ocr-review.yml`](../../.github/workflows/ocr-review.yml) for that workflow. It runs on `runs-on: self-hosted` inside a `node:24` container. One important caveat: it invokes the action with `uses: ./` only because `action.yml` lives in that same repository — that is an internal shortcut and will not resolve in your repo. As an external user, keep `uses: alibaba/open-code-review@main` (the runner fetches the action automatically); only the runner environment needs to change. What is worth borrowing from it:
|
||||
|
||||
- `runs-on: self-hosted`, optionally with a `container:` image such as `node:24` (the action needs Node.js; git is installed automatically if missing).
|
||||
- Marking the workspace as a trusted git `safe.directory` when running inside a container (e.g. `git config --global --replace-all safe.directory '*'`) to avoid "dubious ownership" errors. Use `--replace-all` (not `--add`) so repeated runs across multiple self-hosted actions replace rather than accumulate entries in the global git config.
|
||||
- Pinning action inputs explicitly (`sticky_summary`, `incremental`, `upload_artifacts`, `llm_extra_body`, etc.).
|
||||
|
||||
The action performs its own full `fetch-depth: 0` checkout of the PR internally, so no extra checkout step is needed for the review diff. Adapt the runner settings to your environment and secret layout.
|
||||
|
||||
## How It Works
|
||||
|
||||
|
|
@ -10,40 +44,39 @@ PR Created/Updated → GitHub Actions Triggered → OCR Reviews Diff → Comment
|
|||
Comment with trigger keyword ↗
|
||||
```
|
||||
|
||||
1. When a PR is opened, the workflow triggers (uses `pull_request_target` for fork secret access)
|
||||
2. Alternatively, when a comment containing `/open-code-review` or `@open-code-review` is posted on a PR, the workflow triggers
|
||||
3. It installs OCR via `npm install -g @alibaba-group/open-code-review`
|
||||
4. Runs `ocr review --from origin/<base> --to <head_sha> --format json` to analyze the diff (uses commit SHA to support fork PRs)
|
||||
5. Parses the JSON output and posts inline review comments on the PR using GitHub's Pull Request Review API
|
||||
1. When a PR is opened, the workflow triggers (uses `pull_request_target` for fork secret access).
|
||||
2. Alternatively, when a comment containing `/open-code-review` or `@open-code-review` is posted on a PR, the workflow triggers.
|
||||
3. The reusable action installs OCR, fetches the PR head blobs, computes `git merge-base`, and runs `ocr review --from <merge-base> --to <head> --format json`.
|
||||
4. It parses the JSON output and posts inline review comments on the PR via the Pull Request Review API, plus a summary comment (an issue comment on the PR).
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Copy the workflow file
|
||||
### Configure secrets and variables
|
||||
|
||||
Copy `ocr-review.yml` to your repository's `.github/workflows/` directory:
|
||||
Go to your repository's **Settings → Secrets and variables → Actions**.
|
||||
|
||||
```bash
|
||||
mkdir -p .github/workflows
|
||||
cp ocr-review.yml .github/workflows/ocr-review.yml
|
||||
```
|
||||
|
||||
### 2. Configure secrets
|
||||
|
||||
Go to your repository's **Settings → Secrets and variables → Actions** and add:
|
||||
**Secrets:**
|
||||
|
||||
| Secret | Required | Description |
|
||||
|--------|----------|-------------|
|
||||
| `OCR_LLM_URL` | Yes | LLM API endpoint URL (e.g., `https://api.openai.com/v1/chat/completions`) |
|
||||
| `OCR_LLM_AUTH_TOKEN` | Yes | API authentication token |
|
||||
| `OCR_LLM_MODEL` | No | Model name (defaults to `gpt-4o`) |
|
||||
| `OCR_LLM_USE_ANTHROPIC` | No | Set to `true` if using Anthropic Claude models |
|
||||
| `OCR_LLM_AUTH_TOKEN` | Yes | API authentication token (mapped to env `OCR_LLM_TOKEN` internally) |
|
||||
|
||||
> **Note:** `GITHUB_TOKEN` is automatically provided by GitHub Actions with the required `pull-requests: write` permission.
|
||||
>
|
||||
> The workflow also configures `llm.extra_body` to disable thinking mode for compatibility with various LLM providers.
|
||||
**Variables:**
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `OCR_LLM_MODEL` | Yes | Model name |
|
||||
| `OCR_LLM_USE_ANTHROPIC` | Yes | `true` for Anthropic Claude, `false` for OpenAI-compatible |
|
||||
|
||||
> **Note:** `GITHUB_TOKEN` is automatically provided by GitHub Actions with the required `pull-requests: write` permission. The action also sets `llm.extra_body` to disable thinking mode for compatibility with various LLM providers.
|
||||
|
||||
## Customization
|
||||
|
||||
> These knobs are action inputs — they apply to the demo workflow and any workflow calling `alibaba/open-code-review@main`.
|
||||
|
||||
See [`action.yml`](../../action.yml) for the full input list. Workflow-level settings (triggers, keywords) are edited in the workflow file itself.
|
||||
|
||||
### Change the trigger events
|
||||
|
||||
Modify the `on.pull_request_target.types` array in the workflow file:
|
||||
|
|
@ -56,92 +89,152 @@ on:
|
|||
|
||||
### Customize comment trigger keywords
|
||||
|
||||
By default, the workflow triggers when a PR comment starts with `/open-code-review` or `@open-code-review`. You can customize these keywords by modifying the `if` condition in the workflow:
|
||||
By default the workflow also re-reviews on demand when a PR comment starts with `/open-code-review` or `@open-code-review`. The `if` condition is more defensive than a bare keyword check — it gates comment triggers so only authorized humans can spend LLM quota:
|
||||
|
||||
```yaml
|
||||
if: |
|
||||
github.event_name == 'pull_request_target' ||
|
||||
(github.event_name == 'issue_comment' && github.event.issue.pull_request && startsWith(github.event.comment.body, '/review')) ||
|
||||
(github.event_name == 'issue_comment' && github.event.issue.pull_request && startsWith(github.event.comment.body, '@mybot'))
|
||||
github.event_name == 'pull_request_target'
|
||||
|| (
|
||||
github.event_name == 'issue_comment'
|
||||
&& github.event.issue.pull_request
|
||||
&& github.event.comment.user.type != 'Bot'
|
||||
&& (
|
||||
github.event.comment.author_association == 'MEMBER'
|
||||
|| github.event.comment.author_association == 'OWNER'
|
||||
|| github.event.comment.author_association == 'COLLABORATOR'
|
||||
)
|
||||
&& (
|
||||
startsWith(github.event.comment.body, '/open-code-review')
|
||||
|| startsWith(github.event.comment.body, '@open-code-review')
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
Or use a more flexible pattern with `contains` to trigger on any comment containing the keyword:
|
||||
Each clause guards against a different abuse vector:
|
||||
|
||||
```yaml
|
||||
if: |
|
||||
github.event_name == 'pull_request_target' ||
|
||||
(github.event_name == 'issue_comment' && github.event.issue.pull_request && contains(github.event.comment.body, '/review'))
|
||||
```
|
||||
- `github.event.issue.pull_request` — the comment must be on a PR, not a regular issue.
|
||||
- `github.event.comment.user.type != 'Bot'` — ignore bot comments. `GITHUB_TOKEN` already suppresses events from comments it posted, but a PAT or GitHub App token would not, so this is a safety net against self-triggering loops.
|
||||
- `author_association == 'MEMBER' | 'OWNER' | 'COLLABORATOR'` — only repository collaborators can trigger a (billable) re-review, preventing arbitrary commenters from draining LLM quota.
|
||||
- The `startsWith(...)` pair — the actual trigger keywords.
|
||||
|
||||
> **Note:** The condition `github.event.issue.pull_request` ensures the comment is on a PR, not a regular issue.
|
||||
To change the keywords, edit only that final pair (e.g. `/review` and `@mybot`), or swap `startsWith` for `contains` to match a substring anywhere in the comment body. Keep the preceding guards intact.
|
||||
|
||||
The same predicate is mirrored in the workflow's `concurrency.group`: matching events share a per-PR group (`ocr-<pr_number>`) so a new review cancels any stale one, while non-matching comments land in a unique `noop-<run_id>` group and are skipped instantly without disrupting a running review. If you change the keywords in `if`, mirror the change in `concurrency.group` too.
|
||||
|
||||
### Use a specific OCR version
|
||||
|
||||
```yaml
|
||||
- name: Install OpenCodeReview
|
||||
run: npm install -g @alibaba-group/open-code-review@1.0.0
|
||||
- uses: alibaba/open-code-review@main
|
||||
with:
|
||||
ocr_version: 1.0.0
|
||||
```
|
||||
|
||||
### Add custom review rules
|
||||
|
||||
Use the `--rule` flag to pass a custom rules JSON file:
|
||||
```yaml
|
||||
- uses: alibaba/open-code-review@main
|
||||
with:
|
||||
rule: ./my-rules.json
|
||||
```
|
||||
|
||||
> Security: do not point `rule` at a file sourced from the PR branch when secrets are in scope; use a trusted rules file from your base branch.
|
||||
|
||||
### Control comment posting (sticky summary & incremental)
|
||||
|
||||
The action posts a summary issue comment plus inline review comments. Two inputs select the posting mode (combined, they give the four modes referenced above); a third tunes the incremental overlap test:
|
||||
|
||||
| Input | Default | Description |
|
||||
|-------|---------|-------------|
|
||||
| `sticky_summary` | `'true'` | Update an existing summary comment in place instead of posting a new one each run. |
|
||||
| `incremental` | `'false'` | Only append inline comments whose `(path, line range)` does not overlap an existing bot review comment. History is never deleted (non-destructive). |
|
||||
| `incremental_overlap_threshold` | `'0.6'` | IoU threshold `incremental` uses to decide whether a multi-line comment overlaps an existing one. Two single-line comments match on the same line; single- vs multi-line never match. Ignored unless `incremental` is `'true'`. |
|
||||
|
||||
```yaml
|
||||
- name: Run OCR review
|
||||
run: ocr review --rule ./my-rules.json --from origin/${{ github.base_ref }} --to origin/${{ github.head_ref }}
|
||||
- uses: alibaba/open-code-review@main
|
||||
with:
|
||||
sticky_summary: 'true'
|
||||
incremental: 'true'
|
||||
incremental_overlap_threshold: '0.75'
|
||||
```
|
||||
|
||||
> `sticky_summary` and `incremental` must be quoted strings (`'true'`/`'false'`); the action compares them as strings, so an unquoted YAML boolean will not match.
|
||||
|
||||
### Adjust retry and delay settings
|
||||
|
||||
When posting review comments individually (fallback mode), the workflow includes rate-limit handling with exponential backoff. The retry strategy follows GitHub's documented guidance for REST API rate limits — see [Rate limits for the REST API](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api?apiVersion=2026-03-10) for details on primary/secondary rate limits and recommended retry behavior:
|
||||
When posting review comments individually (fallback mode), the action honors GitHub rate-limit headers (`retry-after`, `x-ratelimit-*`) with exponential backoff. The retry strategy follows GitHub's documented guidance for REST API rate limits — see [Rate limits for the REST API](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api?apiVersion=2026-03-10) for details on primary/secondary rate limits and recommended retry behavior:
|
||||
|
||||
- **Primary rate limit exhausted** (`x-ratelimit-remaining=0`): wait until `x-ratelimit-reset`.
|
||||
- **Secondary rate limit with a `retry-after` header**: wait exactly that long.
|
||||
- **Secondary rate limit with no header**: wait at least one minute, then use exponential backoff on continued failures.
|
||||
|
||||
You can configure the retry and delay behavior via **repository variables** (Settings → Secrets and variables → Actions → Variables):
|
||||
These are environment variables read by the posting module with sensible defaults; set them at the **job `env:` level** to tune (they propagate into the action):
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `OCR_RETRY_BASE_DELAY` | `60000` | Base delay (ms) for exponential backoff when no retry header is present (per GitHub's "at least one minute" recommendation for secondary limits) |
|
||||
| `OCR_RETRY_MAX_DELAY` | `300000` | Maximum delay (ms) cap applied to every computed wait, including retry-after and x-ratelimit-reset, so a far-future reset cannot stall the job past its timeout |
|
||||
| `OCR_RETRY_BASE_DELAY` | `60000` | Base delay (ms) for exponential backoff when no retry header is present |
|
||||
| `OCR_RETRY_MAX_DELAY` | `300000` | Maximum delay (ms) cap applied to every computed wait |
|
||||
| `OCR_MAX_RETRIES` | `3` | Maximum retry attempts per comment when rate-limited |
|
||||
| `OCR_SUCCESS_DELAY` | `2000` | Delay (ms) after a successful comment post to pace subsequent requests |
|
||||
| `OCR_FAILURE_DELAY` | `1000` | Delay (ms) after a non-rate-limit failure to pace subsequent requests |
|
||||
| `OCR_LOW_REMAINING_THRESHOLD` | `3` | When x-ratelimit-remaining is at or below this value, proactively increase request spacing to avoid hitting the limit |
|
||||
| `OCR_SUCCESS_DELAY` | `2000` | Delay (ms) after a successful comment post |
|
||||
| `OCR_FAILURE_DELAY` | `1000` | Delay (ms) after a non-retryable failure |
|
||||
| `OCR_LOW_REMAINING_THRESHOLD` | `3` | When x-ratelimit-remaining is at or below this value, proactively increase request spacing |
|
||||
| `OCR_LOW_REMAINING_SPACING` | `10000` | Request spacing (ms) used when remaining quota is low |
|
||||
| `OCR_READ_SUCCESS_DELAY` | `500` | Delay (ms) after a successful read API call (`listReviews` / `listReviewComments` / `listIssueComments`) used for the idempotency check. Reads are cheaper than writes, so the default is shorter |
|
||||
| `OCR_READ_LOW_REMAINING_SPACING` | `5000` | Request spacing (ms) for read calls when remaining quota is low |
|
||||
|
||||
These variables are optional — if not configured, sensible defaults are used. Consider increasing delays for repositories with many concurrent workflows or large PRs that generate numerous review comments.
|
||||
For example, to raise the per-comment retry count to 5, set `OCR_MAX_RETRIES` on the **job's** `env:` — not on the `uses:` step. A composite action does not forward the caller's step-level `env:` into its internal steps' process environment, so a step-level value would be silently ignored; the job-level value is inherited by the action's comment-posting step and read via `process.env`:
|
||||
|
||||
```yaml
|
||||
jobs:
|
||||
code-review:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
OCR_MAX_RETRIES: 5
|
||||
steps:
|
||||
- uses: alibaba/open-code-review@main
|
||||
with:
|
||||
llm_url: ${{ secrets.OCR_LLM_URL }}
|
||||
# ...other inputs
|
||||
```
|
||||
|
||||
These variables are optional. See GitHub's [Rate limits for the REST API](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api).
|
||||
|
||||
#### Idempotency: avoiding duplicate review comments
|
||||
|
||||
When the batch `createReview` call fails with a `5xx` error, the request may still have landed on the GitHub server (the response was simply lost). Before retrying per-comment, the workflow queries existing reviews and review comments — each tagged with a per-run HTML comment (e.g. `<!-- ocr-<runId>-<attempt>-<token> -->`) — and only retries the comments that are actually missing. This prevents duplicate review posts.
|
||||
When the batch `createReview` call fails with a `5xx` error, the request may still have landed on the GitHub server (the response was simply lost). Before retrying per-comment, the action queries existing reviews and review comments — each tagged with a per-run HTML comment (e.g. `<!-- ocr-<runId>-<attempt>-<token> -->`) — and only retries the comments that are actually missing. This prevents duplicate review posts.
|
||||
|
||||
The same idempotency check is applied to the summary comment: before posting, the workflow verifies whether a summary with the same run tag already exists, and skips posting if so.
|
||||
The summary comment is deduplicated too: in sticky mode (the default) the action finds the existing summary by its persistent marker and updates it in place rather than posting a new one; in non-sticky mode it reuses this run's summary if it already exists. If the read API is unavailable, it skips posting the summary rather than risking a duplicate.
|
||||
|
||||
If the read API itself is unavailable (rate-limited or `5xx`), the check returns *unknown* rather than assuming the comment was not posted. In that case the workflow **skips retrying** to avoid risking a duplicate, and surfaces the uncertainty in the summary instead of silently producing duplicates.
|
||||
If the read API itself is unavailable (rate-limited or `5xx`), the check returns *unknown* rather than assuming the comment was not posted. In that case the action **skips retrying** to avoid risking a duplicate, and surfaces the uncertainty in the summary instead of silently producing duplicates.
|
||||
|
||||
### Limit concurrency
|
||||
|
||||
Adjust the `--concurrency` flag for large PRs to control the number of concurrent LLM requests:
|
||||
### Limit LLM concurrency
|
||||
|
||||
```yaml
|
||||
- name: Run OCR review
|
||||
run: ocr review --concurrency 5 --from origin/${{ github.base_ref }} --to origin/${{ github.head_ref }}
|
||||
- uses: alibaba/open-code-review@main
|
||||
with:
|
||||
review_concurrency: 5
|
||||
```
|
||||
|
||||
### Provide background context
|
||||
|
||||
Use the `--background` flag to pass additional context that helps OCR better understand the purpose of the changes:
|
||||
|
||||
```yaml
|
||||
- name: Run OCR review
|
||||
run: ocr review --background "${{ github.event.pull_request.title }}" --from origin/${{ github.base_ref }} --to origin/${{ github.head_ref }}
|
||||
- uses: alibaba/open-code-review@main
|
||||
with:
|
||||
background: ${{ github.event.pull_request.title }}
|
||||
```
|
||||
|
||||
This is particularly useful when your PR titles follow semantic conventions (e.g., `feat(auth): add OAuth2 support`) that clearly summarize what the PR implements. The background information helps OCR provide more relevant and context-aware review comments.
|
||||
Particularly useful when PR titles follow semantic conventions (e.g., `feat(auth): add OAuth2 support`).
|
||||
|
||||
> Note: `github.event.pull_request.title` is only present on `pull_request_target` events, so it is empty for comment-triggered re-reviews. To cover both trigger types, have the pr-context step also output the title and fall back to it:
|
||||
>
|
||||
> ```yaml
|
||||
> # inside the pr-context script (which only runs for issue_comment):
|
||||
> core.setOutput('title', pullRequest.title);
|
||||
> ```
|
||||
> ```yaml
|
||||
> - uses: alibaba/open-code-review@main
|
||||
> with:
|
||||
> background: ${{ steps.pr-context.outputs.title || github.event.pull_request.title }}
|
||||
> ```
|
||||
|
||||
### Customize the review comment author with GitHub App
|
||||
|
||||
|
|
@ -184,40 +277,53 @@ Add the following secrets to your repository (**Settings → Secrets and variabl
|
|||
|--------|-------------|
|
||||
| `GITHUB_APP_ID` | Your GitHub App's ID |
|
||||
| `GITHUB_APP_PRIVATE_KEY` | Contents of the `.pem` file (including `-----BEGIN RSA PRIVATE KEY-----` and `-----END RSA PRIVATE KEY-----`) |
|
||||
| `GITHUB_APP_INSTALLATION_ID` | The Installation ID from Step 3 |
|
||||
| `GITHUB_APP_INSTALLATION_ID` | (Optional) The Installation ID from Step 3 — only needed for apps with multiple installations |
|
||||
|
||||
#### Step 5: Update the Workflow
|
||||
#### Step 5: Pass the App token to the action
|
||||
|
||||
Add a step to obtain a token from the GitHub App, then use it in the "Post review comments to PR" step:
|
||||
Mint a token with `actions/create-github-app-token` and pass it via the `github_token` input:
|
||||
|
||||
```yaml
|
||||
- name: Get GitHub App Token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v1
|
||||
uses: actions/create-github-app-token@main
|
||||
with:
|
||||
app-id: ${{ secrets.GITHUB_APP_ID }}
|
||||
private-key: ${{ secrets.GITHUB_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Post review comments to PR
|
||||
uses: actions/github-script@v7
|
||||
- uses: alibaba/open-code-review@main
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
# ... existing script
|
||||
github_token: ${{ steps.app-token.outputs.token }}
|
||||
llm_url: ${{ secrets.OCR_LLM_URL }}
|
||||
llm_auth_token: ${{ secrets.OCR_LLM_AUTH_TOKEN }}
|
||||
llm_model: ${{ vars.OCR_LLM_MODEL }}
|
||||
llm_use_anthropic: ${{ vars.OCR_LLM_USE_ANTHROPIC }}
|
||||
```
|
||||
|
||||
Now review comments will be posted with your custom GitHub App identity (e.g., `OpenCodeReview Bot`), providing a more professional and distinguishable appearance in your PRs.
|
||||
|
||||
## Example Output
|
||||
|
||||
When a PR is reviewed, comments appear directly in the PR's "Files changed" tab:
|
||||
The action posts two kinds of output on the PR: a **summary issue comment** (in the PR conversation) and **inline review comments** (in the "Files changed" tab).
|
||||
|
||||
- ✅ If no issues found: A comment saying "No comments generated. Looks good to me."
|
||||
- 🔍 If issues found: Inline review comments with suggestions using GitHub's native suggestion syntax
|
||||
### Summary comment
|
||||
|
||||
### Inline Comment Example
|
||||
A single comment — updated in place on each run when `sticky_summary` is `'true'` (the default) — carries the review outcome and posting statistics.
|
||||
|
||||
The workflow uses GitHub's `suggestion` code block syntax, so reviewers can apply fixes with one click:
|
||||
- ✅ No issues: `✅ **OpenCodeReview**: No comments generated. Looks good to me.`
|
||||
- 🔍 Issues found: a header line plus per-outcome counts, for example:
|
||||
|
||||
```markdown
|
||||
🔍 **OpenCodeReview** found **3** issue(s) in this PR.
|
||||
- ✅ Successfully posted inline: 2 comment(s)
|
||||
- 📝 In summary (no line info): 1 comment(s)
|
||||
```
|
||||
|
||||
The counts are mutually exclusive and sum to the total: `inline` (landed as review inline comments), `summary` (no line info, rendered in the summary body), `skipped` (suppressed by incremental overlap filtering), and `failed` (had line info but could not be posted). Any warnings are appended as a bulleted list.
|
||||
|
||||
### Inline comments
|
||||
|
||||
Comments with valid line info are posted as PR review comments in "Files changed". Each carries the review content plus, when a fix is available, a GitHub-native `suggestion` block so reviewers can apply it with one click:
|
||||
|
||||
````markdown
|
||||
**Suggestion:**
|
||||
|
|
@ -226,6 +332,8 @@ The workflow uses GitHub's `suggestion` code block syntax, so reviewers can appl
|
|||
```
|
||||
````
|
||||
|
||||
Comments that have no line info, or that could not be posted inline (e.g. their line fell outside the current diff), are rendered in the summary body instead — each under a `### 📄 <path>` heading, with a collapsible `<details>` "💡 Suggested Change" (Before/After) when a fix is available.
|
||||
|
||||
## Supported LLM Providers
|
||||
|
||||
OCR supports both OpenAI and Anthropic API formats:
|
||||
|
|
@ -234,22 +342,25 @@ OCR supports both OpenAI and Anthropic API formats:
|
|||
- OpenAI (GPT-4o, GPT-4, etc.)
|
||||
- Azure OpenAI
|
||||
- Self-hosted models (vLLM, Ollama, etc.)
|
||||
- **Anthropic APIs** (set `OCR_LLM_USE_ANTHROPIC: true`):
|
||||
- **Anthropic APIs** (set variable `OCR_LLM_USE_ANTHROPIC=true`, i.e. `llm_use_anthropic: true`):
|
||||
- Anthropic Claude models
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **"Failed to parse OCR output"**: Check that `OCR_LLM_URL` and `OCR_LLM_AUTH_TOKEN` secrets are correctly set
|
||||
2. **"Cannot find merge-base"**: Ensure `fetch-depth: 0` is set in the checkout step
|
||||
3. **Review comments not appearing on correct lines**: This can happen when the diff has changed since the review started; the workflow handles this gracefully with a fallback to issue comments
|
||||
1. **Job fails / "Failed to parse OCR output"**: When `ocr review` exits non-zero the action fails the job with that exit code (the comment-posting step is skipped); a zero exit with malformed JSON surfaces as a parse error in the summary. In both cases, check that `OCR_LLM_URL` and `OCR_LLM_AUTH_TOKEN` are set correctly, then inspect the uploaded `ocr-stderr.log` artifact (also printed in the "Run OpenCodeReview" step log) for the underlying error.
|
||||
2. **"Cannot find merge-base"**: The action fetches full history (`fetch-depth: 0`) and the PR head (`git fetch origin pull/<n>/head`); if this still fails, ensure `permissions: contents: read` is set and the base branch is accessible (e.g., not deleted).
|
||||
3. **Review comments not on the expected lines**: Comments are attached to the PR head commit. If a comment's line falls outside the current diff (the PR was force-pushed or updated mid-review), GitHub rejects the inline post and the comment is rendered in the summary instead. The workflow's concurrency group cancels stale runs on new pushes.
|
||||
4. **No summary or comments at all**: Confirm the job's `permissions` include `pull-requests: write`, and that `github_token` (defaults to `${{ github.token }}`) is not overridden with a token lacking those scopes.
|
||||
|
||||
### Debugging
|
||||
|
||||
Enable debug logging by adding to the OCR review step:
|
||||
The action does not use an `OCR_DEBUG` flag. To diagnose a run:
|
||||
|
||||
```yaml
|
||||
env:
|
||||
OCR_DEBUG: "1"
|
||||
```
|
||||
- **Artifacts**: with `upload_artifacts: 'true'` (the default), the raw `ocr-result.json` and `ocr-stderr.log` are uploaded as workflow artifacts named `ocr-review-result-<run_id>-<run_attempt>`. Download them from the run's **Artifacts** section.
|
||||
- **Step log**: the "Run OpenCodeReview" step prints both the JSON result and stderr to the workflow log.
|
||||
- **Action outputs**: the step exposes `comments_total`, `comments_inline`, `comments_skipped`, `comments_failed`, and `summary_comment_url` outputs — inspect them in the job's step outputs.
|
||||
- **GitHub step debug**: for verbose Actions runner diagnostics, enable the repository secret `ACTIONS_STEP_DEBUG=true` (standard GitHub Actions mechanism).
|
||||
|
||||
To stop uploading the raw artifacts, set `upload_artifacts: 'false'`.
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
1203
scripts/github-actions/post-review-comments.js
Normal file
1203
scripts/github-actions/post-review-comments.js
Normal file
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue