mirror of
https://github.com/alibaba/open-code-review.git
synced 2026-07-09 17:28:58 +00:00
878 lines
46 KiB
YAML
878 lines
46 KiB
YAML
# OpenCodeReview - GitHub Actions PR Auto-Review Pipeline
|
||
#
|
||
# This workflow automatically reviews pull requests using OpenCodeReview
|
||
# and posts review comments directly on the PR.
|
||
#
|
||
# 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.
|
||
#
|
||
# 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
|
||
|
||
concurrency:
|
||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||
cancel-in-progress: true
|
||
|
||
on:
|
||
# Use pull_request_target instead of pull_request so that secrets are
|
||
# available even for PRs from forks. This is safe because OCR only reads
|
||
# the diff and does not execute any code from the PR.
|
||
pull_request_target:
|
||
types: [opened]
|
||
|
||
permissions:
|
||
contents: read
|
||
pull-requests: write
|
||
|
||
jobs:
|
||
code-review:
|
||
runs-on: self-hosted
|
||
container:
|
||
image: node:24
|
||
if: github.event_name == 'pull_request_target'
|
||
steps:
|
||
- name: Checkout repository
|
||
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
|
||
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
|
||
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));
|
||
}
|