diff --git a/.claude/commands/open-code-review.md b/.claude/commands/open-code-review.md index f7339cb..a58cb58 100644 --- a/.claude/commands/open-code-review.md +++ b/.claude/commands/open-code-review.md @@ -15,7 +15,6 @@ ocr review --audience agent [user-args] - If the user provides `--commit` or `--c`: pass through as-is. - If the user provides `--from` and `--to`: pass through as-is. - (Optional) Provide `--background "requirement context"` to review whether the requirements are correctly implemented. -- (Optional) Provide `--background-file ./requirements.md` to load the same context from a Markdown file (sanitised and limited to 8000 characters). Combined with `--background` the inline value is given first. - Capture full stdout. Set a 5-minute timeout. - If the `ocr` command is not found, install it by running `npm i -g @alibaba-group/open-code-review`. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ac1420e..31557d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: runs-on: self-hosted timeout-minutes: 15 container: - image: golang:1.26.5 + image: golang:1.26.4 steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/ocr-review.yml b/.github/workflows/ocr-review.yml index 1bb8775..9abcdf8 100644 --- a/.github/workflows/ocr-review.yml +++ b/.github/workflows/ocr-review.yml @@ -1,19 +1,58 @@ # OpenCodeReview - GitHub Actions PR Auto-Review Pipeline # -# 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.). +# 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 (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 +# 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 @@ -35,30 +74,805 @@ permissions: jobs: code-review: runs-on: self-hosted - timeout-minutes: 30 container: image: node:24 if: github.event_name == 'pull_request_target' steps: - # 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 + - 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: Trust workspace + - 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 - uses: ./ + 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: - 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' + 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 = ``; + const SUMMARY_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 () + // so user-generated content or code suggestions cannot trigger + // false positives in the idempotency check. The ID format is + // `ocr--` where RUN_TAG is `-` + // and is a per-comment random hex token. Capture group 1 + // holds the bare ID (ocr--), so we can add it + // directly without stripping comment markers. + const ID_RE = //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 ``, + // so escape any regex metacharacters before embedding it. + const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const tagRe = new RegExp(''); + 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 = `\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
💡 Suggested Change\n\n'; + md += '**Before:**\n' + fencedBlock(comment.existing_code) + '\n\n'; + md += '**After:**\n' + fencedBlock(comment.suggestion_code) + '\n\n'; + md += '
'; + } + + 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)); + } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index de4de5e..640c7a8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,7 +11,7 @@ jobs: build: runs-on: self-hosted container: - image: golang:1.26.5 + image: golang:1.26.4 strategy: matrix: include: diff --git a/README.ja-JP.md b/README.ja-JP.md index d03931c..c6bb4e2 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -13,6 +13,7 @@

npm Build status + Go Report Card License Ask DeepWiki OpenSSF Best Practices @@ -107,18 +108,6 @@ npm install -g @alibaba-group/open-code-review インストール後、`ocr`コマンドがグローバルに利用可能になります。 -**更新** - -NPM でインストールした場合は、手動で最新バージョンへ更新できます: - -```bash -npm install -g @alibaba-group/open-code-review@latest -``` - -NPM インストール版の `ocr` は、既定でバックグラウンドで新しいバージョンを確認し、自動的に更新します。自動更新を無効にするには、`OCR_NO_UPDATE=1` を設定してください。 - -インストールスクリプトまたは手動ダウンロードしたバイナリでインストールした場合は、同じインストール/ダウンロードコマンドを再実行すると、ローカルのバイナリを最新リリースに置き換えられます。特定のリリースタグに固定する必要がある場合は `OCR_VERSION` を使います。 - **GitHub Releaseから** 1 つのコマンドで、お使いの OS / アーキテクチャ向けの最新バイナリをインストールできます(macOS / Linux): @@ -280,10 +269,6 @@ ocr review --from main --to feature-branch # 単一コミット ocr review --commit abc123 -# 中断した範囲または単一 commit レビューを再開 -ocr session list -ocr review --from main --to feature-branch --resume - # フルファイルスキャン — diffではなくファイル全体をレビュー(git履歴不要) ocr scan # リポジトリ全体をスキャン ocr scan --path internal/agent # ディレクトリまたは特定のファイルをスキャン @@ -434,21 +419,6 @@ 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/) ディレクトリを参照してください。 - ## コマンド | コマンド | エイリアス | 説明 | @@ -462,8 +432,6 @@ GitHub 向けに、本リポジトリはリポジトリルートにすぐ使え | `ocr config unset custom_providers.` | — | カスタムプロバイダーを削除 | | `ocr llm test` | — | LLMの疎通テスト | | `ocr llm providers` | — | ビルトインLLMプロバイダーを一覧表示 | -| `ocr session list` | `ocr sessions list`, `ocr session ls` | 保存済みレビューセッションを一覧表示 | -| `ocr session show ` | `ocr sessions show ` | 1つのセッションとファイル単位のチェックポイントを表示 | | `ocr viewer` | `ocr v` | `localhost:5483`でWebUIセッションビューアーを起動 | | `ocr version` | — | バージョン情報を表示 | @@ -477,54 +445,17 @@ GitHub 向けに、本リポジトリはリポジトリルートにすぐ使え | `--commit` | `-c` | — | レビュー対象の単一コミット | | `--exclude` | — | — | カンマ区切りのgitignoreスタイルパターンでスキップ対象を指定;rule.jsonのexcludesとマージ | | `--preview` | `-p` | `false` | LLMを実行せずにレビュー対象ファイルをプレビュー | -| `--resume` | — | — | 以前の互換性のある範囲または単一 commit レビューセッションから再開 | | `--format` | `-f` | `text` | 出力形式:`text`または`json` | | `--concurrency` | — | `8` | ファイルレビューの最大同時実行数 | | `--timeout` | — | `10` | 同時実行タスクのタイムアウト(分) | | `--audience` | — | `human` | `human`(進捗を表示)または`agent`(サマリーのみ) | | `--background` | `-b` | — | レビューのための任意の要件/ビジネスコンテキスト。`--commit`使用時に未指定の場合、コミットメッセージから自動取得 | -| `--background-file` | `-B` | — | Markdownファイルから読み込む任意の要件/ビジネスコンテキスト。`--background`と併用した場合はインラインの値が先に配置されます | | `--model` | — | — | このレビューでLLMモデルを選択または上書き | | `--rule` | — | — | カスタムJSONレビュールールへのパス | | `--max-tools` | — | 組み込み値 | ファイルごとのツール呼び出しラウンドの上限。テンプレートのデフォルトより大きい場合のみ有効 | | `--max-git-procs` | — | 組み込み値 | gitサブプロセスの最大同時実行数 | | `--tools` | — | — | カスタムJSONツール設定へのパス | -#### 再開可能なレビューとセッション - -すべての `ocr review` 実行は、`~/.opencodereview/sessions/` 配下にローカル -セッションログを保存します。正常終了したテキスト出力はレビュー結果に集中し、session ID -は表示しません。保存済みセッションは `ocr session list/show` で確認でき、 -`--format json` では機械可読出力に `session_id` が含まれます。範囲または単一 commit -レビューが中断された場合は、保存済みセッションを一覧表示し、同じレビュー対象に一致するセッションから再開します: - -```bash -ocr session list -ocr session show -ocr review --from main --to feature-branch --resume -ocr review --commit abc123 --resume -``` - -再開は意図的に厳密です。範囲レビューと単一 commit レビューのみ対応し、ワークスペースレビューは再開できません。 -現在の `--from/--to` または `--commit` は保存済みセッションと一致する必要があります。`--preview` と `--resume` は併用できません。 - -`--format json` を使用すると、再開した実行には次が含まれます: - -- `session_id` — 現在の実行の session ID -- `resume.resumed_from` — 再開元の session ID -- `resume.reused_files` — 保存済みチェックポイントから再利用したファイル数 -- `resume.rerun_files` — 現在の実行で再レビューしたファイル数 - -### `ocr session`のフラグ - -| コマンド | フラグ | デフォルト | 説明 | -|---------|------|---------|------| -| `ocr session list` | `--repo` | カレントディレクトリ | 一覧表示するセッションのリポジトリ | -| `ocr session list` | `--json` | `false` | セッション概要をJSONで出力 | -| `ocr session list` | `--limit` | `20` | 一覧表示するセッション数の上限。`0` は無制限 | -| `ocr session show ` | `--repo` | カレントディレクトリ | 確認するセッションのリポジトリ | -| `ocr session show ` | `--json` | `false` | セッションメタデータとファイル単位の項目をJSONで出力 | - ### `ocr scan`のフラグ `ocr scan` はdiffではなくファイル全体をレビューします — 不慣れなコードベースの監査、マイグレーション前のスキャン、意味のあるdiffがないディレクトリなどに有用です。非gitディレクトリでも動作します(`.gitignore` を尊重するファイルシステムウォークにフォールバック)。 @@ -570,12 +501,6 @@ ocr review --from main --to my-feature --concurrency 4 # 特定のコミットを詳細なJSON出力でレビュー ocr review --commit abc123 --format json --audience agent -# 中断した範囲または単一 commit レビューを再開 -ocr session list -ocr session show -ocr review --from main --to my-feature --resume -ocr review --commit abc123 --resume - # このレビューでモデルを選択またはオーバーライド ocr review --model claude-opus-4-6 ocr review --commit abc123 --model claude-sonnet-4-6 @@ -583,12 +508,6 @@ ocr review --commit abc123 --model claude-sonnet-4-6 # 要件コンテキストを提供してより的確なレビューを実施 ocr review --background "ログインAPIにレート制限を追加" -# Markdownファイルから要件コンテキストを提供 -ocr review --background-file ./docs/my_business_context.md - -# インラインのコンテキストとローカルのコンテキストファイルを組み合わせる(両方が使用されます) -ocr review --background "認証に注目" --background-file ./docs/my_business_context.md - # カスタムレビュールールを使用 ocr review --rule /path/to/my-rules.json @@ -843,22 +762,13 @@ ocr config set telemetry.otlp_endpoint localhost:4317 エクスポートデータにLLMのプロンプトとレスポンスを含めるには、`telemetry.content_logging`を設定してください。 -**プロトコル選択:** 環境変数 `OTEL_EXPORTER_OTLP_PROTOCOL` でエクスポートプロトコルを選択できます: - -| 値 | トランスポート | 説明 | -|---|---|---| -| `grpc`(デフォルト) | gRPC | デフォルトポート 4317 | -| `http/protobuf` | HTTP | デフォルトポート 4318 | - -**Endpoint 形式:** `telemetry.otlp_endpoint` は `host:port` または `http://host:port` 形式のベースURLを指定します。パスを含める必要はありません。SDKが [OTLP仕様](https://opentelemetry.io/docs/specs/otlp/#otlphttp-request)に従いシグナルパス(例:`/v1/traces`)を自動的に付加します。 - ## コントリビューション -このプロジェクトは、貢献してくださるすべての方々のおかげで成り立っています。開発環境のセットアップ、コーディングガイドライン、プルリクエストの提出方法については[CONTRIBUTING.md](CONTRIBUTING.md)を参照してください。 +開発環境のセットアップ、コーディングガイドライン、プルリクエストの提出方法については[CONTRIBUTING.md](CONTRIBUTING.md)を参照してください。 - - - +## Star History + +[![Star History Chart](https://api.star-history.com/svg?repos=alibaba/open-code-review&type=Date)](https://star-history.com/#alibaba/open-code-review&Date) ## ライセンス diff --git a/README.ko-KR.md b/README.ko-KR.md index d5ed2d8..74c67d9 100644 --- a/README.ko-KR.md +++ b/README.ko-KR.md @@ -13,6 +13,7 @@

npm Build status + Go Report Card License Ask DeepWiki OpenSSF Best Practices @@ -107,18 +108,6 @@ npm install -g @alibaba-group/open-code-review 설치 후 `ocr` 명령을 전역에서 사용할 수 있습니다. -**업데이트** - -NPM으로 설치했다면 최신 버전으로 수동 업데이트할 수 있습니다: - -```bash -npm install -g @alibaba-group/open-code-review@latest -``` - -NPM 설치의 `ocr`은 기본적으로 백그라운드에서 새 버전을 확인하고 자동으로 업데이트합니다. 자동 업데이트를 끄려면 `OCR_NO_UPDATE=1`을 설정하세요. - -설치 스크립트나 수동 다운로드한 binary로 설치했다면 같은 설치/다운로드 명령을 다시 실행해 로컬 binary를 최신 release로 교체할 수 있습니다. 특정 release tag로 고정해야 한다면 `OCR_VERSION`을 사용하세요. - **GitHub Release 사용** 명령 한 번으로 사용 중인 OS/아키텍처에 맞는 최신 binary를 설치합니다 (macOS / Linux): @@ -280,10 +269,6 @@ ocr review --from main --to feature-branch # 단일 commit ocr review --commit abc123 -# 중단된 range 또는 단일 commit review 재개 -ocr session list -ocr review --from main --to feature-branch --resume - # 전체 파일 스캔 — diff 대신 파일 전체를 리뷰 (git 이력 불필요) ocr scan # 전체 repository 스캔 ocr scan --path internal/agent # 디렉터리 또는 특정 파일 스캔 @@ -434,21 +419,6 @@ 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 | @@ -462,8 +432,6 @@ GitHub의 경우, 이 리포지터리는 루트에 바로 사용할 수 있는 c | `ocr config unset custom_providers.` | - | custom provider 삭제 | | `ocr llm test` | - | LLM 연결 테스트 | | `ocr llm providers` | - | built-in LLM provider 목록 표시 | -| `ocr session list` | `ocr sessions list`, `ocr session ls` | 저장된 review session 목록 표시 | -| `ocr session show ` | `ocr sessions show ` | 단일 session과 파일별 checkpoint 확인 | | `ocr viewer` | `ocr v` | `localhost:5483`에서 WebUI session viewer 실행 | | `ocr version` | - | version 정보 표시 | @@ -477,54 +445,17 @@ GitHub의 경우, 이 리포지터리는 루트에 바로 사용할 수 있는 c | `--commit` | `-c` | - | 리뷰할 단일 commit | | `--exclude` | - | - | 건너뛸 파일의 쉼표 구분 gitignore 스타일 패턴; rule.json의 excludes와 병합 | | `--preview` | `-p` | `false` | LLM 실행 없이 리뷰 대상 파일 미리보기 | -| `--resume` | - | - | 이전의 호환되는 range 또는 단일 commit review session에서 재개 | | `--format` | `-f` | `text` | Output format: `text` 또는 `json` | | `--concurrency` | - | `8` | 최대 동시 파일 리뷰 수 | | `--timeout` | - | `10` | 동시 task timeout(분) | | `--audience` | - | `human` | `human`(progress 표시) 또는 `agent`(summary only) | | `--background` | `-b` | - | 리뷰를 위한 선택적 요구사항/비즈니스 컨텍스트. `--commit` 사용 시 미지정이면 commit message에서 자동 추출 | -| `--background-file` | `-B` | - | Markdown 파일에서 읽어오는 선택적 요구사항/비즈니스 컨텍스트. `--background`와 함께 사용하면 inline 값이 먼저 배치됩니다 | | `--model` | - | - | 이번 리뷰에서 LLM model 선택 또는 override | | `--rule` | - | - | custom JSON review rules 경로 | | `--max-tools` | - | built-in | 파일별 최대 tool call round. template default보다 클 때만 적용 | | `--max-git-procs` | - | built-in | 최대 동시 git subprocess 수 | | `--tools` | - | - | custom JSON tools config 경로 | -#### Resumable Reviews and Sessions - -모든 `ocr review` 실행은 `~/.opencodereview/sessions/` 아래에 local session log를 저장합니다. -정상 완료된 text output은 review 결과에 집중하며 session ID를 출력하지 않습니다. -저장된 session은 `ocr session list/show`로 찾을 수 있고, `--format json`을 사용하면 -machine-readable output에 `session_id`가 포함됩니다. range 또는 단일 commit review가 중단된 경우, -저장된 session을 나열한 뒤 동일한 review target과 일치하는 session에서 재개합니다. - -```bash -ocr session list -ocr session show -ocr review --from main --to feature-branch --resume -ocr review --commit abc123 --resume -``` - -Resume은 의도적으로 엄격합니다. branch range와 단일 commit review만 지원하고 workspace review는 지원하지 않습니다. -현재 `--from/--to` 또는 `--commit`은 저장된 session과 일치해야 합니다. `--preview`와 `--resume`은 함께 사용할 수 없습니다. - -`--format json`을 사용하면 재개된 run에는 다음 field가 포함됩니다. - -- `session_id`: 현재 run의 session ID -- `resume.resumed_from`: source session ID -- `resume.reused_files`: 저장된 checkpoint에서 재사용한 파일 수 -- `resume.rerun_files`: 현재 run에서 다시 review한 파일 수 - -### `ocr session` Flags - -| Command | Flag | Default | Description | -|---------|------|---------|-------------| -| `ocr session list` | `--repo` | current dir | session을 나열할 repository | -| `ocr session list` | `--json` | `false` | session summary를 JSON으로 출력 | -| `ocr session list` | `--limit` | `20` | 나열할 session 수 제한. `0`은 unlimited | -| `ocr session show ` | `--repo` | current dir | 확인할 session의 repository | -| `ocr session show ` | `--json` | `false` | session metadata와 파일별 item을 JSON으로 출력 | - ### `ocr scan` Flags `ocr scan`은 diff가 아닌 전체 파일을 리뷰합니다 — 익숙하지 않은 코드베이스 감사, 마이그레이션 전 스캔, 의미 있는 diff가 없는 디렉터리 등에 유용합니다. 비-git 디렉터리에서도 작동합니다 (`.gitignore`를 따르는 파일 시스템 탐색으로 폴백). @@ -570,12 +501,6 @@ ocr review --from main --to my-feature --concurrency 4 # 특정 commit을 verbose JSON output으로 리뷰 ocr review --commit abc123 --format json --audience agent -# 중단된 range 또는 단일 commit review 재개 -ocr session list -ocr session show -ocr review --from main --to my-feature --resume -ocr review --commit abc123 --resume - # 이번 리뷰에서 model 선택 또는 override ocr review --model claude-opus-4-6 ocr review --commit abc123 --model claude-sonnet-4-6 @@ -583,12 +508,6 @@ ocr review --commit abc123 --model claude-sonnet-4-6 # 요구사항 컨텍스트를 제공하여 더 정확한 리뷰 수행 ocr review --background "로그인 API에 rate limiting 추가" -# Markdown 파일에서 요구사항 컨텍스트 제공 -ocr review --background-file ./docs/my_business_context.md - -# inline 컨텍스트와 로컬 컨텍스트 파일을 함께 사용(둘 다 적용됨) -ocr review --background "인증에 집중" --background-file ./docs/my_business_context.md - # custom review rules 사용 ocr review --rule /path/to/my-rules.json @@ -800,22 +719,13 @@ ocr config set telemetry.otlp_endpoint localhost:4317 exported data에 LLM prompt와 response를 포함하려면 `telemetry.content_logging`을 설정합니다. -**프로토콜 선택:** 환경 변수 `OTEL_EXPORTER_OTLP_PROTOCOL`로 export 프로토콜을 선택할 수 있습니다: - -| 값 | 전송 방식 | 설명 | -|---|---|---| -| `grpc` (기본값) | gRPC | 기본 포트 4317 | -| `http/protobuf` | HTTP | 기본 포트 4318 | - -**Endpoint 형식:** `telemetry.otlp_endpoint`는 `host:port` 또는 `http://host:port` 형식의 base URL을 지정합니다. 경로를 포함할 필요가 없습니다. SDK가 [OTLP 사양](https://opentelemetry.io/docs/specs/otlp/#otlphttp-request)에 따라 signal 경로(예: `/v1/traces`)를 자동으로 추가합니다. - ## Contributing -이 프로젝트는 기여해 주신 모든 분들 덕분에 존재합니다. 개발 환경 설정, coding guideline, pull request 제출 방법은 [CONTRIBUTING.ko-KR.md](CONTRIBUTING.ko-KR.md)를 참고하세요. +개발 환경 설정, coding guideline, pull request 제출 방법은 [CONTRIBUTING.ko-KR.md](CONTRIBUTING.ko-KR.md)를 참고하세요. - - - +## Star History + +[![Star History Chart](https://api.star-history.com/svg?repos=alibaba/open-code-review&type=Date)](https://star-history.com/#alibaba/open-code-review&Date) ## License diff --git a/README.md b/README.md index 3ed3d78..ba42e2d 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@

npm Build status + Go Report Card License Ask DeepWiki OpenSSF Best Practices @@ -107,18 +108,6 @@ npm install -g @alibaba-group/open-code-review After installation, the `ocr` command is available globally. -**Update** - -If you installed via NPM, update manually to the latest version: - -```bash -npm install -g @alibaba-group/open-code-review@latest -``` - -NPM installations also check for newer versions in the background by default and upgrade automatically. To disable auto-updates, set `OCR_NO_UPDATE=1`. - -If you installed with the install script or a manually downloaded binary, rerun the same install/download command to replace the local binary with the latest release. Use `OCR_VERSION` when you need to pin a specific release tag. - **From GitHub Release** Install the latest binary for your OS/architecture with one command (macOS / Linux): @@ -280,10 +269,6 @@ ocr review --from main --to feature-branch # Single commit ocr review --commit abc123 -# Resume an interrupted range or commit review -ocr session list -ocr review --from main --to feature-branch --resume - # Full-file scan — review whole files instead of a diff (no git history needed) ocr scan # scan the entire repository ocr scan --path internal/agent # scan a directory or specific files @@ -436,21 +421,6 @@ 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 | @@ -464,8 +434,6 @@ Pin to a version tag or commit SHA for reproducibility. See the [`examples/githu | `ocr config unset custom_providers.` | — | Delete a custom provider | | `ocr llm test` | — | Test LLM connectivity | | `ocr llm providers` | — | List built-in LLM providers | -| `ocr session list` | `ocr sessions list`, `ocr session ls` | List saved review sessions | -| `ocr session show ` | `ocr sessions show ` | Inspect one session and its per-file checkpoints | | `ocr viewer` | `ocr v` | Launch WebUI session viewer on `localhost:5483` | | `ocr version` | — | Show version info | @@ -479,55 +447,16 @@ Pin to a version tag or commit SHA for reproducibility. See the [`examples/githu | `--commit` | `-c` | — | Single commit to review | | `--exclude` | — | — | Comma-separated gitignore-style patterns to skip; merged with rule.json excludes | | `--preview` | `-p` | `false` | Preview which files will be reviewed without running the LLM | -| `--resume` | — | — | Resume from a previous compatible range or commit review session | | `--format` | `-f` | `text` | Output format: `text` or `json` | | `--concurrency` | — | `8` | Max concurrent file reviews | | `--timeout` | — | `10` | Concurrent task timeout in minutes | | `--audience` | — | `human` | `human` (show progress) or `agent` (summary only) | | `--background` | `-b` | — | Optional requirement/business context for the review; auto-filled from commit message when using `--commit` | -| `--background-file` | `-B` | — | Optional requirement/business context from a Markdown file; Combined with `--background` the inline value is given first | | `--model` | — | — | Select or override the LLM model for this review | | `--rule` | — | — | Path to custom JSON review rules | | `--max-tools` | — | built-in | Max tool call rounds per file; only takes effect when greater than template default | -| `--max-git-procs` | — | `16` | Max concurrent git subprocesses | -| `--tools` | — | built-in | Path to custom JSON tools config | - -#### Resumable Reviews and Sessions - -Every `ocr review` run persists a local session log under -`~/.opencodereview/sessions/`. Successful text output stays focused on review -results and does not print the session ID; use `ocr session list/show` to find -saved sessions, or `--format json` to include `session_id` in machine-readable -output. If a range or commit review is interrupted, list the saved sessions and -resume from the one that matches the same review target: - -```bash -ocr session list -ocr session show -ocr review --from main --to feature-branch --resume -ocr review --commit abc123 --resume -``` - -Resume is intentionally strict: it only supports branch-range and single-commit -reviews, not workspace reviews, and the current `--from/--to` or `--commit` -must match the saved session. `--preview` cannot be combined with `--resume`. - -When `--format json` is used, resumed runs include: - -- `session_id` — the current run's session ID -- `resume.resumed_from` — the source session ID -- `resume.reused_files` — files reused from saved checkpoints -- `resume.rerun_files` — files reviewed again in the current run - -### `ocr session` Flags - -| Command | Flag | Default | Description | -|---------|------|---------|-------------| -| `ocr session list` | `--repo` | current dir | Repository whose sessions should be listed | -| `ocr session list` | `--json` | `false` | Emit session summaries as JSON | -| `ocr session list` | `--limit` | `20` | Cap listed sessions; use `0` for unlimited | -| `ocr session show ` | `--repo` | current dir | Repository whose session should be inspected | -| `ocr session show ` | `--json` | `false` | Emit session metadata and per-file items as JSON | +| `--max-git-procs` | — | built-in | Max concurrent git subprocesses | +| `--tools` | — | — | Path to custom JSON tools config | ### `ocr scan` Flags @@ -577,12 +506,6 @@ ocr review --from main --to my-feature --concurrency 4 # Review a specific commit with verbose JSON output ocr review --commit abc123 --format json --audience agent -# Resume an interrupted range or commit review -ocr session list -ocr session show -ocr review --from main --to my-feature --resume -ocr review --commit abc123 --resume - # Select or override model for this review ocr review --model claude-opus-4-6 ocr review --commit abc123 --model claude-sonnet-4-6 @@ -590,12 +513,6 @@ ocr review --commit abc123 --model claude-sonnet-4-6 # Provide requirement context for more targeted review ocr review --background "Adding rate limiting to the login API" -# Provide requirement context from a Markdown file -ocr review --background-file ./docs/my_business_context.md - -# Combine inline context with a local context file (both are used) -ocr review --background "Focus on auth" --background-file ./docs/my_business_context.md - # Use custom review rules ocr review --rule /path/to/my-rules.json @@ -850,23 +767,13 @@ ocr config set telemetry.otlp_endpoint localhost:4317 Set `telemetry.content_logging` to include LLM prompts and responses in exported data. -**Protocol selection:** Set the environment variable `OTEL_EXPORTER_OTLP_PROTOCOL` to choose the export protocol: - -| Value | Transport | Notes | -|---|---|---| -| `grpc` (default) | gRPC | Default port 4317 | -| `http/protobuf` | HTTP | Default port 4318 | - -**Endpoint format:** `telemetry.otlp_endpoint` expects a base URL in `host:port` or `http://host:port` format, without a path component. The SDK appends the signal path (e.g. `/v1/traces`) automatically per the [OTLP specification](https://opentelemetry.io/docs/specs/otlp/#otlphttp-request). - - ## Contributing -This project exists thanks to all the people who contribute. See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, coding guidelines, and how to submit pull requests. +See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, coding guidelines, and how to submit pull requests. - - - +## Star History + +[![Star History Chart](https://api.star-history.com/svg?repos=alibaba/open-code-review&type=Date)](https://star-history.com/#alibaba/open-code-review&Date) ## License diff --git a/README.ru-RU.md b/README.ru-RU.md index c1310fa..6535ce5 100644 --- a/README.ru-RU.md +++ b/README.ru-RU.md @@ -13,6 +13,7 @@

npm Build status + Go Report Card License Ask DeepWiki OpenSSF Best Practices @@ -107,18 +108,6 @@ npm install -g @alibaba-group/open-code-review После установки команда `ocr` доступна глобально. -**Обновление** - -Если установка выполнена через NPM, обновите вручную до последней версии: - -```bash -npm install -g @alibaba-group/open-code-review@latest -``` - -Установка через NPM также по умолчанию проверяет новые версии в фоне и обновляется автоматически. Чтобы отключить автообновления, задайте `OCR_NO_UPDATE=1`. - -Если вы устанавливали через install script или вручную скачанный бинарный файл, повторно запустите ту же команду установки/скачивания, чтобы заменить локальный бинарный файл последним релизом. Используйте `OCR_VERSION`, если нужно зафиксировать конкретный тег релиза. - **Из GitHub Release** Установите свежий бинарный файл для вашей ОС/архитектуры одной командой (macOS / Linux): @@ -280,10 +269,6 @@ ocr review --from main --to feature-branch # Один коммит ocr review --commit abc123 -# Возобновить прерванное ревью диапазона или одного коммита -ocr session list -ocr review --from main --to feature-branch --resume - # Полнофайловое сканирование — ревью целых файлов вместо диффа (история git не нужна) ocr scan # сканировать весь репозиторий ocr scan --path internal/agent # сканировать каталог или конкретные файлы @@ -436,21 +421,6 @@ 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/). - ## Команды | Команда | Алиас | Описание | @@ -464,8 +434,6 @@ ocr review \ | `ocr config unset custom_providers.` | — | Удалить пользовательского провайдера | | `ocr llm test` | — | Проверить подключение к LLM | | `ocr llm providers` | — | Показать список встроенных LLM-провайдеров | -| `ocr session list` | `ocr sessions list`, `ocr session ls` | Показать сохранённые сессии ревью | -| `ocr session show ` | `ocr sessions show ` | Показать одну сессию и её checkpoint'ы по файлам | | `ocr viewer` | `ocr v` | Запустить WebUI-просмотрщик сессий на `localhost:5483` | | `ocr version` | — | Показать информацию о версии | @@ -479,56 +447,17 @@ ocr review \ | `--commit` | `-c` | — | Один коммит для ревью | | `--exclude` | — | — | Паттерны в стиле gitignore через запятую для пропуска файлов; объединяются с excludes из rule.json | | `--preview` | `-p` | `false` | Показать, какие файлы попадут в ревью, без запуска LLM | -| `--resume` | — | — | Возобновить предыдущую совместимую сессию ревью диапазона или одного коммита | | `--format` | `-f` | `text` | Формат вывода: `text` или `json` | | `--concurrency` | — | `8` | Максимум одновременных ревью файлов | | `--timeout` | — | `10` | Таймаут конкурентной задачи в минутах | | `--audience` | — | `human` | `human` (показывать прогресс) или `agent` (только сводка) | | `--background` | `-b` | — | Необязательный контекст требований/бизнес-логики для ревью; при `--commit` автоматически заполняется из сообщения коммита | -| `--background-file` | `-B` | — | Необязательный контекст требований/бизнес-логики из Markdown-файла; при совместном использовании с `--background` встроенное значение идёт первым | | `--model` | — | — | Выбрать или переопределить LLM-модель для этого ревью | | `--rule` | — | — | Путь к пользовательским JSON-правилам ревью | | `--max-tools` | — | встроенное | Максимум раундов вызова инструментов на файл; действует, только если больше значения шаблона по умолчанию | | `--max-git-procs` | — | встроенное | Максимум одновременных git-подпроцессов | | `--tools` | — | — | Путь к пользовательскому JSON-конфигу инструментов | -#### Возобновляемые ревью и сессии - -Каждый запуск `ocr review` сохраняет локальный журнал сессии в -`~/.opencodereview/sessions/`. Успешный текстовый вывод остаётся сфокусированным -на результате ревью и не печатает session ID. Сохранённые сессии можно найти через -`ocr session list/show`, а `--format json` добавляет `session_id` в машиночитаемый -вывод. Если ревью диапазона или одного коммита было прервано, выберите сохранённую -сессию с тем же целевым ревью и возобновите её: - -```bash -ocr session list -ocr session show -ocr review --from main --to feature-branch --resume -ocr review --commit abc123 --resume -``` - -Возобновление намеренно строгое: поддерживаются только ревью диапазона веток и одного -коммита, но не ревью рабочей копии. Текущие `--from/--to` или `--commit` должны -совпадать с сохранённой сессией. `--preview` нельзя использовать вместе с `--resume`. - -При `--format json` возобновлённый запуск включает: - -- `session_id` — session ID текущего запуска -- `resume.resumed_from` — исходный session ID -- `resume.reused_files` — файлы, повторно использованные из сохранённых checkpoint'ов -- `resume.rerun_files` — файлы, заново проверенные в текущем запуске - -### Флаги `ocr session` - -| Команда | Флаг | По умолчанию | Описание | -|---------|------|--------------|----------| -| `ocr session list` | `--repo` | текущий каталог | Репозиторий, для которого нужно показать сессии | -| `ocr session list` | `--json` | `false` | Вывести сводки сессий в JSON | -| `ocr session list` | `--limit` | `20` | Ограничить количество сессий; `0` означает без ограничения | -| `ocr session show ` | `--repo` | текущий каталог | Репозиторий, сессию которого нужно посмотреть | -| `ocr session show ` | `--json` | `false` | Вывести метаданные сессии и элементы по файлам в JSON | - ### Флаги `ocr scan` `ocr scan` проверяет целые файлы, а не дифф — удобно для аудита незнакомой кодовой базы, предмиграционного сканирования или любого каталога без значимого диффа. Работает и в каталогах без git (используется обход файловой системы с учётом `.gitignore`). @@ -574,12 +503,6 @@ ocr review --from main --to my-feature --concurrency 4 # Ревью конкретного коммита с подробным JSON-выводом ocr review --commit abc123 --format json --audience agent -# Возобновить прерванное ревью диапазона или одного коммита -ocr session list -ocr session show -ocr review --from main --to my-feature --resume -ocr review --commit abc123 --resume - # Выбрать или переопределить модель для этого ревью ocr review --model claude-opus-4-6 ocr review --commit abc123 --model claude-sonnet-4-6 @@ -587,12 +510,6 @@ ocr review --commit abc123 --model claude-sonnet-4-6 # Передать контекст требований для более прицельного ревью ocr review --background "Добавляем rate limiting в API логина" -# Передать контекст требований из Markdown-файла -ocr review --background-file ./docs/my_business_context.md - -# Совместить встроенный контекст с локальным файлом контекста (используются оба) -ocr review --background "Фокус на аутентификации" --background-file ./docs/my_business_context.md - # Использовать собственные правила ревью ocr review --rule /path/to/my-rules.json @@ -847,22 +764,13 @@ ocr config set telemetry.otlp_endpoint localhost:4317 Установите `telemetry.content_logging`, чтобы включать промпты и ответы LLM в экспортируемые данные. -**Выбор протокола:** Переменная окружения `OTEL_EXPORTER_OTLP_PROTOCOL` определяет протокол экспорта: - -| Значение | Транспорт | Описание | -|---|---|---| -| `grpc` (по умолчанию) | gRPC | Порт по умолчанию 4317 | -| `http/protobuf` | HTTP | Порт по умолчанию 4318 | - -**Формат endpoint:** `telemetry.otlp_endpoint` принимает базовый URL в формате `host:port` или `http://host:port` без компонента пути. SDK автоматически добавляет путь сигнала (например, `/v1/traces`) в соответствии со [спецификацией OTLP](https://opentelemetry.io/docs/specs/otlp/#otlphttp-request). - ## Участие в разработке -Этот проект существует благодаря всем, кто вносит свой вклад. В [CONTRIBUTING.ru-RU.md](CONTRIBUTING.ru-RU.md) описаны настройка окружения разработки, рекомендации по коду и порядок отправки pull request'ов. +В [CONTRIBUTING.ru-RU.md](CONTRIBUTING.ru-RU.md) описаны настройка окружения разработки, рекомендации по коду и порядок отправки pull request'ов. - - - +## История звёзд + +[![Star History Chart](https://api.star-history.com/svg?repos=alibaba/open-code-review&type=Date)](https://star-history.com/#alibaba/open-code-review&Date) ## Лицензия diff --git a/README.zh-CN.md b/README.zh-CN.md index 13ae792..1f2c2e4 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -13,6 +13,7 @@

npm Build status + Go Report Card License Ask DeepWiki OpenSSF Best Practices @@ -107,18 +108,6 @@ npm install -g @alibaba-group/open-code-review 安装后,`ocr` 命令即可全局使用。 -**更新** - -如果通过 NPM 安装,可手动更新到最新版本: - -```bash -npm install -g @alibaba-group/open-code-review@latest -``` - -通过 NPM 安装的 `ocr` 还会默认在后台检查新版本并自动升级;如需关闭自动更新,可设置 `OCR_NO_UPDATE=1`。 - -如果通过安装脚本或手动下载二进制文件安装,重新运行对应的安装/下载命令即可替换为最新 release。需要固定版本时,可继续通过 `OCR_VERSION` 指定 release tag。 - **从 GitHub Release 下载** 使用一条命令为你的操作系统/架构安装最新二进制文件(macOS / Linux): @@ -280,10 +269,6 @@ ocr review --from main --to feature-branch # 单个提交 ocr review --commit abc123 -# 恢复中断的区间或单 commit 评审 -ocr session list -ocr review --from main --to feature-branch --resume - # 全量文件扫描 —— 审查整个文件而非 diff(无需 git 历史) ocr scan # 扫描整个仓库 ocr scan --path internal/agent # 扫描指定目录或文件 @@ -434,21 +419,6 @@ 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/) 目录。 - ## 命令 | 命令 | 别名 | 描述 | @@ -462,8 +432,6 @@ ocr review \ | `ocr config unset custom_providers.` | — | 删除自定义供应商 | | `ocr llm test` | — | 测试 LLM 连通性 | | `ocr llm providers` | — | 列出内置 LLM 供应商 | -| `ocr session list` | `ocr sessions list`, `ocr session ls` | 列出已保存的评审会话 | -| `ocr session show ` | `ocr sessions show ` | 查看单个会话及其逐文件检查点 | | `ocr viewer` | `ocr v` | 启动 WebUI 会话查看器,地址 `localhost:5483` | | `ocr version` | — | 显示版本信息 | @@ -477,53 +445,17 @@ ocr review \ | `--commit` | `-c` | — | 审查单个提交 | | `--exclude` | — | — | 以逗号分隔的 gitignore 风格模式,用于跳过匹配文件;与 rule.json 中的 excludes 合并 | | `--preview` | `-p` | `false` | 预览将被审查的文件列表,不调用 LLM | -| `--resume` | — | — | 从之前兼容的区间或单 commit 评审会话恢复 | | `--format` | `-f` | `text` | 输出格式:`text` 或 `json` | | `--concurrency` | — | `8` | 最大并发文件审查数 | | `--timeout` | — | `10` | 并发任务超时时间(分钟) | | `--audience` | — | `human` | `human`(显示进度)或 `agent`(仅输出摘要) | | `--background` | `-b` | — | 可选的需求/业务背景信息;使用 `--commit` 时如未指定则自动从 commit message 中提取 | -| `--background-file` | `-B` | — | 来自 Markdown 文件的可选需求/业务背景信息;与 `--background` 同时使用时,内联内容排在前面 | | `--model` | — | — | 为本次审查选择或覆盖 LLM 模型 | | `--rule` | — | — | 自定义 JSON 审查规则路径 | | `--max-tools` | — | 内置默认 | 每个文件的最大工具调用轮次;仅在大于模板默认值时生效 | | `--max-git-procs` | — | 内置默认 | 最大并发 git 子进程数 | | `--tools` | — | — | 自定义 JSON 工具配置路径 | -#### 可恢复评审与会话 - -每次 `ocr review` 都会在 `~/.opencodereview/sessions/` 下保存本地会话日志。 -正常完成的文本输出只展示评审结果,不打印 session ID;可使用 -`ocr session list/show` 查找已保存会话,或用 `--format json` 在机器可读输出中获取 -`session_id`。如果区间或单 commit 评审被中断,可列出保存的会话,并从匹配相同评审目标的会话恢复: - -```bash -ocr session list -ocr session show -ocr review --from main --to feature-branch --resume -ocr review --commit abc123 --resume -``` - -恢复逻辑是严格的:仅支持分支区间和单 commit 评审,不支持工作区评审;当前 -`--from/--to` 或 `--commit` 必须与保存的会话一致。`--preview` 不能与 `--resume` 同时使用。 - -使用 `--format json` 时,恢复运行会包含: - -- `session_id` — 当前运行的 session ID -- `resume.resumed_from` — 来源 session ID -- `resume.reused_files` — 从已保存检查点复用的文件数 -- `resume.rerun_files` — 本次重新评审的文件数 - -### `ocr session` 参数 - -| 命令 | 参数 | 默认值 | 描述 | -|------|------|--------|------| -| `ocr session list` | `--repo` | 当前目录 | 要列出会话的仓库 | -| `ocr session list` | `--json` | `false` | 以 JSON 输出会话摘要 | -| `ocr session list` | `--limit` | `20` | 限制列出的会话数量;`0` 表示不限 | -| `ocr session show ` | `--repo` | 当前目录 | 要查看会话的仓库 | -| `ocr session show ` | `--json` | `false` | 以 JSON 输出会话元数据和逐文件条目 | - ### `ocr scan` 参数 `ocr scan` 审查整个文件而非 diff —— 适用于审计不熟悉的代码库、迁移前扫描,或任何没有有意义 diff 的目录。它也可以在非 git 目录中工作(会回退到遵循 `.gitignore` 的文件系统遍历)。 @@ -569,12 +501,6 @@ ocr review --from main --to my-feature --concurrency 4 # 审查特定提交并以 JSON 格式输出详细信息 ocr review --commit abc123 --format json --audience agent -# 恢复中断的区间或单 commit 评审 -ocr session list -ocr session show -ocr review --from main --to my-feature --resume -ocr review --commit abc123 --resume - # 为本次审查选择或覆盖模型 ocr review --model claude-opus-4-6 ocr review --commit abc123 --model claude-sonnet-4-6 @@ -582,12 +508,6 @@ ocr review --commit abc123 --model claude-sonnet-4-6 # 提供需求背景以获得更有针对性的审查 ocr review --background "为登录 API 添加限流" -# 从 Markdown 文件提供需求背景 -ocr review --background-file ./docs/my_business_context.md - -# 将内联背景与本地背景文件结合使用(两者都会生效) -ocr review --background "关注鉴权" --background-file ./docs/my_business_context.md - # 使用自定义审查规则 ocr review --rule /path/to/my-rules.json @@ -832,22 +752,13 @@ ocr config set telemetry.otlp_endpoint localhost:4317 设置 `telemetry.content_logging` 可在导出数据中包含 LLM 提示词和响应。 -**协议选择:** 通过环境变量 `OTEL_EXPORTER_OTLP_PROTOCOL` 选择导出协议: - -| 值 | 传输方式 | 说明 | -|---|---|---| -| `grpc`(默认) | gRPC | 默认端口 4317 | -| `http/protobuf` | HTTP | 默认端口 4318 | - -**Endpoint 格式:** `telemetry.otlp_endpoint` 的值为 `host:port` 或 `http://host:port`,无需包含路径。SDK 会根据 [OTLP 规范](https://opentelemetry.io/docs/specs/otlp/#otlphttp-request)自动追加信号路径(如 `/v1/traces`)。 - ## 贡献 -感谢所有为本项目做出贡献的人。参见 [CONTRIBUTING.zh-CN.md](CONTRIBUTING.zh-CN.md) 了解开发环境搭建、编码规范以及如何提交 Pull Request。 +参见 [CONTRIBUTING.zh-CN.md](CONTRIBUTING.zh-CN.md) 了解开发环境搭建、编码规范以及如何提交 Pull Request。 - - - +## Star History + +[![Star History Chart](https://api.star-history.com/svg?repos=alibaba/open-code-review&type=Date)](https://star-history.com/#alibaba/open-code-review&Date) ## 许可证 diff --git a/ROADMAP.md b/ROADMAP.md index 07d0b38..4fa8ada 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -19,9 +19,6 @@ OpenCodeReview currently provides: - CI/CD integration (GitHub Actions, GitLab CI, etc.). - Multi-provider LLM support (OpenAI-compatible, Anthropic, Google Gemini, Amazon Bedrock, Azure OpenAI, etc.). -- MCP server — expose OpenCodeReview over the - [Model Context Protocol](https://modelcontextprotocol.io/) so review - capabilities can be invoked from any MCP-compatible client. - Review rules engine with per-file pattern matching. - Multi-language documentation (English, Chinese, Japanese, Korean, Russian). @@ -33,15 +30,13 @@ OpenCodeReview currently provides: PyCharm, and other JetBrains IDEs with the same capabilities as the existing VSCode extension. -### Delegate Mode +### MCP Integration -- **Subscription-friendly review** — An opt-in mode where `ocr` no longer - depends on a separately-configured LLM endpoint. Instead of calling an - LLM itself, `ocr` resolves the review scope, applies excludes, loads - review rules, injects background context, and collects the diffs, then - hands that off as a structured review task for the host coding agent - (e.g. Claude Code) to execute using its own agent loop and included - subscription usage — removing the need for a standalone API key. +- **Standard MCP server** — Expose OpenCodeReview as a + [Model Context Protocol](https://modelcontextprotocol.io/) server, + allowing users to integrate external context tools (documentation + retrieval, issue trackers, internal knowledge bases) into the review + process through the standard MCP interface. ### Ultra Mode diff --git a/action.yml b/action.yml deleted file mode 100644 index 203b2e2..0000000 --- a/action.yml +++ /dev/null @@ -1,305 +0,0 @@ -name: OpenCodeReview PR Review -description: >- - AI-powered GitHub PR review with inline comments, sticky summary, and - incremental non-destructive posting. -author: alibaba -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), - }); diff --git a/cmd/opencodereview/background_file.go b/cmd/opencodereview/background_file.go deleted file mode 100644 index 2c14a5c..0000000 --- a/cmd/opencodereview/background_file.go +++ /dev/null @@ -1,135 +0,0 @@ -package main - -import ( - "fmt" - "os" - "regexp" - "strings" - "unicode" -) - -const ( - backgroundSoftLimit = 2000 - backgroundHardLimit = 8000 - backgroundOpenTag = "" - backgroundCloseTag = "" - maxBackgroundFileBytes = 1 << 20 // 1 MB -) - -var multiNewline = regexp.MustCompile(`\n{3,}`) - -// mergeBackground combines the inline --background value (or an auto-populated -// commit message) with the content read from --background-file, separated by a -// blank line. The inline value is sanitised the same way as the file content so -// both portions are cleaned consistently. The file content is already wrapped -// and sanitised by loadBackgroundFile. -func mergeBackground(inline, fromFile string) string { - inline = sanitizeMarkdown(inline) - switch { - case inline == "": - return fromFile - case fromFile == "": - return inline - default: - return inline + "\n\n" + fromFile - } -} - -func loadBackgroundFile(path string) (string, error) { - info, err := os.Stat(path) - if err != nil { - return "", fmt.Errorf("read background file %q: %w", path, err) - } - if info.IsDir() { - return "", fmt.Errorf("background file %q is a directory, not a file", path) - } - if info.Size() > maxBackgroundFileBytes { - return "", fmt.Errorf( - "background file %q is %d bytes, exceeding the maximum of %d bytes; please provide a smaller file", - path, info.Size(), maxBackgroundFileBytes, - ) - } - - raw, err := os.ReadFile(path) - if err != nil { - return "", fmt.Errorf("read background file %q: %w", path, err) - } - - cleaned := sanitizeMarkdown(string(raw)) - if cleaned == "" { - return "", fmt.Errorf("background file %q is empty after sanitisation", path) - } - - if strings.Contains(cleaned, backgroundOpenTag) || strings.Contains(cleaned, backgroundCloseTag) { - return "", fmt.Errorf( - "background file %q must not contain the reserved delimiters %q or %q", - path, backgroundOpenTag, backgroundCloseTag, - ) - } - - // Enforce the limits on the cleaned content only: the wrapper delimiters add - // overhead the user cannot control, so counting them would make the reported - // character count misleading. - if n := len([]rune(cleaned)); n > backgroundHardLimit { - return "", fmt.Errorf( - "background content is %d characters, exceeding the hard limit of %d (aborting)", - n, backgroundHardLimit, - ) - } else if n > backgroundSoftLimit { - fmt.Fprintf(os.Stderr, - "[ocr] --background-file content is %d characters, exceeding the recommended %d (continuing but review quality might be impacted)\n", - n, backgroundSoftLimit, - ) - } - - return backgroundOpenTag + "\n" + cleaned + "\n" + backgroundCloseTag, nil -} - -func sanitizeMarkdown(s string) string { - var b strings.Builder - b.Grow(len(s)) - - for _, r := range s { - switch r { - case '\n', '\t': - b.WriteRune(r) - continue - case '\r': - continue - } - if isForbiddenChar(r) { - continue - } - b.WriteRune(r) - } - - collapsed := multiNewline.ReplaceAllString(b.String(), "\n\n") - return strings.TrimSpace(collapsed) -} - -func isForbiddenChar(r rune) bool { - switch { - case r <= 0x1F: // C0 control characters (includes NUL) - return true - case r >= 0x7F && r <= 0x9F: // DEL and C1 control characters - return true - } - - // The runes below all belong to Unicode category Cf and are therefore - // already caught by the unicode.Is(unicode.Cf, r) check at the end. They are - // listed explicitly only as documentation of the most common invisible - // characters we strip; the switch is redundant, not a correctness necessity. - switch r { - case '\u200B', // zero-width space - '\u200C', // zero-width non-joiner - '\u200D', // zero-width joiner - '\u200E', // left-to-right mark - '\u200F', // right-to-left mark - '\u2060', // word joiner - '\u00AD', // soft hyphen - '\uFEFF': // BOM / zero-width no-break space - return true - } - - return unicode.Is(unicode.Cf, r) -} diff --git a/cmd/opencodereview/background_file_test.go b/cmd/opencodereview/background_file_test.go deleted file mode 100644 index cb18f21..0000000 --- a/cmd/opencodereview/background_file_test.go +++ /dev/null @@ -1,312 +0,0 @@ -package main - -import ( - "os" - "os/exec" - "path/filepath" - "strings" - "testing" -) - -// writeTempFile writes content to a temporary file and returns its path. -func writeTempFile(t *testing.T, content string) string { - t.Helper() - path := filepath.Join(t.TempDir(), "background.md") - if err := os.WriteFile(path, []byte(content), 0o600); err != nil { - t.Fatalf("write temp file: %v", err) - } - return path -} - -func TestLoadBackgroundFileNotFound(t *testing.T) { - _, err := loadBackgroundFile(filepath.Join(t.TempDir(), "does-not-exist.md")) - if err == nil { - t.Fatal("expected an error for a missing file, got nil") - } -} - -func TestLoadBackgroundFileEmpty(t *testing.T) { - cases := map[string]string{ - "zero bytes": "", - "whitespace only": " \n\t \n ", - "invisible only": "\u200B\u200E\u00AD\uFEFF", - } - for name, content := range cases { - t.Run(name, func(t *testing.T) { - _, err := loadBackgroundFile(writeTempFile(t, content)) - if err == nil { - t.Fatal("expected an error for empty-after-sanitisation content, got nil") - } - if !strings.Contains(err.Error(), "empty") { - t.Errorf("error = %q, want it to mention 'empty'", err) - } - }) - } -} - -func TestLoadBackgroundFileControlCharRemoval(t *testing.T) { - // Mix in NUL, bell, DEL, a C1 control char, zero-width space, BOM and an - // LTR mark around legitimate text. - content := "Hello\x00\x07world\x7f\u0085!\u200B\uFEFF\u200E" - got, err := loadBackgroundFile(writeTempFile(t, content)) - if err != nil { - t.Fatalf("loadBackgroundFile: %v", err) - } - for _, bad := range []string{"\x00", "\x07", "\x7f", "\u0085", "\u200B", "\uFEFF", "\u200E"} { - if strings.Contains(got, bad) { - t.Errorf("result still contains control/invisible char %q: %q", bad, got) - } - } - if !strings.Contains(got, "Helloworld!") { - t.Errorf("expected cleaned text to contain %q, got %q", "Helloworld!", got) - } -} - -func TestSanitizeMarkdownPreservesNewlinesAndTabs(t *testing.T) { - got := sanitizeMarkdown("line1\n\tindented\nline3") - want := "line1\n\tindented\nline3" - if got != want { - t.Errorf("sanitizeMarkdown = %q, want %q", got, want) - } -} - -func TestSanitizeMarkdownCollapsesNewlines(t *testing.T) { - got := sanitizeMarkdown("a\n\n\n\n\nb") - want := "a\n\nb" - if got != want { - t.Errorf("sanitizeMarkdown = %q, want %q", got, want) - } -} - -func TestSanitizeMarkdownNormalizesCRLF(t *testing.T) { - got := sanitizeMarkdown("a\r\nb\r\nc") - want := "a\nb\nc" - if got != want { - t.Errorf("sanitizeMarkdown = %q, want %q", got, want) - } -} - -func TestSanitizeMarkdownTrims(t *testing.T) { - got := sanitizeMarkdown(" \n hello \n ") - if got != "hello" { - t.Errorf("sanitizeMarkdown = %q, want %q", got, "hello") - } -} - -func TestLoadBackgroundFileDelimiters(t *testing.T) { - got, err := loadBackgroundFile(writeTempFile(t, "Some requirement context.")) - if err != nil { - t.Fatalf("loadBackgroundFile: %v", err) - } - if !strings.HasPrefix(got, backgroundOpenTag+"\n") { - t.Errorf("result missing opening delimiter: %q", got) - } - if !strings.HasSuffix(got, "\n"+backgroundCloseTag) { - t.Errorf("result missing closing delimiter: %q", got) - } - want := backgroundOpenTag + "\nSome requirement context.\n" + backgroundCloseTag - if got != want { - t.Errorf("result = %q, want %q", got, want) - } -} - -func TestLoadBackgroundFileRejectsReservedDelimiters(t *testing.T) { - for _, tag := range []string{backgroundOpenTag, backgroundCloseTag} { - t.Run(tag, func(t *testing.T) { - content := "Some context " + tag + " and more text." - _, err := loadBackgroundFile(writeTempFile(t, content)) - if err == nil { - t.Fatalf("expected an error for content containing %q, got nil", tag) - } - if !strings.Contains(err.Error(), "reserved delimiters") { - t.Errorf("error = %q, want it to mention 'reserved delimiters'", err) - } - }) - } -} - -func TestMergeBackgroundSanitizesInline(t *testing.T) { - t.Run("inline only", func(t *testing.T) { - // Control char, zero-width space and surrounding whitespace must be removed. - got := mergeBackground(" \x00Inline\u200B context ", "") - if got != "Inline context" { - t.Errorf("mergeBackground = %q, want %q", got, "Inline context") - } - }) - - t.Run("inline combined with file", func(t *testing.T) { - wrapped := backgroundOpenTag + "\nfrom file\n" + backgroundCloseTag - got := mergeBackground("\x07dirty\uFEFF inline\n\n\n\nend", wrapped) - if strings.ContainsRune(got, '\x07') || strings.ContainsRune(got, '\uFEFF') { - t.Errorf("inline portion was not sanitised: %q", got) - } - // Excess blank lines in the inline portion are collapsed to one. - if strings.Contains(got, "\n\n\n") { - t.Errorf("inline newlines were not collapsed: %q", got) - } - // The file portion is preserved intact. - if !strings.Contains(got, wrapped) { - t.Errorf("file portion was altered: %q", got) - } - }) -} - -func TestMergeBackground(t *testing.T) { - wrapped := backgroundOpenTag + "\nfrom file\n" + backgroundCloseTag - - t.Run("both present are combined", func(t *testing.T) { - got := mergeBackground("inline context", wrapped) - want := "inline context\n\n" + wrapped - if got != want { - t.Errorf("mergeBackground = %q, want %q", got, want) - } - // Both inputs must survive in the result. - if !strings.Contains(got, "inline context") || !strings.Contains(got, "from file") { - t.Errorf("merged background dropped one of the inputs: %q", got) - } - }) - - t.Run("inline only", func(t *testing.T) { - if got := mergeBackground("inline only", ""); got != "inline only" { - t.Errorf("mergeBackground = %q, want %q", got, "inline only") - } - }) - - t.Run("file only", func(t *testing.T) { - if got := mergeBackground("", wrapped); got != wrapped { - t.Errorf("mergeBackground = %q, want %q", got, wrapped) - } - }) -} - -func TestLoadBackgroundFileSoftLimit(t *testing.T) { - // Just above the soft limit but below the hard size limit: must succeed. - content := strings.Repeat("a", backgroundSoftLimit+100) - got, err := loadBackgroundFile(writeTempFile(t, content)) - if err != nil { - t.Fatalf("loadBackgroundFile: %v", err) - } - if !strings.Contains(got, content) { - t.Error("expected content to be preserved past the soft limit") - } -} - -func TestLoadBackgroundFileOversized(t *testing.T) { - // A file larger than maxBackgroundFileBytes must be rejected up front, - // before its content is read into memory. - content := strings.Repeat("a", maxBackgroundFileBytes+1) - _, err := loadBackgroundFile(writeTempFile(t, content)) - if err == nil { - t.Fatal("expected an error for an oversized file, got nil") - } - if !strings.Contains(err.Error(), "maximum") { - t.Errorf("error = %q, want it to mention the byte 'maximum'", err) - } -} - -func TestLoadBackgroundFileDirectory(t *testing.T) { - if _, err := loadBackgroundFile(t.TempDir()); err == nil { - t.Fatal("expected an error when the path is a directory, got nil") - } -} - -func TestLoadBackgroundFileHardLimit(t *testing.T) { - content := strings.Repeat("a", backgroundHardLimit+1) - _, err := loadBackgroundFile(writeTempFile(t, content)) - if err == nil { - t.Fatal("expected an error when exceeding the hard size limit, got nil") - } - if !strings.Contains(err.Error(), "hard limit") { - t.Errorf("error = %q, want it to mention 'hard limit'", err) - } -} - -func TestLoadBackgroundFileHardLimitExcludesWrapper(t *testing.T) { - // The wrapper delimiters must NOT count toward the limit: cleaned content of - // exactly the hard limit is accepted even though the wrapped string is longer. - content := strings.Repeat("a", backgroundHardLimit) - if _, err := loadBackgroundFile(writeTempFile(t, content)); err != nil { - t.Fatalf("cleaned content at the hard limit must be accepted, got: %v", err) - } -} - -func TestLoadBackgroundFileMultiByteRuneCount(t *testing.T) { - // Multi-byte runes must be counted as single characters, not bytes. - // A precomposed accented letter is one rune but two bytes; a string of exactly - // backgroundHardLimit runes (~2x the byte count) must still be accepted. - content := strings.Repeat("\u00E9", backgroundHardLimit) - got, err := loadBackgroundFile(writeTempFile(t, content)) - if err != nil { - t.Fatalf("loadBackgroundFile rejected content within the rune limit: %v", err) - } - if !strings.Contains(got, content) { - t.Error("expected multi-byte content to be preserved") - } -} - -// initRepoWithCommit creates a real git repository with a single commit whose -// message is `message`, and returns the repo directory and the commit hash. -func initRepoWithCommit(t *testing.T, message string) (string, string) { - t.Helper() - repo := t.TempDir() - run := func(args ...string) []byte { - cmd := exec.Command("git", args...) - cmd.Dir = repo - out, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("git %v failed: %v\n%s", args, err, out) - } - return out - } - run("init", "-q") - run("config", "user.email", "test@example.com") - run("config", "user.name", "Test") - run("config", "commit.gpgsign", "false") - if err := os.WriteFile(filepath.Join(repo, "file.txt"), []byte("hello\n"), 0o600); err != nil { - t.Fatalf("write file: %v", err) - } - run("add", ".") - run("commit", "-q", "-m", message) - hash := strings.TrimSpace(string(run("rev-parse", "HEAD"))) - return repo, hash -} - -// TestBackgroundFromCommitThenFile reproduces the resolution order used by -// runReview when --background-file is supplied but --background is not: the -// inline background is first auto-filled from the commit message, then the -// background file is appended. Both must end up in the final background. -func TestBackgroundFromCommitThenFile(t *testing.T) { - const commitMsg = "Implement rate limiting on login" - repo, hash := initRepoWithCommit(t, commitMsg) - - // Mirror runReview: --background empty + --commit set -> use commit message. - background := "" - msg, err := getCommitMessage(repo, hash) - if err != nil { - t.Fatalf("getCommitMessage: %v", err) - } - if msg != commitMsg { - t.Fatalf("commit message = %q, want %q", msg, commitMsg) - } - if background == "" { - background = msg - } - - // Then --background-file is loaded and merged in. - fileBg, err := loadBackgroundFile(writeTempFile(t, "Extra context from a file.")) - if err != nil { - t.Fatalf("loadBackgroundFile: %v", err) - } - background = mergeBackground(background, fileBg) - - // The commit message must come first, followed by the wrapped file content. - if !strings.HasPrefix(background, commitMsg+"\n\n") { - t.Errorf("expected commit message to lead the background, got %q", background) - } - if !strings.Contains(background, "Extra context from a file.") { - t.Errorf("expected file content to be appended, got %q", background) - } - if !strings.Contains(background, backgroundOpenTag) || !strings.Contains(background, backgroundCloseTag) { - t.Errorf("expected file content to keep its delimiters, got %q", background) - } -} diff --git a/cmd/opencodereview/emit_run_result_test.go b/cmd/opencodereview/emit_run_result_test.go index f153fe2..4082ca2 100644 --- a/cmd/opencodereview/emit_run_result_test.go +++ b/cmd/opencodereview/emit_run_result_test.go @@ -7,9 +7,6 @@ import ( "testing" "time" - "go.opentelemetry.io/otel" - sdktrace "go.opentelemetry.io/otel/sdk/trace" - "github.com/open-code-review/open-code-review/internal/agent" "github.com/open-code-review/open-code-review/internal/model" ) @@ -25,8 +22,6 @@ type mockResultProvider struct { warnings []agent.AgentWarning projectSummary string toolCalls map[string]int64 - resumeInfo *agent.ResumeInfo - sessionID string } func (m *mockResultProvider) Diffs() []model.Diff { return m.diffs } @@ -39,8 +34,6 @@ func (m *mockResultProvider) TotalCacheWriteTokens() int64 { return m.cacheWri func (m *mockResultProvider) Warnings() []agent.AgentWarning { return m.warnings } func (m *mockResultProvider) ProjectSummary() string { return m.projectSummary } func (m *mockResultProvider) ToolCalls() map[string]int64 { return m.toolCalls } -func (m *mockResultProvider) ResumeInfo() *agent.ResumeInfo { return m.resumeInfo } -func (m *mockResultProvider) SessionID() string { return m.sessionID } func TestEmitRunResult_JSONNoFiles(t *testing.T) { ag := &mockResultProvider{filesReviewed: 0} @@ -87,32 +80,6 @@ func TestEmitRunResult_JSONWithComments(t *testing.T) { } } -func TestEmitRunResult_JSONWithResumeInfo(t *testing.T) { - ag := &mockResultProvider{ - filesReviewed: 2, - resumeInfo: &agent.ResumeInfo{ - ResumedFrom: "old-session", - ReusedFiles: 1, - RerunFiles: 1, - PreviousModel: "anthropic-model", - CurrentModel: "openai-model", - }, - } - got := captureStdout(t, func() { - err := emitRunResult(context.Background(), ag, nil, time.Now(), "json", "developer", nil) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - }) - var out jsonOutput - if err := json.Unmarshal([]byte(got), &out); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if out.Resume == nil || out.Resume.ResumedFrom != "old-session" || out.Resume.ReusedFiles != 1 || out.Resume.RerunFiles != 1 { - t.Fatalf("resume = %+v", out.Resume) - } -} - func TestEmitRunResult_TextNoComments(t *testing.T) { ag := &mockResultProvider{filesReviewed: 2} got := captureStdout(t, func() { @@ -126,19 +93,6 @@ func TestEmitRunResult_TextNoComments(t *testing.T) { } } -func TestEmitRunResult_TextDoesNotPrintSuccessfulSessionHint(t *testing.T) { - ag := &mockResultProvider{filesReviewed: 2, sessionID: "session-123"} - got := captureStdout(t, func() { - err := emitRunResult(context.Background(), ag, nil, time.Now(), "text", "developer", nil) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - }) - if strings.Contains(got, "session-123") || strings.Contains(got, "--resume") { - t.Fatalf("successful text output should not print session ID or resume hint, got %q", got) - } -} - func TestEmitRunResult_TextWithComments(t *testing.T) { ag := &mockResultProvider{filesReviewed: 1} comments := []model.LlmComment{{Path: "a.go", Content: "rename", StartLine: 5, EndLine: 10}} @@ -221,78 +175,3 @@ func TestEmitRunResult_NilQuietHandle(t *testing.T) { }) _ = got } - -func TestEmitRunResult_JSONTraceIDFromContext(t *testing.T) { - tp := sdktrace.NewTracerProvider() - defer func() { _ = tp.Shutdown(context.Background()) }() - otel.SetTracerProvider(tp) - - ctx, span := tp.Tracer("test").Start(context.Background(), "test-root") - wantTraceID := span.SpanContext().TraceID().String() - defer span.End() - - ag := &mockResultProvider{ - filesReviewed: 2, - inputTokens: 10, - outputTokens: 5, - totalTokens: 15, - } - got := captureStdout(t, func() { - err := emitRunResult(ctx, ag, nil, time.Now(), "json", "developer", nil) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - }) - var out jsonOutput - if err := json.Unmarshal([]byte(got), &out); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if out.TraceID != wantTraceID { - t.Errorf("trace_id = %q, want %q", out.TraceID, wantTraceID) - } -} - -func TestEmitRunResult_JSONNoFilesTraceID(t *testing.T) { - tp := sdktrace.NewTracerProvider() - defer func() { _ = tp.Shutdown(context.Background()) }() - otel.SetTracerProvider(tp) - - ctx, span := tp.Tracer("test").Start(context.Background(), "test-root") - wantTraceID := span.SpanContext().TraceID().String() - defer span.End() - - ag := &mockResultProvider{filesReviewed: 0} - got := captureStdout(t, func() { - err := emitRunResult(ctx, ag, nil, time.Now(), "json", "developer", nil) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - }) - var out jsonOutput - if err := json.Unmarshal([]byte(got), &out); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if out.Status != "skipped" { - t.Errorf("status = %q, want skipped", out.Status) - } - if out.TraceID != wantTraceID { - t.Errorf("trace_id = %q, want %q", out.TraceID, wantTraceID) - } -} - -func TestEmitRunResult_JSONIncludesSessionID(t *testing.T) { - ag := &mockResultProvider{filesReviewed: 1, sessionID: "session-99"} - got := captureStdout(t, func() { - err := emitRunResult(context.Background(), ag, nil, time.Now(), "json", "developer", nil) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - }) - var out jsonOutput - if err := json.Unmarshal([]byte(got), &out); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if out.SessionID != "session-99" { - t.Errorf("session_id = %q, want session-99", out.SessionID) - } -} diff --git a/cmd/opencodereview/flags.go b/cmd/opencodereview/flags.go index 1f72bb1..ccda515 100644 --- a/cmd/opencodereview/flags.go +++ b/cmd/opencodereview/flags.go @@ -101,12 +101,10 @@ type reviewOptions struct { from string to string commit string - resume string excludes string // --exclude: comma-separated gitignore-style patterns outputFormat string audience string // --audience: "human" (default) or "agent" background string // --background: optional requirement context - backgroundFile string // --background-file: path to a Markdown file used as background model string // --model: override resolved LLM model for this review concurrency int perFileTimeout int @@ -127,14 +125,12 @@ func parseReviewFlags(args []string) (reviewOptions, error) { a.StringVar(&opts.from, "from", "", "source ref to start diff from (e.g., 'main')") a.StringVar(&opts.to, "to", "", "target ref to end diff at (e.g., 'feature-branch')") a.StringVarP(&opts.commit, "commit", "c", "", "single commit hash or tag to review (vs its parent)") - a.StringVar(&opts.resume, "resume", "", "resume from a previous review session id") a.StringVar(&opts.excludes, "exclude", "", "comma-separated gitignore-style patterns to exclude; merged with rule.json excludes") a.StringVarP(&opts.outputFormat, "format", "f", "text", "output format: text or json") a.IntVar(&opts.concurrency, "concurrency", 8, "max concurrent file reviews") a.IntVar(&opts.perFileTimeout, "timeout", 10, "concurrent task timeout in minutes") a.StringVar(&opts.audience, "audience", "human", "output audience: human (show progress) or agent (summary only)") a.StringVarP(&opts.background, "background", "b", "", "optional requirement/business context for the review") - a.StringVarP(&opts.backgroundFile, "background-file", "B", "", "optional requirement/business context from a Markdown file (combined with --background; inline value appears first when both are set)") a.StringVar(&opts.model, "model", "", "override LLM model for this review (e.g., claude-opus-4-6)") a.IntVar(&opts.maxTools, "max-tools", 0, "max tool call rounds per file (0 = template default; min 10)") a.IntVar(&opts.maxGitProcs, "max-git-procs", 16, "max concurrent git subprocesses") @@ -166,9 +162,6 @@ func parseReviewFlags(args []string) (reviewOptions, error) { if opts.to != "" && opts.from == "" { return opts, fmt.Errorf("--from is required when --to is specified") } - if opts.preview && opts.resume != "" { - return opts, fmt.Errorf("--preview and --resume cannot be used together") - } switch opts.audience { case "human", "agent": @@ -210,9 +203,6 @@ Examples: ocr review --commit abc123 ocr review -c abc123 - # Resume a previous range review - ocr review --from master --to dev-ref --resume - # Output JSON format ocr review --format json ocr review -f json @@ -224,29 +214,22 @@ Examples: ocr review --preview ocr review -c abc123 -p - # Provide requirement/business context inline, from a Markdown file, or both - ocr review --background "Adding rate limiting to the login API" - ocr review --background-file ./docs/requirements.md - ocr review --background "Focus on auth" --background-file ./docs/requirements.md - Flags: - --audience string output audience: human (show progress) or agent (summary only) (default "human") - -b, --background string optional requirement/business context for the review - -B, --background-file string path to a Markdown file used as review background (combined with --background; inline value appears first when both are set) - -c, --commit string single commit hash or tag to review (vs its parent) - -f, --format string output format: text or json (default "text") - --concurrency int max concurrent file reviews (default 8) - --max-git-procs int max concurrent git subprocesses (default 16) - --from string source ref to start diff from (e.g., 'main') - --max-tools int max tool call rounds per file (0 = template default; min 10) - --model string override LLM model for this review (e.g., claude-opus-4-6) - -p, --preview preview which files will be reviewed without running the LLM - --repo string root directory of the git repository (default: current dir) - --resume string resume from a previous review session id - --rule string path to JSON file with system review rules - --timeout int concurrent task timeout in minutes (default 10) - --to string target ref to end diff at (e.g., 'feature-branch') - --tools string path to JSON tools config file (default: embedded)`) + --audience string output audience: human (show progress) or agent (summary only) (default "human") + -b, --background string optional requirement/business context for the review + -c, --commit string single commit hash or tag to review (vs its parent) + -f, --format string output format: text or json (default "text") + --concurrency int max concurrent file reviews (default 8) + --max-git-procs int max concurrent git subprocesses (default 16) + --from string source ref to start diff from (e.g., 'main') + --max-tools int max tool call rounds per file (0 = template default; min 10) + --model string override LLM model for this review (e.g., claude-opus-4-6) + -p, --preview preview which files will be reviewed without running the LLM + --repo string root directory of the git repository (default: current dir) + --rule string path to JSON file with system review rules + --timeout int concurrent task timeout in minutes (default 10) + --to string target ref to end diff at (e.g., 'feature-branch') + --tools string path to JSON tools config file (default: embedded)`) } // --- config subcommand --- diff --git a/cmd/opencodereview/flags_test.go b/cmd/opencodereview/flags_test.go index 8e25996..15701ca 100644 --- a/cmd/opencodereview/flags_test.go +++ b/cmd/opencodereview/flags_test.go @@ -5,20 +5,6 @@ import ( "time" ) -func TestParseReviewFlagsBackgroundFile(t *testing.T) { - for _, flag := range []string{"--background-file", "-B"} { - t.Run(flag, func(t *testing.T) { - opts, err := parseReviewFlags([]string{flag, "./docs/req.md"}) - if err != nil { - t.Fatalf("parseReviewFlags: %v", err) - } - if opts.backgroundFile != "./docs/req.md" { - t.Errorf("backgroundFile = %q, want %q", opts.backgroundFile, "./docs/req.md") - } - }) - } -} - func TestParseReviewFlagsModelOverride(t *testing.T) { opts, err := parseReviewFlags([]string{"--model", "claude-opus-4-6"}) if err != nil { @@ -36,23 +22,6 @@ func TestParseReviewFlagsModelOverride(t *testing.T) { } } -func TestParseReviewFlagsResume(t *testing.T) { - opts, err := parseReviewFlags([]string{"--from", "main", "--to", "feature", "--resume", "session-123"}) - if err != nil { - t.Fatalf("parseReviewFlags: %v", err) - } - if opts.resume != "session-123" { - t.Errorf("resume = %q, want session-123", opts.resume) - } -} - -func TestParseReviewFlags_PreviewWithResume(t *testing.T) { - _, err := parseReviewFlags([]string{"--commit", "abc123", "--preview", "--resume", "session-123"}) - if err == nil { - t.Fatal("expected error for --preview with --resume") - } -} - func TestParseReviewFlags_InvalidAudience(t *testing.T) { _, err := parseReviewFlags([]string{"--audience", "robot"}) if err == nil { diff --git a/cmd/opencodereview/git_test.go b/cmd/opencodereview/git_test.go index 4528b29..0630a4c 100644 --- a/cmd/opencodereview/git_test.go +++ b/cmd/opencodereview/git_test.go @@ -23,17 +23,11 @@ func initTestGitRepo(t *testing.T) string { } } f := filepath.Join(dir, "README.md") - if err := os.WriteFile(f, []byte("hello"), 0o644); err != nil { - t.Fatalf("write README: %v", err) - } + os.WriteFile(f, []byte("hello"), 0o644) cmd := exec.Command("git", "-C", dir, "add", ".") - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("git add: %v: %s", err, out) - } + cmd.CombinedOutput() cmd = exec.Command("git", "-C", dir, "commit", "-m", "initial commit") - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("git commit: %v: %s", err, out) - } + cmd.CombinedOutput() return dir } @@ -99,18 +93,9 @@ func TestResolveRepoDir_NotGitRepo(t *testing.T) { func TestResolveRepoDir_EmptyUsesWd(t *testing.T) { dir := initTestGitRepo(t) - origDir, err := os.Getwd() - if err != nil { - t.Fatalf("getwd: %v", err) - } - defer func() { - if err := os.Chdir(origDir); err != nil { - t.Errorf("restore chdir: %v", err) - } - }() - if err := os.Chdir(dir); err != nil { - t.Fatalf("chdir: %v", err) - } + origDir, _ := os.Getwd() + defer os.Chdir(origDir) + os.Chdir(dir) resolved, err := resolveRepoDir("") if err != nil { diff --git a/cmd/opencodereview/main.go b/cmd/opencodereview/main.go index 378e22e..fac044e 100644 --- a/cmd/opencodereview/main.go +++ b/cmd/opencodereview/main.go @@ -56,8 +56,6 @@ func dispatch() error { return runRules(args[1:]) case "viewer": return runViewer(args[1:]) - case "session", "sessions": - return runSession(args[1:]) case "-h", "--help": printTopLevelUsage() return nil @@ -79,7 +77,6 @@ Commands: config Manage configuration settings llm LLM utility commands viewer Start the WebUI session viewer - session, sessions List and inspect saved review sessions version Show version information Examples: @@ -92,7 +89,6 @@ Examples: ocr config set llm.model opus-4-6 Set a config value ocr llm test Test LLM connectivity ocr llm providers List built-in providers - ocr session list List saved review sessions ocr version Show version info Use "ocr review -h" for more information about review. @@ -100,7 +96,6 @@ Use "ocr scan -h" for more information about scan. Use "ocr rules -h" for more information about rules. Use "ocr config" for more information about config. Use "ocr llm" for more information about LLM utilities. -Use "ocr session -h" for more information about session inspection. GitHub: https://github.com/alibaba/open-code-review`) } diff --git a/cmd/opencodereview/output.go b/cmd/opencodereview/output.go index 0006164..c87b696 100644 --- a/cmd/opencodereview/output.go +++ b/cmd/opencodereview/output.go @@ -240,15 +240,12 @@ type jsonToolCalls struct { type jsonOutput struct { Status string `json:"status"` - TraceID string `json:"trace_id,omitempty"` Message string `json:"message,omitempty"` Summary *jsonSummary `json:"summary,omitempty"` ToolCalls *jsonToolCalls `json:"tool_calls"` Comments []model.LlmComment `json:"comments"` Warnings []agent.AgentWarning `json:"warnings,omitempty"` ProjectSummary string `json:"project_summary,omitempty"` - Resume *agent.ResumeInfo `json:"resume,omitempty"` - SessionID string `json:"session_id,omitempty"` } func outputJSON(comments []model.LlmComment) error { @@ -266,10 +263,9 @@ func outputJSON(comments []model.LlmComment) error { func outputJSONWithWarnings(comments []model.LlmComment, warnings []agent.AgentWarning, filesReviewed, inputTokens, outputTokens, totalTokens, cacheReadTokens, cacheWriteTokens int64, - duration time.Duration, projectSummary string, toolCalls map[string]int64, traceID string, resumeInfo *agent.ResumeInfo, sessionID string) error { + duration time.Duration, projectSummary string, toolCalls map[string]int64) error { out := jsonOutput{ Status: "success", - TraceID: traceID, Comments: comments, Summary: &jsonSummary{ FilesReviewed: filesReviewed, @@ -282,8 +278,6 @@ func outputJSONWithWarnings(comments []model.LlmComment, warnings []agent.AgentW Elapsed: duration.Round(time.Second).String(), }, ProjectSummary: projectSummary, - Resume: resumeInfo, - SessionID: sessionID, } var total int64 for _, v := range toolCalls { @@ -317,10 +311,9 @@ func outputJSONWithWarnings(comments []model.LlmComment, warnings []agent.AgentW return enc.Encode(out) } -func outputJSONNoFiles(traceID string) error { +func outputJSONNoFiles() error { out := jsonOutput{ Status: "skipped", - TraceID: traceID, Message: "No supported files changed.", Comments: []model.LlmComment{}, ToolCalls: &jsonToolCalls{ diff --git a/cmd/opencodereview/output_helpers_test.go b/cmd/opencodereview/output_helpers_test.go index 80520b2..d0d25b2 100644 --- a/cmd/opencodereview/output_helpers_test.go +++ b/cmd/opencodereview/output_helpers_test.go @@ -168,8 +168,9 @@ func TestOutputJSONWithWarnings_NoCommentsSubtaskError(t *testing.T) { os.Stdout = w warnings := []agent.AgentWarning{{Type: "subtask_error", File: "x.go", Message: "fail"}} - err := outputJSONWithWarnings(nil, warnings, 1, 10, 5, 15, 0, 0, time.Second, "", nil, "abc123trace", nil, "") - _ = w.Close() + err := outputJSONWithWarnings(nil, warnings, 1, 10, 5, 15, 0, 0, time.Second, "", nil) + + w.Close() os.Stdout = old if err != nil { @@ -177,21 +178,16 @@ func TestOutputJSONWithWarnings_NoCommentsSubtaskError(t *testing.T) { } var buf bytes.Buffer - _, _ = buf.ReadFrom(r) + buf.ReadFrom(r) var out jsonOutput - if err := json.Unmarshal(buf.Bytes(), &out); err != nil { - t.Fatalf("unmarshal: %v", err) - } + json.Unmarshal(buf.Bytes(), &out) if out.Status != "completed_with_errors" { t.Errorf("status = %q, want completed_with_errors", out.Status) } if !strings.Contains(out.Message, "errors") { t.Errorf("message = %q, expected to mention errors", out.Message) } - if out.TraceID != "abc123trace" { - t.Errorf("trace_id = %q, want abc123trace", out.TraceID) - } } func TestStatusBadge(t *testing.T) { @@ -226,7 +222,7 @@ func TestOutputJSON(t *testing.T) { } err := outputJSON(comments) - _ = w.Close() + w.Close() os.Stdout = old if err != nil { @@ -234,7 +230,7 @@ func TestOutputJSON(t *testing.T) { } var buf bytes.Buffer - _, _ = buf.ReadFrom(r) + buf.ReadFrom(r) var out jsonOutput if err := json.Unmarshal(buf.Bytes(), &out); err != nil { @@ -255,7 +251,7 @@ func TestOutputJSON_NoComments(t *testing.T) { err := outputJSON(nil) - _ = w.Close() + w.Close() os.Stdout = old if err != nil { @@ -263,12 +259,10 @@ func TestOutputJSON_NoComments(t *testing.T) { } var buf bytes.Buffer - _, _ = buf.ReadFrom(r) + buf.ReadFrom(r) var out jsonOutput - if err := json.Unmarshal(buf.Bytes(), &out); err != nil { - t.Fatalf("unmarshal: %v", err) - } + json.Unmarshal(buf.Bytes(), &out) if out.Message == "" { t.Error("expected non-empty message when no comments") } @@ -281,8 +275,9 @@ func TestOutputJSONWithWarnings(t *testing.T) { comments := []model.LlmComment{{Path: "b.go", Content: "test"}} warnings := []agent.AgentWarning{{Type: "subtask_error", File: "c.go", Message: "failed"}} - err := outputJSONWithWarnings(comments, warnings, 5, 100, 50, 150, 10, 5, 3*time.Second, "summary", map[string]int64{"file_read": 3}, "trace-xyz-789", nil, "") - _ = w.Close() + err := outputJSONWithWarnings(comments, warnings, 5, 100, 50, 150, 10, 5, 3*time.Second, "summary", map[string]int64{"file_read": 3}) + + w.Close() os.Stdout = old if err != nil { @@ -290,12 +285,10 @@ func TestOutputJSONWithWarnings(t *testing.T) { } var buf bytes.Buffer - _, _ = buf.ReadFrom(r) + buf.ReadFrom(r) var out jsonOutput - if err := json.Unmarshal(buf.Bytes(), &out); err != nil { - t.Fatalf("unmarshal: %v", err) - } + json.Unmarshal(buf.Bytes(), &out) if out.Status != "completed_with_errors" { t.Errorf("status = %q, want completed_with_errors", out.Status) } @@ -308,9 +301,6 @@ func TestOutputJSONWithWarnings(t *testing.T) { if out.ToolCalls == nil || out.ToolCalls.Total != 3 { t.Errorf("ToolCalls.Total = %v", out.ToolCalls) } - if out.TraceID != "trace-xyz-789" { - t.Errorf("trace_id = %q, want trace-xyz-789", out.TraceID) - } } func TestOutputJSONWithWarnings_NoCommentsNoErrors(t *testing.T) { @@ -319,8 +309,9 @@ func TestOutputJSONWithWarnings_NoCommentsNoErrors(t *testing.T) { os.Stdout = w warnings := []agent.AgentWarning{{Type: "warning", Message: "something"}} - err := outputJSONWithWarnings(nil, warnings, 2, 50, 20, 70, 0, 0, time.Second, "", nil, "", nil, "") - _ = w.Close() + err := outputJSONWithWarnings(nil, warnings, 2, 50, 20, 70, 0, 0, time.Second, "", nil) + + w.Close() os.Stdout = old if err != nil { @@ -328,12 +319,10 @@ func TestOutputJSONWithWarnings_NoCommentsNoErrors(t *testing.T) { } var buf bytes.Buffer - _, _ = buf.ReadFrom(r) + buf.ReadFrom(r) var out jsonOutput - if err := json.Unmarshal(buf.Bytes(), &out); err != nil { - t.Fatalf("unmarshal: %v", err) - } + json.Unmarshal(buf.Bytes(), &out) if out.Status != "completed_with_warnings" { t.Errorf("status = %q, want completed_with_warnings", out.Status) } @@ -347,9 +336,9 @@ func TestOutputJSONNoFiles(t *testing.T) { r, w, _ := os.Pipe() os.Stdout = w - err := outputJSONNoFiles("test-trace-id-456") + err := outputJSONNoFiles() - _ = w.Close() + w.Close() os.Stdout = old if err != nil { @@ -357,18 +346,13 @@ func TestOutputJSONNoFiles(t *testing.T) { } var buf bytes.Buffer - _, _ = buf.ReadFrom(r) + buf.ReadFrom(r) var out jsonOutput - if err := json.Unmarshal(buf.Bytes(), &out); err != nil { - t.Fatalf("unmarshal: %v", err) - } + json.Unmarshal(buf.Bytes(), &out) if out.Status != "skipped" { t.Errorf("status = %q, want skipped", out.Status) } - if out.TraceID != "test-trace-id-456" { - t.Errorf("trace_id = %q, want test-trace-id-456", out.TraceID) - } } func captureStdout(t *testing.T, fn func()) string { @@ -380,10 +364,10 @@ func captureStdout(t *testing.T, fn func()) string { } os.Stdout = w fn() - _ = w.Close() + w.Close() os.Stdout = old var buf bytes.Buffer - _, _ = buf.ReadFrom(r) + buf.ReadFrom(r) return buf.String() } diff --git a/cmd/opencodereview/output_test.go b/cmd/opencodereview/output_test.go index 038cd84..c5b8487 100644 --- a/cmd/opencodereview/output_test.go +++ b/cmd/opencodereview/output_test.go @@ -93,8 +93,8 @@ func TestSanitizeTerminal(t *testing.T) { {"only control chars", "\x1b\x07\x00\x7f", ""}, {"unicode preserved", "代码审查 レビュー 🔍", "代码审查 レビュー 🔍"}, {"mixed safe and unsafe", "path\x1b[0m/file.go", "path[0m/file.go"}, - {"strips C1 CSI (U+009B)", "before\u009bafter", "beforeafter"}, - {"strips C1 OSC (U+009D)", "before\u009dafter", "beforeafter"}, + {"strips C1 CSI (U+009B)", "before›after", "beforeafter"}, + {"strips C1 OSC (U+009D)", "beforeafter", "beforeafter"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/cmd/opencodereview/provider_cmd_test.go b/cmd/opencodereview/provider_cmd_test.go index d1f03bb..e8d84db 100644 --- a/cmd/opencodereview/provider_cmd_test.go +++ b/cmd/opencodereview/provider_cmd_test.go @@ -364,7 +364,7 @@ func TestPrintWizardCancelled(t *testing.T) { } os.Stdout = w printWizardCancelled(tc.savedInSession, tc.scope) - _ = w.Close() + w.Close() os.Stdout = old got, err := io.ReadAll(r) if err != nil { diff --git a/cmd/opencodereview/provider_tui_test.go b/cmd/opencodereview/provider_tui_test.go index 624bc50..4c9de47 100644 --- a/cmd/opencodereview/provider_tui_test.go +++ b/cmd/opencodereview/provider_tui_test.go @@ -2169,7 +2169,7 @@ func TestProviderTUI_ApiKeyTypingShowsOneStarPerChar(t *testing.T) { m.loadExistingAPIKey() m.apiKeyInput.Focus() - for _, ch := range "abc" { + for _, ch := range []rune("abc") { result, _ := m.Update(charKey(ch)) m = result.(providerTUIModel) } diff --git a/cmd/opencodereview/review_cmd.go b/cmd/opencodereview/review_cmd.go index 8379156..118d398 100644 --- a/cmd/opencodereview/review_cmd.go +++ b/cmd/opencodereview/review_cmd.go @@ -11,11 +11,8 @@ import ( "github.com/open-code-review/open-code-review/internal/agent" "github.com/open-code-review/open-code-review/internal/mcp" - "github.com/open-code-review/open-code-review/internal/session" "github.com/open-code-review/open-code-review/internal/telemetry" "github.com/open-code-review/open-code-review/internal/tool" - - "go.opentelemetry.io/otel/codes" ) func runReview(args []string) error { @@ -47,26 +44,10 @@ func runReview(args []string) error { } } - // Only touch the background when --background-file is set, so the existing - // --background behaviour (raw, unsanitised) is preserved for users who do - // not opt into the file-based context. - if opts.backgroundFile != "" { - fileBackground, err := loadBackgroundFile(opts.backgroundFile) - if err != nil { - return err - } - opts.background = mergeBackground(opts.background, fileBackground) - } - if opts.preview { return runPreview(cc, opts) } - resumeState, err := loadReviewResumeState(cc.RepoDir, opts) - if err != nil { - return err - } - rt, err := loadLLMRuntime(cc.Template, opts.toolConfigPath, opts.model) if err != nil { return err @@ -100,7 +81,6 @@ func runReview(args []string) error { From: opts.from, To: opts.to, Commit: opts.commit, - ReviewMode: reviewModeFromOptions(opts), Template: *cc.Template, SystemRule: cc.Resolver, FileFilter: cc.FileFilter, @@ -115,7 +95,6 @@ func runReview(args []string) error { Model: rt.Model, Background: opts.background, GitRunner: cc.GitRunner, - Resume: resumeState, }) // Silence progress output during execution; restored before the trace @@ -125,68 +104,17 @@ func runReview(args []string) error { ctx, span := telemetry.StartSpan(context.Background(), "review.run") defer span.End() - telemetry.SetAttr(span, "review.repo", cc.RepoDir) - telemetry.SetAttr(span, "review.from", opts.from) - telemetry.SetAttr(span, "review.to", opts.to) - telemetry.SetAttr(span, "review.model", rt.Model) - var traceID string - if telemetry.IsEnabled() { - traceID = telemetry.TraceIDFromContext(ctx) - if opts.outputFormat != "json" { - fmt.Fprintf(os.Stderr, "[ocr] TraceID: %s\n", traceID) - } - } startTime := time.Now() comments, err := ag.Run(ctx) if err != nil { - span.SetStatus(codes.Error, err.Error()) - span.RecordError(err) - if id := ag.SessionID(); id != "" { - fmt.Fprintf(os.Stderr, "[ocr] Session: %s (retry with: --resume %s)\n", id, id) - } + telemetry.SetAttr(span, "error", err.Error()) return fmt.Errorf("review failed: %w", err) } return emitRunResult(ctx, ag, comments, startTime, opts.outputFormat, opts.audience, q) } -func loadReviewResumeState(repoDir string, opts reviewOptions) (*session.ResumeState, error) { - if opts.resume == "" { - return nil, nil - } - current := session.SessionOptions{ - ReviewMode: reviewModeFromOptions(opts), - DiffFrom: opts.from, - DiffTo: opts.to, - DiffCommit: opts.commit, - } - if current.ReviewMode == session.ReviewModeWorkspace { - return nil, fmt.Errorf("resume requires --from/--to or --commit; workspace resume is not supported") - } - state, err := session.LoadResumeState(repoDir, opts.resume) - if err != nil { - return nil, fmt.Errorf("load resume session: %w (run 'ocr session list' to see available sessions)", err) - } - if err := state.ValidateOptions(current); err != nil { - return nil, fmt.Errorf("%w (run 'ocr session list' to see available sessions)", err) - } - if state.CompletedCount() == 0 { - return nil, fmt.Errorf("resume session %q has no completed review items (run 'ocr session list' to see available sessions)", opts.resume) - } - return state, nil -} - -func reviewModeFromOptions(opts reviewOptions) string { - if opts.commit != "" { - return session.ReviewModeCommit - } - if opts.from != "" && opts.to != "" { - return session.ReviewModeRange - } - return session.ReviewModeWorkspace -} - // resolveRepoDir resolves the repo dir for `ocr rules check`. It delegates to // resolveWorkingDir(requireGit=true) so it anchors at the git top-level just // like the review path — keeping rule resolution consistent when run from a diff --git a/cmd/opencodereview/scan_cmd.go b/cmd/opencodereview/scan_cmd.go index 46353ab..3757e98 100644 --- a/cmd/opencodereview/scan_cmd.go +++ b/cmd/opencodereview/scan_cmd.go @@ -3,7 +3,6 @@ package main import ( "context" "fmt" - "os" "strings" "time" @@ -12,8 +11,6 @@ import ( "github.com/open-code-review/open-code-review/internal/scan" "github.com/open-code-review/open-code-review/internal/telemetry" "github.com/open-code-review/open-code-review/internal/tool" - - "go.opentelemetry.io/otel/codes" ) // scanOptions mirrors reviewOptions for the full-scan subcommand. The two @@ -210,22 +207,11 @@ func runScan(args []string) error { ctx, span := telemetry.StartSpan(context.Background(), "scan.run") defer span.End() - var traceID string - if telemetry.IsEnabled() { - traceID = telemetry.TraceIDFromContext(ctx) - if opts.outputFormat != "json" { - fmt.Fprintf(os.Stderr, "[ocr] TraceID: %s\n", traceID) - } - } startTime := time.Now() comments, err := ag.Run(ctx) if err != nil { - span.SetStatus(codes.Error, err.Error()) - span.RecordError(err) - if id := ag.SessionID(); id != "" { - fmt.Fprintf(os.Stderr, "[ocr] Session: %s\n", id) - } + telemetry.SetAttr(span, "error", err.Error()) return fmt.Errorf("scan failed: %w", err) } diff --git a/cmd/opencodereview/session_cmd.go b/cmd/opencodereview/session_cmd.go deleted file mode 100644 index 805b8a5..0000000 --- a/cmd/opencodereview/session_cmd.go +++ /dev/null @@ -1,299 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "io" - "os" - "strings" - "text/tabwriter" - "time" - - "github.com/open-code-review/open-code-review/internal/session" -) - -func runSession(args []string) error { - if len(args) == 0 { - printSessionUsage() - return nil - } - switch args[0] { - case "list", "ls": - return runSessionList(args[1:]) - case "show": - return runSessionShow(args[1:]) - case "-h", "--help": - printSessionUsage() - return nil - default: - return fmt.Errorf("unknown session sub-command: %s\nRun 'ocr session -h' for usage", args[0]) - } -} - -func runSessionList(args []string) error { - a := newOcrFlagSet("ocr session list") - var repoDir string - var asJSON bool - var limit int - a.StringVar(&repoDir, "repo", "", "root directory of the git repository (default: current dir)") - a.BoolVar(&asJSON, "json", false, "emit JSON instead of a table") - a.IntVar(&limit, "limit", 20, "cap the number of listed sessions (0 = unlimited)") - if err := a.Parse(args); err != nil { - return err - } - if a.showHelp { - printSessionListUsage() - return nil - } - - resolvedRepo, err := resolveWorkingDirForSession(repoDir) - if err != nil { - return err - } - summaries, err := session.ListSessions(resolvedRepo) - if err != nil { - return fmt.Errorf("list sessions: %w", err) - } - if limit > 0 && len(summaries) > limit { - summaries = summaries[:limit] - } - - if asJSON { - enc := json.NewEncoder(os.Stdout) - enc.SetIndent("", " ") - return enc.Encode(summaries) - } - - if len(summaries) == 0 { - fmt.Printf("No sessions found for %s\n", resolvedRepo) - return nil - } - printSessionTable(os.Stdout, summaries) - return nil -} - -func runSessionShow(args []string) error { - a := newOcrFlagSet("ocr session show") - var repoDir string - var asJSON bool - a.StringVar(&repoDir, "repo", "", "root directory of the git repository (default: current dir)") - a.BoolVar(&asJSON, "json", false, "emit JSON instead of a table") - if err := a.Parse(args); err != nil { - return err - } - if a.showHelp { - printSessionShowUsage() - return nil - } - - rest := a.fs.Args() - if len(rest) == 0 { - printSessionShowUsage() - return fmt.Errorf("session show requires a session ID") - } - sessionID := rest[0] - - resolvedRepo, err := resolveWorkingDirForSession(repoDir) - if err != nil { - return err - } - summary, items, err := session.LoadDetail(resolvedRepo, sessionID) - if err != nil { - return fmt.Errorf("load session %q: %w", sessionID, err) - } - - if asJSON { - payload := struct { - Summary *session.Summary `json:"summary"` - Items []session.ItemDetail `json:"items"` - }{Summary: summary, Items: items} - enc := json.NewEncoder(os.Stdout) - enc.SetIndent("", " ") - return enc.Encode(payload) - } - - printSessionDetail(os.Stdout, summary, items) - return nil -} - -// resolveWorkingDirForSession accepts an explicit --repo flag value and falls -// back to the current working directory. Unlike resolveRepoDir it does not -// require the target to be a git repository, so users can inspect sessions -// even after archiving a checkout. -func resolveWorkingDirForSession(input string) (string, error) { - dir, _, err := resolveWorkingDir(input, false) - if err != nil { - return "", err - } - return dir, nil -} - -func printSessionTable(w io.Writer, summaries []session.Summary) { - tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) - fmt.Fprintln(tw, "SESSION ID\tMODE\tRANGE\tFILES\tCOMMENTS\tSTATUS\tSTARTED") - for _, s := range summaries { - fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%d\t%s\t%s\n", - s.SessionID, - displayMode(s.ReviewMode), - describeRange(s), - describeFiles(s), - s.TotalComments, - describeStatus(s), - describeStart(s), - ) - } - tw.Flush() -} - -func printSessionDetail(w io.Writer, s *session.Summary, items []session.ItemDetail) { - fmt.Fprintf(w, "Session: %s\n", s.SessionID) - fmt.Fprintf(w, " File: %s\n", s.FilePath) - fmt.Fprintf(w, " Repo: %s\n", s.RepoDir) - if s.GitBranch != "" { - fmt.Fprintf(w, " Branch: %s\n", s.GitBranch) - } - if s.Model != "" { - fmt.Fprintf(w, " Model: %s\n", s.Model) - } - fmt.Fprintf(w, " Mode: %s\n", displayMode(s.ReviewMode)) - if r := describeRange(*s); r != "" && r != "-" { - fmt.Fprintf(w, " Range: %s\n", r) - } - if s.ResumedFrom != "" { - fmt.Fprintf(w, " Resumed: from session %s\n", s.ResumedFrom) - } - fmt.Fprintf(w, " Started: %s\n", describeStart(*s)) - if !s.EndTime.IsZero() { - fmt.Fprintf(w, " Ended: %s\n", s.EndTime.Local().Format("2006-01-02 15:04:05")) - } - if s.Duration > 0 { - fmt.Fprintf(w, " Duration: %s\n", s.Duration.Round(time.Second)) - } - fmt.Fprintf(w, " Status: %s\n", describeStatus(*s)) - fmt.Fprintf(w, " Files: %d completed, %d reused, %d failed\n", - s.CompletedFiles, s.ReusedFiles, s.FailedFiles) - fmt.Fprintf(w, " Comments: %d\n", s.TotalComments) - if s.LLMFailures > 0 { - fmt.Fprintf(w, " LLM err: %d\n", s.LLMFailures) - } - - if len(items) == 0 { - return - } - fmt.Fprintln(w) - fmt.Fprintln(w, "Files:") - tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) - fmt.Fprintln(tw, " TYPE\tFILE\tCOMMENTS\tNOTE") - for _, it := range items { - note := "" - switch it.Type { - case "reused": - note = "from " + shortSessionID(it.SourceSessionID) - case "failed": - note = truncate(it.Error, 60) - } - fmt.Fprintf(tw, " %s\t%s\t%d\t%s\n", it.Type, it.FilePath, it.Comments, note) - } - tw.Flush() -} - -func displayMode(m string) string { - if m == "" { - return "-" - } - return m -} - -func describeRange(s session.Summary) string { - switch s.ReviewMode { - case session.ReviewModeRange: - if s.DiffFrom != "" || s.DiffTo != "" { - return fmt.Sprintf("%s..%s", s.DiffFrom, s.DiffTo) - } - case session.ReviewModeCommit: - if s.DiffCommit != "" { - return s.DiffCommit - } - } - return "-" -} - -func describeFiles(s session.Summary) string { - total := s.CompletedFiles + s.ReusedFiles - if s.ReusedFiles > 0 { - return fmt.Sprintf("%d (reused %d)", total, s.ReusedFiles) - } - return fmt.Sprintf("%d", total) -} - -func describeStatus(s session.Summary) string { - if s.Aborted { - return "aborted" - } - if s.FailedFiles > 0 { - return fmt.Sprintf("completed (%d fail)", s.FailedFiles) - } - return "completed" -} - -func describeStart(s session.Summary) string { - if s.StartTime.IsZero() { - return "-" - } - return s.StartTime.Local().Format("2006-01-02 15:04:05") -} - -func shortSessionID(id string) string { - if len(id) > 8 { - return id[:8] - } - return id -} - -func truncate(s string, n int) string { - s = strings.ReplaceAll(strings.ReplaceAll(s, "\n", " "), "\t", " ") - runes := []rune(s) - if len(runes) <= n { - return s - } - if n <= 1 { - return "…" - } - return string(runes[:n-1]) + "…" -} - -func printSessionUsage() { - fmt.Println(`Usage: - ocr session - -Sub-commands: - list, ls List recent review sessions for the current repo - show Show one session's metadata and per-file items - -Use "ocr session list -h" or "ocr session show -h" for details.`) -} - -func printSessionListUsage() { - fmt.Println(`Usage: - ocr session list [flags] - ocr session ls [flags] - -List review sessions previously persisted to ~/.opencodereview/sessions/. The -session id printed here can be passed to 'ocr review --resume '. - -Flags: - --repo string Root directory of the git repository (default: current dir) - --json Emit JSON instead of a table - --limit int Cap the number of listed sessions (default 20; 0 = unlimited)`) -} - -func printSessionShowUsage() { - fmt.Println(`Usage: - ocr session show [flags] - -Show metadata and per-file items for a single session. - -Flags: - --repo string Root directory of the git repository (default: current dir) - --json Emit JSON instead of a table`) -} diff --git a/cmd/opencodereview/session_cmd_test.go b/cmd/opencodereview/session_cmd_test.go deleted file mode 100644 index 1c32a71..0000000 --- a/cmd/opencodereview/session_cmd_test.go +++ /dev/null @@ -1,170 +0,0 @@ -package main - -import ( - "encoding/json" - "strings" - "testing" - - "github.com/open-code-review/open-code-review/internal/model" - "github.com/open-code-review/open-code-review/internal/session" -) - -func TestRunSessionList_TextIncludesSessionID(t *testing.T) { - tmpHome := t.TempDir() - t.Setenv("HOME", tmpHome) - repoDir := t.TempDir() - - sh := session.New(repoDir, "main", "test-model", session.SessionOptions{ - ReviewMode: session.ReviewModeCommit, - DiffCommit: "abc123", - }) - sh.RecordReviewItemDone("a.go", "a.go", "a.go", "fp-a", []model.LlmComment{{Path: "a.go", Content: "note"}}) - sh.Finalize() - - got := captureStdout(t, func() { - if err := runSessionList([]string{"--repo", repoDir}); err != nil { - t.Fatalf("runSessionList: %v", err) - } - }) - - if !strings.Contains(got, sh.SessionID) { - t.Errorf("expected list output to contain session id %s, got %q", sh.SessionID, got) - } - if !strings.Contains(got, "abc123") { - t.Errorf("expected list output to contain commit range, got %q", got) - } - if !strings.Contains(got, "SESSION ID") { - t.Errorf("expected header, got %q", got) - } -} - -func TestRunSessionList_JSON(t *testing.T) { - tmpHome := t.TempDir() - t.Setenv("HOME", tmpHome) - repoDir := t.TempDir() - - sh := session.New(repoDir, "main", "test-model", session.SessionOptions{ - ReviewMode: session.ReviewModeCommit, - DiffCommit: "abc123", - }) - sh.RecordReviewItemDone("a.go", "a.go", "a.go", "fp-a", nil) - sh.Finalize() - - got := captureStdout(t, func() { - if err := runSessionList([]string{"--repo", repoDir, "--json"}); err != nil { - t.Fatalf("runSessionList: %v", err) - } - }) - - var decoded []session.Summary - if err := json.Unmarshal([]byte(got), &decoded); err != nil { - t.Fatalf("unmarshal: %v (out=%q)", err, got) - } - if len(decoded) != 1 || decoded[0].SessionID != sh.SessionID { - t.Fatalf("decoded = %+v", decoded) - } -} - -func TestRunSessionList_EmptyRepo(t *testing.T) { - tmpHome := t.TempDir() - t.Setenv("HOME", tmpHome) - repoDir := t.TempDir() - - got := captureStdout(t, func() { - if err := runSessionList([]string{"--repo", repoDir}); err != nil { - t.Fatalf("runSessionList: %v", err) - } - }) - if !strings.Contains(got, "No sessions found") { - t.Errorf("expected empty message, got %q", got) - } -} - -func TestRunSessionShow_Text(t *testing.T) { - tmpHome := t.TempDir() - t.Setenv("HOME", tmpHome) - repoDir := t.TempDir() - - sh := session.New(repoDir, "main", "test-model", session.SessionOptions{ - ReviewMode: session.ReviewModeCommit, - DiffCommit: "abc123", - }) - sh.RecordReviewItemDone("a.go", "a.go", "a.go", "fp-a", []model.LlmComment{{Path: "a.go", Content: "note"}}) - sh.RecordReviewItemFailed("bad.go", "bad.go", "bad.go", "fp-bad", "boom") - sh.Finalize() - - got := captureStdout(t, func() { - if err := runSessionShow([]string{"--repo", repoDir, sh.SessionID}); err != nil { - t.Fatalf("runSessionShow: %v", err) - } - }) - - for _, want := range []string{sh.SessionID, "abc123", "a.go", "bad.go", "boom", "Files:"} { - if !strings.Contains(got, want) { - t.Errorf("expected output to contain %q, got %q", want, got) - } - } -} - -func TestRunSessionShow_JSON(t *testing.T) { - tmpHome := t.TempDir() - t.Setenv("HOME", tmpHome) - repoDir := t.TempDir() - - sh := session.New(repoDir, "main", "test-model", session.SessionOptions{ - ReviewMode: session.ReviewModeCommit, - DiffCommit: "abc123", - }) - sh.RecordReviewItemDone("a.go", "a.go", "a.go", "fp-a", nil) - sh.Finalize() - - got := captureStdout(t, func() { - if err := runSessionShow([]string{"--repo", repoDir, "--json", sh.SessionID}); err != nil { - t.Fatalf("runSessionShow: %v", err) - } - }) - - var payload struct { - Summary *session.Summary `json:"summary"` - Items []session.ItemDetail `json:"items"` - } - if err := json.Unmarshal([]byte(got), &payload); err != nil { - t.Fatalf("unmarshal: %v (out=%q)", err, got) - } - if payload.Summary == nil || payload.Summary.SessionID != sh.SessionID { - t.Fatalf("summary mismatch: %+v", payload.Summary) - } - if len(payload.Items) != 1 || payload.Items[0].FilePath != "a.go" { - t.Fatalf("items = %+v", payload.Items) - } -} - -func TestRunSessionShow_MissingID(t *testing.T) { - tmpHome := t.TempDir() - t.Setenv("HOME", tmpHome) - got := captureStdout(t, func() { - if err := runSessionShow([]string{}); err == nil { - t.Fatal("expected error for missing session id") - } - }) - if !strings.Contains(got, "session show") { - t.Errorf("expected usage output, got %q", got) - } -} - -func TestTruncateUnicode(t *testing.T) { - got := truncate("错误原因:超过限制", 6) - if !strings.HasSuffix(got, "…") { - t.Fatalf("expected ellipsis suffix, got %q", got) - } - if !strings.Contains(got, "错误") { - t.Fatalf("expected valid truncated unicode text, got %q", got) - } -} - -func TestRunSession_UnknownSubcommand(t *testing.T) { - err := runSession([]string{"bogus"}) - if err == nil { - t.Fatal("expected error for unknown sub-command") - } -} diff --git a/cmd/opencodereview/shared.go b/cmd/opencodereview/shared.go index c99c1b2..eaacf83 100644 --- a/cmd/opencodereview/shared.go +++ b/cmd/opencodereview/shared.go @@ -251,14 +251,6 @@ type ResultProvider interface { // that skipped / failed the summary phase. ProjectSummary() string ToolCalls() map[string]int64 - // SessionID returns the persisted session identifier so callers can show it - // in JSON output or failure diagnostics. Returns "" when no session was - // created. - SessionID() string -} - -type resumeInfoProvider interface { - ResumeInfo() *agent.ResumeInfo } // emitRunResult is the post-LLM-run finalization shared by `ocr review` and @@ -284,10 +276,8 @@ func emitRunResult( telemetry.RecordCommentsGenerated(ctx, int64(len(comments))) } - traceID := telemetry.TraceIDFromContext(ctx) - if outputFormat == "json" && len(comments) == 0 && ag.FilesReviewed() == 0 { - return outputJSONNoFiles(traceID) + return outputJSONNoFiles() } // Agent-text audiences need stdout back before PrintTraceSummary so the @@ -303,14 +293,10 @@ func emitRunResult( } if outputFormat == "json" { - var resumeInfo *agent.ResumeInfo - if p, ok := ag.(resumeInfoProvider); ok { - resumeInfo = p.ResumeInfo() - } return outputJSONWithWarnings(comments, ag.Warnings(), ag.FilesReviewed(), ag.TotalInputTokens(), ag.TotalOutputTokens(), ag.TotalTokensUsed(), ag.TotalCacheReadTokens(), ag.TotalCacheWriteTokens(), duration, - ag.ProjectSummary(), ag.ToolCalls(), traceID, resumeInfo, ag.SessionID()) + ag.ProjectSummary(), ag.ToolCalls()) } outputTextWithWarnings(comments, ag.Warnings()) if summary := ag.ProjectSummary(); summary != "" { diff --git a/cmd/opencodereview/shared_test.go b/cmd/opencodereview/shared_test.go index 417a65f..21e7553 100644 --- a/cmd/opencodereview/shared_test.go +++ b/cmd/opencodereview/shared_test.go @@ -79,18 +79,9 @@ func TestQuietHandle_IdempotentRestore(t *testing.T) { func TestResolveWorkingDir_CurrentDir(t *testing.T) { dir := t.TempDir() - origDir, err := os.Getwd() - if err != nil { - t.Fatalf("getwd: %v", err) - } - defer func() { - if err := os.Chdir(origDir); err != nil { - t.Errorf("restore chdir: %v", err) - } - }() - if err := os.Chdir(dir); err != nil { - t.Fatalf("chdir: %v", err) - } + origDir, _ := os.Getwd() + defer os.Chdir(origDir) + os.Chdir(dir) absPath, isGit, err := resolveWorkingDir("", false) if err != nil { diff --git a/examples/github_actions/README.md b/examples/github_actions/README.md index 0236505..468c601 100644 --- a/examples/github_actions/README.md +++ b/examples/github_actions/README.md @@ -1,40 +1,6 @@ -# OpenCodeReview - GitHub Actions Workflow +# OpenCodeReview - GitHub Actions Demo -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. +This demo shows how to integrate OpenCodeReview into your GitHub Actions workflow to automatically review Pull Requests and post review comments. ## How It Works @@ -44,39 +10,40 @@ 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. The reusable action installs OCR, fetches the PR head blobs, computes `git merge-base`, and runs `ocr review --from --to --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). +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/ --to --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 ## Setup -### Configure secrets and variables +### 1. Copy the workflow file -Go to your repository's **Settings → Secrets and variables → Actions**. +Copy `ocr-review.yml` to your repository's `.github/workflows/` directory: -**Secrets:** +```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: | 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 (mapped to env `OCR_LLM_TOKEN` internally) | +| `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 | -**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. +> **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. ## 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: @@ -89,152 +56,92 @@ on: ### Customize comment trigger keywords -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: +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: ```yaml if: | - 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') - ) - ) + 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')) ``` -Each clause guards against a different abuse vector: +Or use a more flexible pattern with `contains` to trigger on any comment containing the keyword: -- `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. +```yaml +if: | + github.event_name == 'pull_request_target' || + (github.event_name == 'issue_comment' && github.event.issue.pull_request && contains(github.event.comment.body, '/review')) +``` -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-`) so a new review cancels any stale one, while non-matching comments land in a unique `noop-` group and are skipped instantly without disrupting a running review. If you change the keywords in `if`, mirror the change in `concurrency.group` too. +> **Note:** The condition `github.event.issue.pull_request` ensures the comment is on a PR, not a regular issue. ### Use a specific OCR version ```yaml -- uses: alibaba/open-code-review@main - with: - ocr_version: 1.0.0 +- name: Install OpenCodeReview + run: npm install -g @alibaba-group/open-code-review@1.0.0 ``` ### Add custom review rules -```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'`. | +Use the `--rule` flag to pass a custom rules JSON file: ```yaml -- uses: alibaba/open-code-review@main - with: - sticky_summary: 'true' - incremental: 'true' - incremental_overlap_threshold: '0.75' +- name: Run OCR review + run: ocr review --rule ./my-rules.json --from origin/${{ github.base_ref }} --to origin/${{ github.head_ref }} ``` -> `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 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: +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: - **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. -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): +You can configure the retry and delay behavior via **repository variables** (Settings → Secrets and variables → Actions → Variables): | Variable | Default | Description | |----------|---------|-------------| -| `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_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_MAX_RETRIES` | `3` | Maximum retry attempts per comment when rate-limited | -| `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_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_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 | -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). +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. #### 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 action queries existing reviews and review comments — each tagged with a per-run HTML comment (e.g. ``) — 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 workflow queries existing reviews and review comments — each tagged with a per-run HTML comment (e.g. ``) — and only retries the comments that are actually missing. This prevents duplicate review posts. -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. +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. -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. +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. -### Limit LLM concurrency +### Limit concurrency + +Adjust the `--concurrency` flag for large PRs to control the number of concurrent LLM requests: ```yaml -- uses: alibaba/open-code-review@main - with: - review_concurrency: 5 +- name: Run OCR review + run: ocr review --concurrency 5 --from origin/${{ github.base_ref }} --to origin/${{ github.head_ref }} ``` ### Provide background context +Use the `--background` flag to pass additional context that helps OCR better understand the purpose of the changes: + ```yaml -- uses: alibaba/open-code-review@main - with: - background: ${{ github.event.pull_request.title }} +- name: Run OCR review + run: ocr review --background "${{ github.event.pull_request.title }}" --from origin/${{ github.base_ref }} --to origin/${{ github.head_ref }} ``` -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 }} -> ``` +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. ### Customize the review comment author with GitHub App @@ -277,53 +184,40 @@ 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` | (Optional) The Installation ID from Step 3 — only needed for apps with multiple installations | +| `GITHUB_APP_INSTALLATION_ID` | The Installation ID from Step 3 | -#### Step 5: Pass the App token to the action +#### Step 5: Update the Workflow -Mint a token with `actions/create-github-app-token` and pass it via the `github_token` input: +Add a step to obtain a token from the GitHub App, then use it in the "Post review comments to PR" step: ```yaml - name: Get GitHub App Token id: app-token - uses: actions/create-github-app-token@main + uses: actions/create-github-app-token@v1 with: app-id: ${{ secrets.GITHUB_APP_ID }} private-key: ${{ secrets.GITHUB_APP_PRIVATE_KEY }} -- uses: alibaba/open-code-review@main +- name: Post review comments to PR + uses: actions/github-script@v7 with: - 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 }} + github-token: ${{ steps.app-token.outputs.token }} + script: | + # ... existing script ``` 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 -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). +When a PR is reviewed, comments appear directly in the PR's "Files changed" tab: -### Summary comment +- ✅ 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 -A single comment — updated in place on each run when `sticky_summary` is `'true'` (the default) — carries the review outcome and posting statistics. +### Inline Comment Example -- ✅ 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: +The workflow uses GitHub's `suggestion` code block syntax, so reviewers can apply fixes with one click: ````markdown **Suggestion:** @@ -332,8 +226,6 @@ Comments with valid line info are posted as PR review comments in "Files changed ``` ```` -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 `### 📄 ` heading, with a collapsible `

` "💡 Suggested Change" (Before/After) when a fix is available. - ## Supported LLM Providers OCR supports both OpenAI and Anthropic API formats: @@ -342,25 +234,22 @@ 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 variable `OCR_LLM_USE_ANTHROPIC=true`, i.e. `llm_use_anthropic: true`): +- **Anthropic APIs** (set `OCR_LLM_USE_ANTHROPIC: true`): - Anthropic Claude models ## Troubleshooting ### Common Issues -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//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. +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 ### Debugging -The action does not use an `OCR_DEBUG` flag. To diagnose a run: +Enable debug logging by adding to the OCR review step: -- **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--`. 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'`. +```yaml +env: + OCR_DEBUG: "1" +``` diff --git a/examples/github_actions/ocr-review.yml b/examples/github_actions/ocr-review.yml index 11a57a3..2073ae1 100644 --- a/examples/github_actions/ocr-review.yml +++ b/examples/github_actions/ocr-review.yml @@ -1,64 +1,71 @@ # OpenCodeReview - GitHub Actions PR Auto-Review Demo # -# Demonstrates invoking the reusable action for both automatic PR review -# (pull_request_target: opened/synchronize/reopened) and on-demand re-review -# via comments starting with '/open-code-review' or '@open-code-review'. +# This workflow automatically reviews pull requests using OpenCodeReview +# and posts review comments directly on the PR. # -# Required secrets/vars (Settings -> Secrets and variables -> Actions): -# secret OCR_LLM_URL LLM API endpoint -# secret OCR_LLM_AUTH_TOKEN LLM auth token (mapped to OCR_LLM_TOKEN) -# variable OCR_LLM_MODEL model name -# variable OCR_LLM_USE_ANTHROPIC 'true' for Anthropic, 'false' for OpenAI-compatible +# Triggers: +# - PR opened (uses pull_request_target for fork secret access) +# - Comment on PR containing '/open-code-review' or '@open-code-review' # -# For the full list of action inputs/outputs and the four comment-posting modes -# (sticky / incremental), see action.yml at the repo root. +# 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 -# Conditional concurrency group. -# -# GitHub Actions evaluates concurrency BEFORE job-level if-conditions. With a -# flat group (ocr-), every comment on the PR — even an unrelated -# conversation reply that will be skipped — enters the same group and, because -# cancel-in-progress is true, cancels any in-progress review. The result: a -# single normal comment kills a running review, and you see "two runs, one -# cancelled" in the Actions tab. -# -# Fix: matching events (PR events + /open-code-review comments) share a per-PR -# group so a new review cancels any stale one for the same PR. Non-matching -# comments land in a unique noop- group that can never collide with a -# real review, so they are skipped instantly without disrupting anything. concurrency: - group: >- - ${{ - ( - 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') - ) - ) - ) - && format('ocr-{0}', github.event.pull_request.number || github.event.issue.number) - || format('noop-{0}', github.run_id) - }} + 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 the reusable - # action only reads the diff and does not execute any code from the PR. + # 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, synchronize, reopened] + types: [opened] issue_comment: types: [created] @@ -69,38 +76,19 @@ permissions: jobs: code-review: runs-on: ubuntu-latest - timeout-minutes: 30 - # Run on PR events, or on human-authored comments starting with trigger - # keywords. Bot comments are excluded as a safety net: GITHUB_TOKEN already - # suppresses events from bot-posted comments, but a PAT/App token would not. - # issue_comment triggers are further gated on author_association so only - # MEMBER/OWNER/COLLABORATOR users can spend LLM quota via re-review. + # Run on PR events, or on comments starting with trigger keywords if: | - 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') - ) - ) + github.event_name == 'pull_request_target' || + (github.event_name == 'issue_comment' && github.event.issue.pull_request && startsWith(github.event.comment.body, '/open-code-review')) || + (github.event_name == 'issue_comment' && github.event.issue.pull_request && startsWith(github.event.comment.body, '@open-code-review')) steps: - name: Get PR context id: pr-context - if: github.event_name == 'issue_comment' + if: github.event_name != 'pull_request_target' uses: actions/github-script@v7 with: script: | - // For issue_comment events, resolve PR base/head so the action - // can review the right diff (issue_comment has no top-level - // pull_request payload fields). + // For issue_comment events, get PR info const prNumber = context.issue.number; const { data: pullRequest } = await github.rest.pulls.get({ owner: context.repo.owner, @@ -108,16 +96,829 @@ jobs: pull_number: prNumber }); core.setOutput('base_ref', pullRequest.base.ref); + core.setOutput('head_ref', pullRequest.head.ref); core.setOutput('head_sha', pullRequest.head.sha); - - name: Run OpenCodeReview - uses: alibaba/open-code-review@main + - name: Checkout repository + uses: actions/checkout@v4 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 }} - # For issue_comment triggers, pass the resolved refs; for - # pull_request_target the action resolves them from the event. - base_ref: ${{ steps.pr-context.outputs.base_ref }} - head_sha: ${{ steps.pr-context.outputs.head_sha }} + fetch-depth: 0 # Full history needed for merge-base diff + ref: ${{ github.event.pull_request.head.sha || steps.pr-context.outputs.head_sha }} + + - name: Fetch PR head ref (ensures fork commits are available) + run: git fetch origin pull/${{ github.event.pull_request.number || github.event.issue.number }}/head + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '24' + + - name: Install OpenCodeReview + run: | + npm install -g @alibaba-group/open-code-review + echo "OpenCodeReview installed with version:" + ocr version || true + + - 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 '{"thinking": {"type": "disabled"}}' + + - name: Run OpenCodeReview + id: review + run: | + # Get base ref and head SHA from PR context (different for comment triggers) + # Note: We use HEAD_SHA instead of origin/${HEAD_REF} to support fork PRs, + # because fork branches don't exist on the origin remote. + if [ "${{ github.event_name }}" = "pull_request_target" ]; then + BASE_REF="${{ github.event.pull_request.base.ref }}" + HEAD_SHA="${{ github.event.pull_request.head.sha }}" + else + BASE_REF="${{ steps.pr-context.outputs.base_ref }}" + HEAD_SHA="${{ steps.pr-context.outputs.head_sha }}" + fi + + 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 = ``; + const SUMMARY_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; + + // Get commit SHA from event context + if (context.eventName === 'pull_request_target') { + commitSha = context.payload.pull_request.head.sha; + } else { + // For comment events, we need to fetch the PR + const { data: pullRequest } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber + }); + commitSha = pullRequest.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 () + // so user-generated content or code suggestions cannot trigger + // false positives in the idempotency check. The ID format is + // `ocr--` where RUN_TAG is `-` + // and is a per-comment random hex token. Capture group 1 + // holds the bare ID (ocr--), so we can add it + // directly without stripping comment markers. + const ID_RE = //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 ``, + // so escape any regex metacharacters before embedding it. + const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const tagRe = new RegExp(''); + 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 = `\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
💡 Suggested Change\n\n'; + md += '**Before:**\n' + fencedBlock(comment.existing_code) + '\n\n'; + md += '**After:**\n' + fencedBlock(comment.suggestion_code) + '\n\n'; + md += '
'; + } + + 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)); + } diff --git a/extensions/vscode/.gitignore b/extensions/vscode/.gitignore index e7377a7..ae7fae2 100644 --- a/extensions/vscode/.gitignore +++ b/extensions/vscode/.gitignore @@ -2,5 +2,4 @@ .superpowers/ node_modules/ reference -out/ -*.vsix \ No newline at end of file +out/ \ No newline at end of file diff --git a/extensions/vscode/.vscodeignore b/extensions/vscode/.vscodeignore index d64358b..213ba03 100644 --- a/extensions/vscode/.vscodeignore +++ b/extensions/vscode/.vscodeignore @@ -13,4 +13,3 @@ jest.config.js .superpowers/** prototype.html resources/icon-old.svg -__mocks__/** diff --git a/extensions/vscode/LICENSE b/extensions/vscode/LICENSE deleted file mode 100644 index a4f1fec..0000000 --- a/extensions/vscode/LICENSE +++ /dev/null @@ -1,201 +0,0 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this definition, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to the Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a complaint) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that you distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act on - Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "{}" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the same - "printed page" as the copyright notice for easier identification within - third-party archives. - - Copyright 2026 Alibaba - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. diff --git a/extensions/vscode/open-code-review-vscode-0.1.0.vsix b/extensions/vscode/open-code-review-vscode-0.1.0.vsix new file mode 100644 index 0000000..428b077 Binary files /dev/null and b/extensions/vscode/open-code-review-vscode-0.1.0.vsix differ diff --git a/extensions/vscode/package.json b/extensions/vscode/package.json index 53d9678..7644112 100644 --- a/extensions/vscode/package.json +++ b/extensions/vscode/package.json @@ -2,12 +2,12 @@ "name": "open-code-review-vscode", "displayName": "Open Code Review", "description": "%ocr.description%", - "version": "0.1.1", + "version": "0.1.0", "publisher": "open-code-review", "license": "Apache-2.0", "repository": { "type": "git", - "url": "git+https://github.com/alibaba/open-code-review.git", + "url": "git+https://github.com/nigulasikk/open-code-review.git", "directory": "extensions/vscode" }, "engines": { @@ -16,7 +16,6 @@ "categories": [ "Other" ], - "icon": "resources/icon.png", "main": "./out/extension.js", "activationEvents": [ "onStartupFinished" diff --git a/extensions/vscode/resources/icon-old.svg b/extensions/vscode/resources/icon-old.svg new file mode 100644 index 0000000..3ce9a0e --- /dev/null +++ b/extensions/vscode/resources/icon-old.svg @@ -0,0 +1 @@ + diff --git a/extensions/vscode/resources/icon.png b/extensions/vscode/resources/icon.png deleted file mode 100644 index 0c2add0..0000000 Binary files a/extensions/vscode/resources/icon.png and /dev/null differ diff --git a/extensions/vscode/resources/icon.svg b/extensions/vscode/resources/icon.svg index c4a054b..7a0e418 100644 --- a/extensions/vscode/resources/icon.svg +++ b/extensions/vscode/resources/icon.svg @@ -1 +1,21 @@ - \ No newline at end of file + + + + + diff --git a/extensions/vscode/src/extension/extension.ts b/extensions/vscode/src/extension/extension.ts index 5bc0c56..c398e6b 100644 --- a/extensions/vscode/src/extension/extension.ts +++ b/extensions/vscode/src/extension/extension.ts @@ -16,7 +16,7 @@ export function activate(context: vscode.ExtensionContext): void { const cli = new CliService('ocr'); const config = new ConfigService(cli); const git = new GitService(output); - const comments = new CommentProvider(extensionUri, git); + const comments = new CommentProvider(extensionUri); const sidebar = new SidebarProvider(extensionUri, cli, config, git, comments); const configPanel = new ConfigPanelProvider(extensionUri, cli, config, (cfg) => sidebar.pushConfig(cfg)); diff --git a/extensions/vscode/src/extension/providers/CommentProvider.ts b/extensions/vscode/src/extension/providers/CommentProvider.ts index 84742f7..02d1dba 100644 --- a/extensions/vscode/src/extension/providers/CommentProvider.ts +++ b/extensions/vscode/src/extension/providers/CommentProvider.ts @@ -1,34 +1,22 @@ import { t, resolveLocale, SupportedLocale } from '../../shared/i18n'; import * as vscode from 'vscode'; -import { ReviewComment, CommentStatus, CommentSyncState, ReviewContext, ReviewMode } from '../../shared/types'; +import { ReviewComment, CommentStatus, CommentSyncState } from '../../shared/types'; import { COMMENT_CONTROLLER_ID } from '../../shared/constants'; import { LineOffsetTracker } from './lineOffset'; -import { GitService } from '../services/GitService'; -import { - MountableCommentAnchor, - resolveCommentAnchor, - CommentAnchorDeps, - SidebarOnlyReason, -} from './commentAnchor'; export class CommentProvider { private controller: vscode.CommentController; + // 以 comment 在 result.comments 中的原始下标为 key,与 webview 共用同一索引空间。 + // 打不开的文件(如目录)没有 thread,但下标依旧保留,避免错位。 private threads = new Map(); - private mounts = new Map(); - private jumpable = new Set(); - private jumpBlockReasons = new Map(); private comments: ReviewComment[] = []; private status = new Map(); private offsets = new LineOffsetTracker(); private syncListeners: Array<(s: CommentSyncState[]) => void> = []; - private reviewContext: ReviewContext = { mode: ReviewMode.Workspace }; private locale: SupportedLocale; - constructor( - private extensionUri: vscode.Uri, - private git: GitService, - ) { + constructor(private extensionUri: vscode.Uri) { this.locale = resolveLocale(vscode.env.language); this.controller = vscode.comments.createCommentController(COMMENT_CONTROLLER_ID, t(this.locale, 'ext.commentController')); } @@ -39,92 +27,62 @@ export class CommentProvider { private emitSync(): void { const states: CommentSyncState[] = this.comments.map((_, i) => ({ - index: i, - status: this.status.get(i) ?? 'pending', - jumpable: this.jumpable.has(i), + index: i, status: this.status.get(i) ?? 'pending', })); this.syncListeners.forEach((fn) => fn(states)); } - /** 展示审查评论:能解析到 git/工作区快照的挂 thread,否则仅侧边栏。 */ - async show(comments: ReviewComment[], ctx: ReviewContext): Promise { + /** + * 展示审查评论。 + * @param inEditor 是否在编辑器内创建 CommentThread。仅工作区模式为 true; + * 分支对比/单次提交模式下被审查代码不在当前工作区,行号会错位,故只在侧边栏展示。 + */ + async show(comments: ReviewComment[], inEditor = true): Promise { this.clear(); - this.reviewContext = ctx; + // 不重排:保持与 webview(result.comments)相同的顺序与下标 this.comments = comments; - const root = vscode.workspace.workspaceFolders?.[0].uri.fsPath; if (!root) return; - await this.git.prepareReviewFileStatus(ctx); - const deps = this.buildAnchorDeps(root, ctx); + // 非工作区模式:只登记评论与状态供侧边栏同步,不在编辑器内放置 thread。 + if (!inEditor) { + for (let i = 0; i < this.comments.length; i++) this.status.set(i, 'pending'); + this.emitSync(); + return; + } let firstShown = -1; for (let i = 0; i < this.comments.length; i++) { const c = this.comments[i]; this.status.set(i, 'pending'); - - const anchor = await resolveCommentAnchor(c, ctx, deps); - if (anchor.kind === 'sidebar') { - this.jumpBlockReasons.set(i, anchor.reason); - continue; - } - try { - await vscode.workspace.openTextDocument(anchor.uri); - const body = this.renderBody(c, i, 'pending', anchor.locateNote); - const thread = this.controller.createCommentThread(anchor.uri, anchor.range, [{ - body, - mode: vscode.CommentMode.Preview, + const uri = vscode.Uri.file(`${root}/${c.path}`); + const doc = await vscode.workspace.openTextDocument(uri); + const range = new vscode.Range(Math.max(0, c.startLine - 1), 0, Math.max(0, c.endLine - 1), 0); + const body = this.renderBody(c, i, 'pending'); + const thread = this.controller.createCommentThread(doc.uri, range, [{ + body, mode: vscode.CommentMode.Preview, author: { name: t(this.locale, 'ext.comment.pending') }, }]); thread.canReply = false; thread.label = `${t(this.locale, 'ext.comment.threadLabel')} (${i + 1} / ${this.comments.length})`; - thread.contextValue = this.threadContextValue(c, ctx); + // 有代码建议 → 'pending'(显示应用+忽略);无建议 → 'pendingNoSuggestion'(仅忽略) + thread.contextValue = this.hasSuggestion(c) ? 'pending' : 'pendingNoSuggestion'; thread.collapsibleState = vscode.CommentThreadCollapsibleState.Expanded; this.threads.set(i, thread); - this.mounts.set(i, anchor); - this.jumpable.add(i); if (firstShown < 0) firstShown = i; - } catch { - this.jumpBlockReasons.set(i, 'mount-failed'); - } + } catch { /* 文件打不开(如目录)则无 thread,但保留下标 */ } } - if (firstShown >= 0) await this.jumpTo(firstShown); this.emitSync(); } - private buildAnchorDeps(root: string, ctx: ReviewContext): CommentAnchorDeps { - return { - repoRoot: root, - fileStatus: (path) => this.git.getReviewFileStatus(path), - readAtRef: (ref, path) => this.git.readFileAtRef(ref, path), - readWorkspace: (path) => this.git.readWorkspaceFile(path), - buildDiffUris: (path, status) => this.git.buildCommentDiffUris(path, status, ctx), - toGitUri: async (path, ref) => { - const uri = await this.git.createGitFileUri(path, ref); - if (!uri) throw new Error('git uri unavailable'); - return uri; - }, - }; - } - - private threadContextValue(c: ReviewComment, ctx: ReviewContext): string { - if (ctx.mode !== ReviewMode.Workspace) return 'pendingNoSuggestion'; - return this.hasSuggestion(c) ? 'pending' : 'pendingNoSuggestion'; - } - private hasSuggestion(c: ReviewComment): boolean { return !!(c.suggestionCode && c.suggestionCode.trim()); } - private renderBody( - c: ReviewComment, - _index: number, - _status: CommentStatus, - locateNote?: string, - ): vscode.MarkdownString { - let md = locateNote ? `${locateNote}\n\n${c.content}` : c.content; + private renderBody(c: ReviewComment, _index: number, _status: CommentStatus): vscode.MarkdownString { + let md = c.content; if (this.hasSuggestion(c)) { md += `\n***\n\`\`\`diff\n${c.suggestionCode}\n\`\`\``; } else { @@ -136,10 +94,6 @@ export class CommentProvider { } async apply(index: number): Promise { - if (this.reviewContext.mode !== ReviewMode.Workspace) { - vscode.window.showWarningMessage(t(this.locale, 'ext.comment.applyWorkspaceOnly')); - return; - } const c = this.comments[index]; if (!c) return; const root = vscode.workspace.workspaceFolders?.[0].uri.fsPath; @@ -156,6 +110,8 @@ export class CommentProvider { const range = new vscode.Range(start, 0, end, doc.lineAt(end).text.length); const hasSuggestion = !!(c.suggestionCode && c.suggestionCode.trim()); + // 用 WorkspaceEdit 而非 editor.edit:后者要求目标编辑器为活动编辑器, + // 从评论标题栏按钮触发时焦点在评论控件上,会静默返回 false 导致“点不动”。 const edit = new vscode.WorkspaceEdit(); if (hasSuggestion) edit.replace(uri, range, c.suggestionCode!); else edit.delete(uri, range); @@ -176,7 +132,6 @@ export class CommentProvider { private setStatus(index: number, status: CommentStatus): void { this.status.set(index, status); const thread = this.threads.get(index); - const mount = this.mounts.get(index); if (thread) { const label = { applied: t(this.locale, 'ext.comment.statusApplied'), @@ -184,11 +139,7 @@ export class CommentProvider { falsePositive: t(this.locale, 'ext.comment.statusFalsePositive'), pending: t(this.locale, 'ext.comment.pending'), }[status]; - thread.comments = [{ - ...thread.comments[0], - author: { name: label }, - body: this.renderBody(this.comments[index], index, status, mount?.locateNote), - }] as any; + thread.comments = [{ ...thread.comments[0], author: { name: label }, body: this.renderBody(this.comments[index], index, status) }] as any; thread.contextValue = status; thread.collapsibleState = vscode.CommentThreadCollapsibleState.Collapsed; } @@ -196,128 +147,27 @@ export class CommentProvider { } async jumpTo(index: number): Promise { - const mount = this.mounts.get(index); const thread = this.threads.get(index); - if (!mount || !thread) { - this.showJumpFailed(index); + if (!thread) { + const c = this.comments[index]; + if (c) vscode.window.showWarningMessage(`${t(this.locale, 'ext.comment.jumpFailed')}${c.path}${t(this.locale, 'ext.comment.jumpNotAFile')}`); return; } - - if (mount.diff) { - const opened = await this.openDiffEditor(mount); - if (opened) { - await this.revealInDiffSide(mount.uri, mount.range, mount.side); - } else { - await vscode.window.showTextDocument(mount.uri, { selection: mount.range, preview: false }); - } - } else { - await vscode.window.showTextDocument(mount.uri, { selection: mount.range, preview: false }); - } + await vscode.window.showTextDocument(thread.uri, { selection: thread.range, preview: false }); thread.collapsibleState = vscode.CommentThreadCollapsibleState.Expanded; } - private showJumpFailed(index: number): void { - const c = this.comments[index]; - if (!c) return; - - const reason = this.jumpBlockReasons.get(index) ?? this.inferJumpBlockReason(c); - const key = reason === 'missing-file' || reason === 'mount-failed' - ? 'ext.comment.jumpFileMissing' - : 'ext.comment.jumpLineUnresolved'; - vscode.window.showWarningMessage( - t(this.locale, key).replace('{path}', c.path), - ); - } - - /** 未记录原因时根据评论元数据推断(如 L0 多为行号未解析)。 */ - private inferJumpBlockReason(c: ReviewComment): SidebarOnlyReason | 'mount-failed' { - if (c.startLine <= 0 && c.endLine <= 0) return 'unresolved'; - return 'missing-file'; - } - - private async openDiffEditor(mount: MountableCommentAnchor): Promise { - if (!mount.diff) return false; - try { - await vscode.commands.executeCommand( - 'vscode.diff', - mount.diff.left, - mount.diff.right, - mount.diff.title, - { preview: false }, - ); - return true; - } catch { - return false; - } - } - - /** 在已打开的 diff 编辑器中定位到挂载侧行,不再额外打开单文件 tab。 */ - private async revealInDiffSide( - uri: vscode.Uri, - range: vscode.Range, - side: MountableCommentAnchor['side'], - ): Promise { - for (let attempt = 0; attempt < 8; attempt++) { - const editor = this.findEditorForUri(uri, side); - if (editor) { - editor.selection = new vscode.Selection(range.start, range.end); - editor.revealRange(range, vscode.TextEditorRevealType.InCenter); - return; - } - await new Promise((r) => setTimeout(r, 40)); - } - } - - private findEditorForUri( - uri: vscode.Uri, - side?: MountableCommentAnchor['side'], - ): vscode.TextEditor | undefined { - for (const editor of vscode.window.visibleTextEditors) { - if (this.urisMatch(editor.document.uri, uri)) return editor; - } - - const tab = vscode.window.tabGroups.activeTabGroup.activeTab; - if (tab?.input instanceof vscode.TabInputTextDiff) { - const { original, modified } = tab.input; - const prefer = side === 'left' ? original : side === 'right' ? modified : undefined; - const candidates = prefer ? [prefer, original, modified] : [original, modified]; - for (const candidate of candidates) { - if (!this.urisMatch(candidate, uri)) continue; - const editor = vscode.window.visibleTextEditors.find( - (ed) => this.urisMatch(ed.document.uri, candidate), - ); - if (editor) return editor; - } - } - return undefined; - } - - private urisMatch(a: vscode.Uri, b: vscode.Uri): boolean { - if (a.toString() === b.toString()) return true; - if (a.scheme !== b.scheme) return false; - if (a.scheme === 'git') { - const refA = new URLSearchParams(a.query).get('ref'); - const refB = new URLSearchParams(b.query).get('ref'); - return a.path === b.path && refA === refB; - } - return a.fsPath === b.fsPath; - } - indexOfThread(thread: vscode.CommentThread): number { - for (const [i, th] of this.threads) if (th === thread) return i; + for (const [i, t] of this.threads) if (t === thread) return i; return -1; } clear(): void { - this.threads.forEach((th) => th.dispose()); + this.threads.forEach((t) => t.dispose()); this.threads.clear(); - this.mounts.clear(); - this.jumpable.clear(); - this.jumpBlockReasons.clear(); this.comments = []; this.status.clear(); this.offsets.clear(); - this.reviewContext = { mode: ReviewMode.Workspace }; } dispose(): void { diff --git a/extensions/vscode/src/extension/providers/SidebarProvider.ts b/extensions/vscode/src/extension/providers/SidebarProvider.ts index f3beebd..f63ff58 100644 --- a/extensions/vscode/src/extension/providers/SidebarProvider.ts +++ b/extensions/vscode/src/extension/providers/SidebarProvider.ts @@ -2,7 +2,7 @@ import { resolveLocale, toHtmlLang } from '../../shared/i18n'; import * as vscode from 'vscode'; import { ConfigPanelFocus } from '../../shared/configUtils'; import { HostToWebview, WebviewToHost } from '../../shared/messages'; -import { FileChange, ReviewMode } from '../../shared/types'; +import { FileChange } from '../../shared/types'; import { CliService } from '../services/CliService'; import { ConfigService } from '../services/ConfigService'; import { GitService } from '../services/GitService'; @@ -13,7 +13,6 @@ export class SidebarProvider implements vscode.WebviewViewProvider { private view?: vscode.WebviewView; private session?: ReviewSession; private openConfigPanel?: (focus?: ConfigPanelFocus) => void; - private gitWatchDisposable?: vscode.Disposable; constructor( private extensionUri: vscode.Uri, @@ -38,16 +37,6 @@ export class SidebarProvider implements vscode.WebviewViewProvider { view.webview.options = { enableScripts: true, localResourceRoots: [this.extensionUri] }; view.webview.html = this.html(view.webview); view.webview.onDidReceiveMessage((msg: WebviewToHost) => this.handle(msg)); - - this.gitWatchDisposable?.dispose(); - this.gitWatchDisposable = this.git.watchWorkspaceChanges((gitState) => { - this.post({ type: 'gitState', gitState }); - }); - view.onDidDispose(() => { - this.gitWatchDisposable?.dispose(); - this.gitWatchDisposable = undefined; - this.view = undefined; - }); } private post(msg: HostToWebview): void { @@ -59,7 +48,7 @@ export class SidebarProvider implements vscode.WebviewViewProvider { switch (msg.type) { case 'ready': { const config = this.config.read(); - const gitState = await this.git.getState(ReviewMode.Workspace); + const gitState = await this.git.getState('workspace'); const locale = resolveLocale(vscode.env.language); this.post({ type: 'init', config, gitState, locale }); break; @@ -70,9 +59,9 @@ export class SidebarProvider implements vscode.WebviewViewProvider { } case 'getModeFiles': { let files: FileChange[] = []; - if (msg.mode === ReviewMode.Branch && msg.from && msg.to) { + if (msg.mode === 'branch' && msg.from && msg.to) { files = await this.git.getBranchDiff(msg.from, msg.to); - } else if (msg.mode === ReviewMode.Commit && msg.commit) { + } else if (msg.mode === 'commit' && msg.commit) { files = await this.git.getCommitFiles(msg.commit); } this.post({ type: 'modeFiles', mode: msg.mode, files }); @@ -86,21 +75,14 @@ export class SidebarProvider implements vscode.WebviewViewProvider { break; case 'startReview': { this.session = new ReviewSession(this.cli, cwd); + // 仅工作区模式在编辑器内放置评论 thread;分支/提交模式代码不在工作区,会错位。 + const inEditor = msg.options.mode === 'workspace'; await this.session.run(msg.options, { onState: (state, error) => this.post({ type: 'stateChange', state, error }), onLog: (line) => this.post({ type: 'logLine', line }), onDone: (result) => { - void (async () => { - if (result.comments.length) { - await this.comments.show(result.comments, { - mode: msg.options.mode, - from: msg.options.from, - to: msg.options.to, - commit: msg.options.commit, - }); - } - this.post({ type: 'reviewDone', result }); - })(); + this.post({ type: 'reviewDone', result }); + if (result.comments.length) this.comments.show(result.comments, inEditor); }, }); break; diff --git a/extensions/vscode/src/extension/providers/__tests__/commentAnchor.test.ts b/extensions/vscode/src/extension/providers/__tests__/commentAnchor.test.ts deleted file mode 100644 index 22a14dd..0000000 --- a/extensions/vscode/src/extension/providers/__tests__/commentAnchor.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { - findLinesByExistingCode, - normalizeLine, - resolveLinesInContent, - splitAndNormalize, -} from '../commentAnchor'; - -describe('commentAnchor line resolution', () => { - const content = ['line1', 'for (let i = 0; i <= 30, i++) {', ' console.log(i);', '}', 'line5'].join('\n'); - - it('normalizeLine strips diff markers', () => { - expect(normalizeLine('+added')).toBe('added'); - expect(normalizeLine('-removed')).toBe('removed'); - }); - - it('splitAndNormalize skips blank lines', () => { - expect(splitAndNormalize('a\n\n b ')).toEqual(['a', 'b']); - }); - - it('resolveLinesInContent uses explicit line numbers when in range', () => { - expect(resolveLinesInContent(content, 2, 2)).toEqual({ start: 2, end: 2, relocated: false }); - }); - - it('resolveLinesInContent falls back to existingCode when line out of range', () => { - const code = 'for (let i = 0; i <= 30, i++) {'; - const result = resolveLinesInContent(content, 99, 99, code); - expect(result).toEqual({ start: 2, end: 2, relocated: true }); - }); - - it('findLinesByExistingCode matches consecutive non-blank lines', () => { - const found = findLinesByExistingCode(content, 'console.log(i);'); - expect(found).toEqual({ start: 3, end: 3 }); - }); - - it('returns null when neither line nor existingCode resolves', () => { - expect(resolveLinesInContent(content, 99, 99)).toBeNull(); - expect(resolveLinesInContent(content, 0, 0)).toBeNull(); - }); -}); diff --git a/extensions/vscode/src/extension/providers/commentAnchor.ts b/extensions/vscode/src/extension/providers/commentAnchor.ts deleted file mode 100644 index b18bc2a..0000000 --- a/extensions/vscode/src/extension/providers/commentAnchor.ts +++ /dev/null @@ -1,197 +0,0 @@ -import * as vscode from 'vscode'; -import { ReviewComment, ReviewContext, ReviewMode } from '../../shared/types'; - -export type CommentMountSide = 'left' | 'right' | 'workspace'; - -/** 可挂载到编辑器 / diff 的定位结果。 */ -export interface MountableCommentAnchor { - kind: 'mountable'; - uri: vscode.Uri; - range: vscode.Range; - side: CommentMountSide; - /** commit/branch 模式跳转时打开原生 diff。 */ - diff?: { - left: vscode.Uri; - right: vscode.Uri; - title: string; - }; - /** 行号经 existingCode 重新定位时附带说明。 */ - locateNote?: string; -} - -/** 无法在快照中定位,仅侧边栏展示。 */ -export interface SidebarOnlyCommentAnchor { - kind: 'sidebar'; - reason: 'binary' | 'unresolved' | 'missing-file'; -} - -export type SidebarOnlyReason = SidebarOnlyCommentAnchor['reason']; - -export type CommentAnchorResult = MountableCommentAnchor | SidebarOnlyCommentAnchor; - -export interface CommentAnchorDeps { - repoRoot: string; - fileStatus: (path: string) => Promise<'added' | 'modified' | 'deleted' | 'renamed' | 'binary' | null>; - readAtRef: (ref: string, path: string) => Promise; - readWorkspace: (path: string) => Promise; - buildDiffUris: ( - path: string, - status: 'added' | 'modified' | 'deleted' | 'renamed' | 'binary', - ) => Promise<{ - left: vscode.Uri; - right: vscode.Uri; - title: string; - mountRef: string; - mountSide: 'left' | 'right'; - leftRef: string | null; - rightRef: string | null; - } | null>; - toGitUri: (path: string, ref: string) => Promise; -} - -export function normalizeLine(s: string): string { - let line = s.trim(); - if (line.startsWith('+') || line.startsWith('-')) line = line.slice(1).trim(); - return line; -} - -export function splitAndNormalize(code: string): string[] { - const result: string[] = []; - for (const raw of code.split('\n')) { - const n = normalizeLine(raw); - if (n) result.push(n); - } - return result; -} - -/** 在文件内容中滑动匹配 existingCode,返回 1-based 行号。 */ -export function findLinesByExistingCode(content: string, existingCode: string): { start: number; end: number } | null { - const targetLines = splitAndNormalize(existingCode); - if (targetLines.length === 0) return null; - - const fileLines = content.split('\n'); - const normalized: string[] = []; - const lineNums: number[] = []; - for (let i = 0; i < fileLines.length; i++) { - const n = normalizeLine(fileLines[i].replace(/\r$/, '')); - if (!n) continue; - normalized.push(n); - lineNums.push(i + 1); - } - if (normalized.length < targetLines.length) return null; - - for (let i = 0; i <= normalized.length - targetLines.length; i++) { - let matched = true; - for (let j = 0; j < targetLines.length; j++) { - if (normalized[i + j] !== targetLines[j]) { - matched = false; - break; - } - } - if (matched) { - return { start: lineNums[i], end: lineNums[i + targetLines.length - 1] }; - } - } - return null; -} - -export function resolveLinesInContent( - content: string, - startLine: number, - endLine: number, - existingCode?: string, -): { start: number; end: number; relocated: boolean } | null { - const lineCount = content.split('\n').length; - const start = startLine > 0 ? startLine : 0; - const end = endLine > 0 ? endLine : start; - - if (start > 0 && end > 0 && start <= lineCount && end <= lineCount && start <= end) { - return { start, end, relocated: false }; - } - - if (existingCode?.trim()) { - const found = findLinesByExistingCode(content, existingCode); - if (found) return { ...found, relocated: true }; - } - return null; -} - -function toRange(start: number, end: number): vscode.Range { - return new vscode.Range(Math.max(0, start - 1), 0, Math.max(0, end - 1), 0); -} - -export async function resolveCommentAnchor( - comment: ReviewComment, - ctx: ReviewContext, - deps: CommentAnchorDeps, -): Promise { - const status = await deps.fileStatus(comment.path); - if (status === 'binary') return { kind: 'sidebar', reason: 'binary' }; - if (status === null && ctx.mode !== ReviewMode.Workspace) { - return { kind: 'sidebar', reason: 'missing-file' }; - } - - const effectiveStatus = status ?? 'modified'; - - if (ctx.mode === ReviewMode.Workspace) { - const content = await deps.readWorkspace(comment.path); - if (content === null) return { kind: 'sidebar', reason: 'missing-file' }; - const lines = resolveLinesInContent(content, comment.startLine, comment.endLine, comment.existingCode); - if (!lines) return { kind: 'sidebar', reason: 'unresolved' }; - const uri = vscode.Uri.file(`${deps.repoRoot}/${comment.path}`); - return { - kind: 'mountable', - uri, - range: toRange(lines.start, lines.end), - side: 'workspace', - locateNote: lines.relocated ? formatLocateNote(comment.startLine, lines.start) : undefined, - }; - } - - const diff = await deps.buildDiffUris(comment.path, effectiveStatus); - if (!diff) return { kind: 'sidebar', reason: 'missing-file' }; - - const primaryRef = diff.mountRef; - const primaryContent = await deps.readAtRef(primaryRef, comment.path); - let lines = primaryContent - ? resolveLinesInContent(primaryContent, comment.startLine, comment.endLine, comment.existingCode) - : null; - - let mountRef = primaryRef; - let mountSide = diff.mountSide; - - if (!lines && effectiveStatus !== 'added') { - const altRef = diff.mountSide === 'right' ? diff.leftRef : diff.rightRef; - const altSide: CommentMountSide = diff.mountSide === 'right' ? 'left' : 'right'; - if (altRef) { - const altContent = await deps.readAtRef(altRef, comment.path); - const altLines = altContent - ? resolveLinesInContent(altContent, comment.startLine, comment.endLine, comment.existingCode) - : null; - if (altLines) { - lines = altLines; - mountRef = altRef; - mountSide = altSide; - } - } - } - - if (!lines) return { kind: 'sidebar', reason: 'unresolved' }; - - const uri = await deps.toGitUri(comment.path, mountRef); - return { - kind: 'mountable', - uri, - range: toRange(lines.start, lines.end), - side: mountSide, - diff: { left: diff.left, right: diff.right, title: diff.title }, - locateNote: lines.relocated ? formatLocateNote(comment.startLine, lines.start) : undefined, - }; -} - -function formatLocateNote(originalLine: number, resolvedLine: number): string { - if (originalLine > 0 && originalLine !== resolvedLine) { - return `⚠ Original line L${originalLine} could not be matched; showing L${resolvedLine} instead.`; - } - return '⚠ Line number was re-located from code content.'; -} diff --git a/extensions/vscode/src/extension/services/GitService.ts b/extensions/vscode/src/extension/services/GitService.ts index 8d4eaef..a3b29f0 100644 --- a/extensions/vscode/src/extension/services/GitService.ts +++ b/extensions/vscode/src/extension/services/GitService.ts @@ -1,16 +1,11 @@ import { t, resolveLocale } from '../../shared/i18n'; import * as vscode from 'vscode'; -import { readFile } from 'fs/promises'; import { execFile } from 'child_process'; -import { GitState, FileChange, ReviewMode, ReviewContext } from '../../shared/types'; -import { buildWorkspaceFiles, branchRefCandidates, parseNameStatus, pickRepoRoot } from './gitMap'; - -const WORKSPACE_REFRESH_DEBOUNCE_MS = 300; +import { GitState, CommitInfo, FileChange, ReviewMode } from '../../shared/types'; +import { parsePorcelain, parseNameStatus, pickRepoRoot } from './gitMap'; export class GitService { private api: any | null = null; - private cache: GitState = { branches: [], currentBranch: '', recentCommits: [], workspaceFiles: [] }; - private reviewFileStatus = new Map(); constructor(private log?: vscode.OutputChannel) {} @@ -66,155 +61,57 @@ export class GitService { async getState(mode: ReviewMode): Promise { const empty: GitState = { branches: [], currentBranch: '', recentCommits: [], workspaceFiles: [] }; - - if (mode === ReviewMode.Workspace) { - await this.refreshWorkspaceFiles(); - return { ...this.cache }; - } - const repo = await this.waitForRepo(); if (!repo) { this.trace(`getState(${mode}): no repo`); return empty; } + let currentBranch = ''; try { - this.cache.currentBranch = repo.state.HEAD?.name || ''; + currentBranch = repo.state.HEAD?.name || ''; } catch { /* ignore */ } - if (mode === ReviewMode.Branch) { - await this.refreshBranches(repo); - } else if (mode === ReviewMode.Commit) { - await this.refreshRecentCommits(repo); - } - - return { ...this.cache }; - } - - /** - * 订阅 VS Code Git 扩展的仓库状态变化,debounce 后刷新工作区文件列表。 - * 用于侧边栏工作区模式实时反映暂存/工作区/未跟踪变更。 - */ - watchWorkspaceChanges(onUpdate: (state: GitState) => void): vscode.Disposable { - const cleanups: vscode.Disposable[] = []; - let debounceTimer: ReturnType | undefined; - let repoDisposable: vscode.Disposable | undefined; - let cancelled = false; - - const scheduleRefresh = () => { - if (debounceTimer) clearTimeout(debounceTimer); - debounceTimer = setTimeout(() => { - debounceTimer = undefined; - void this.refreshWorkspaceFiles().then(() => { - if (!cancelled) onUpdate({ ...this.cache }); - }); - }, WORKSPACE_REFRESH_DEBOUNCE_MS); - }; - - const attachRepo = (repo: any) => { - repoDisposable?.dispose(); - repoDisposable = repo?.state?.onDidChange?.(scheduleRefresh); - }; - - void this.ensureApi().then(async (api) => { - if (!api || cancelled) return; - - const repo = await this.waitForRepo(); - if (!cancelled && repo) attachRepo(repo); - - if (api.onDidOpenRepository) { - cleanups.push(api.onDidOpenRepository(() => { - if (cancelled) return; - const selected = this.selectRepo(api); - if (selected) attachRepo(selected); - })); - } - }); - - return new vscode.Disposable(() => { - cancelled = true; - if (debounceTimer) clearTimeout(debounceTimer); - repoDisposable?.dispose(); - for (const d of cleanups) d.dispose(); - }); - } - - /** 工作区模式仅刷新变更文件,不等待 VS Code Git 扩展,也不拉分支/提交历史。 */ - private async refreshWorkspaceFiles(): Promise { - const root = await this.repoRootFast(); - if (!root) { - this.cache.workspaceFiles = []; - return; - } - - try { - const [diffHeadOut, untrackedOut] = await Promise.all([ - runGit(root, ['diff', '--name-status', 'HEAD']), - runGit(root, ['ls-files', '--others', '--exclude-standard']), - ]); - let diffCachedOut = ''; - if (!diffHeadOut.trim()) { - diffCachedOut = await runGit(root, ['diff', '--cached', '--name-status']); - } - this.cache.workspaceFiles = buildWorkspaceFiles(diffHeadOut, diffCachedOut, untrackedOut); - this.trace(`refreshWorkspaceFiles: root=${root} files=${this.cache.workspaceFiles.length}`); - } catch (e) { - this.trace(`refreshWorkspaceFiles failed: ${e instanceof Error ? e.message : String(e)}`); - this.cache.workspaceFiles = []; - } - } - - /** 通过 git rev-parse 解析仓库根,避免等待 VS Code Git 扩展初始化。 */ - private async repoRootFast(): Promise { - const ws = vscode.workspace.workspaceFolders?.[0].uri.fsPath; - if (!ws) return null; - try { - const out = await runGit(ws, ['rev-parse', '--show-toplevel']); - const root = out.trim(); - return root || null; - } catch { - return null; - } - } - - private async refreshBranches(repo: any): Promise { + let branches: string[] = []; try { const refs = await repo.getBranches({ remote: true }); - this.cache.branches = refs.map((r: any) => r.name).filter(Boolean); - } catch { - this.cache.branches = []; - } - } + branches = refs.map((r: any) => r.name).filter(Boolean); + } catch { /* ignore */ } - private async refreshRecentCommits(repo: any): Promise { + let recentCommits: CommitInfo[] = []; try { const commits = await repo.log({ maxEntries: 20 }); - this.cache.recentCommits = commits.map((c: any) => ({ + recentCommits = commits.map((c: any) => ({ sha: c.hash.slice(0, 7), message: c.message.split('\n')[0], relativeTime: formatRelative(c.authorDate), })); - } catch { - this.cache.recentCommits = []; + } catch { /* ignore */ } + + // 工作区变更直接走 git status --porcelain,避免依赖扩展懒填充的 state 数组。 + let workspaceFiles: FileChange[] = []; + try { + const root: string = repo.rootUri?.fsPath + ?? vscode.workspace.workspaceFolders?.[0].uri.fsPath + ?? process.cwd(); + const out = await runGit(root, ['status', '--porcelain']); + workspaceFiles = parsePorcelain(out); + this.trace(`getState(${mode}): root=${root} porcelainBytes=${out.length} files=${workspaceFiles.length}`); + } catch (e) { + this.trace(`getState(${mode}): status failed: ${e instanceof Error ? e.message : String(e)}`); } + + return { branches, currentBranch, recentCommits, workspaceFiles }; } /** 分支对比:merge-base 三点 diff。 */ async getBranchDiff(from: string, to: string): Promise { const root = await this.repoRoot(); if (!root || !from || !to) return []; - - const resolvedFrom = await this.resolveGitRef(root, from); - const resolvedTo = await this.resolveGitRef(root, to); - if (!resolvedFrom || !resolvedTo) { - this.trace(`getBranchDiff: unresolved ref from=${from} to=${to}`); - return []; - } - try { - const out = await runGit(root, ['diff', '--name-status', `${resolvedFrom}...${resolvedTo}`]); + const out = await runGit(root, ['diff', '--name-status', `${from}...${to}`]); const files = parseNameStatus(out); - this.trace(`getBranchDiff(${resolvedFrom}...${resolvedTo}): files=${files.length}`); + this.trace(`getBranchDiff(${from}...${to}): files=${files.length}`); return files; } catch (e) { this.trace(`getBranchDiff failed: ${e instanceof Error ? e.message : String(e)}`); @@ -222,22 +119,12 @@ export class GitService { } } - private async resolveGitRef(root: string, ref: string): Promise { - for (const candidate of branchRefCandidates(ref)) { - try { - const out = await runGit(root, ['rev-parse', '--verify', candidate]); - if (out.trim()) return candidate; - } catch { /* try next candidate */ } - } - return null; - } - /** 单次提交:该 commit 相对父提交的改动文件。 */ async getCommitFiles(sha: string): Promise { const root = await this.repoRoot(); if (!root || !sha) return []; try { - const out = await runGit(root, ['show', '--name-status', '--format=', sha]); + const out = await runGit(root, ['show', '--name-status', '--format=', '--', sha]); const files = parseNameStatus(out); this.trace(`getCommitFiles(${sha}): files=${files.length}`); return files; @@ -249,7 +136,7 @@ export class GitService { private async repoRoot(): Promise { const repo = await this.waitForRepo(); - if (!repo) return this.repoRootFast(); + if (!repo) return null; return repo.rootUri?.fsPath ?? vscode.workspace.workspaceFolders?.[0].uri.fsPath ?? process.cwd(); @@ -266,125 +153,41 @@ export class GitService { const fileUri = vscode.Uri.file(`${root}/${opts.path}`); + // 二进制无法做文本 diff,直接打开文件本身。 if (opts.status === 'binary') { - if (opts.mode === ReviewMode.Workspace) { - try { await vscode.window.showTextDocument(fileUri, { preview: true }); } catch { /* ignore */ } - } + try { await vscode.window.showTextDocument(fileUri, { preview: true }); } catch { /* ignore */ } return; } - if (opts.mode === ReviewMode.Workspace) { - const emptyRef = ''; - const left = api.toGitUri(fileUri, opts.status === 'added' ? emptyRef : 'HEAD'); - const right = opts.status === 'deleted' ? api.toGitUri(fileUri, emptyRef) : fileUri; - const label = t(resolveLocale(vscode.env.language), 'ext.git.workspaceVsHead'); - await this.presentDiff(left, right, `${opts.path} (${label})`); + // toGitUri(uri, '') 返回空文档,用于新增/删除时缺失的一侧。 + const emptyRef = ''; + let left: vscode.Uri; + let right: vscode.Uri; + let label: string; + + if (opts.mode === 'workspace') { + left = api.toGitUri(fileUri, opts.status === 'added' ? emptyRef : 'HEAD'); + right = opts.status === 'deleted' ? api.toGitUri(fileUri, emptyRef) : fileUri; + label = t(resolveLocale(vscode.env.language), 'ext.git.workspaceVsHead'); + } else if (opts.mode === 'commit' && opts.commit) { + left = api.toGitUri(fileUri, opts.status === 'added' ? emptyRef : `${opts.commit}^`); + right = opts.status === 'deleted' ? api.toGitUri(fileUri, emptyRef) : api.toGitUri(fileUri, opts.commit); + label = `${opts.commit}^ ↔ ${opts.commit}`; + } else if (opts.mode === 'branch' && opts.from && opts.to) { + // 文件列表用三点 diff(merge-base),逐文件 diff 也应以 merge-base 为基准。 + const base = (await this.mergeBase(root, opts.from, opts.to)) || opts.from; + left = api.toGitUri(fileUri, opts.status === 'added' ? emptyRef : base); + right = opts.status === 'deleted' ? api.toGitUri(fileUri, emptyRef) : api.toGitUri(fileUri, opts.to); + label = `${opts.from}...${opts.to}`; + } else { return; } - if (opts.mode === ReviewMode.Commit && opts.commit) { - const parent = `${opts.commit}^`; - const label = `${opts.commit}^ ↔ ${opts.commit}`; - const leftRef = opts.status === 'added' ? null : parent; - const rightRef = opts.status === 'deleted' ? null : opts.commit; - await this.presentRefDiff(api, root, opts.path, opts.status, leftRef, rightRef, `${opts.path} (${label})`, { - fallbackRange: `${parent}..${opts.commit}`, - }); - return; - } - - if (opts.mode === ReviewMode.Branch && opts.from && opts.to) { - const resolvedFrom = await this.resolveGitRef(root, opts.from); - const resolvedTo = await this.resolveGitRef(root, opts.to); - if (!resolvedFrom || !resolvedTo) return; - const base = (await this.mergeBase(root, resolvedFrom, resolvedTo)) || resolvedFrom; - const label = `${resolvedFrom}...${resolvedTo}`; - const leftRef = opts.status === 'added' ? null : base; - const rightRef = opts.status === 'deleted' ? null : resolvedTo; - await this.presentRefDiff(api, root, opts.path, opts.status, leftRef, rightRef, `${opts.path} (${label})`, { - fallbackRange: `${resolvedFrom}...${resolvedTo}`, - }); - } - } - - /** 分支/提交模式:用 git ref 或空文件构造两侧,走 VS Code 原生 diff 编辑器。 */ - private async presentRefDiff( - api: any, - root: string, - relPath: string, - status: FileChange['status'], - leftRef: string | null, - rightRef: string | null, - title: string, - opts: { fallbackRange: string }, - ): Promise { - const left = await this.resolveDiffSide(api, root, relPath, leftRef, status, 'left'); - const right = await this.resolveDiffSide(api, root, relPath, rightRef, status, 'right'); - const opened = await this.presentDiff(left, right, title); - if (!opened) { - await this.presentPatchDiff(root, opts.fallbackRange, relPath, title); - } - } - - private async resolveDiffSide( - api: any, - root: string, - relPath: string, - ref: string | null, - status: FileChange['status'], - side: 'left' | 'right', - ): Promise { - if (side === 'left' && (status === 'added' || !ref)) return this.emptySideUri(); - if (side === 'right' && (status === 'deleted' || !ref)) return this.emptySideUri(); - if (!ref || !(await this.pathExistsAtRef(root, ref, relPath))) return this.emptySideUri(); - return api.toGitUri(vscode.Uri.file(`${root}/${relPath}`), ref); - } - - /** 空侧占位(等同 /dev/null),用于新增/删除文件的单侧 diff。 */ - private emptySideUri(): vscode.Uri { - return vscode.Uri.file(process.platform === 'win32' ? '\\\\.\\NUL' : '/dev/null'); - } - - private async presentDiff( - left: vscode.Uri, - right: vscode.Uri, - title: string, - ): Promise { + const title = `${opts.path} (${label})`; try { await vscode.commands.executeCommand('vscode.diff', left, right, title, { preview: true }); - return true; } catch (e) { - this.trace(`presentDiff failed: ${e instanceof Error ? e.message : String(e)}`); - return false; - } - } - - /** 最后兜底:原生 diff 失败时才展示补丁文本。 */ - private async presentPatchDiff( - root: string, - range: string, - relPath: string, - title: string, - ): Promise { - let patch = ''; - try { - patch = await runGit(root, ['diff', range, '--', relPath]); - } catch (e) { - this.trace(`presentPatchDiff failed: ${e instanceof Error ? e.message : String(e)}`); - } - const doc = await vscode.workspace.openTextDocument({ - content: patch || `# ${title}\n\n(no changes)`, - language: 'diff', - }); - await vscode.window.showTextDocument(doc, { preview: true }); - } - - private async pathExistsAtRef(root: string, ref: string, relPath: string): Promise { - try { - await runGit(root, ['cat-file', '-e', `${ref}:${relPath}`]); - return true; - } catch { - return false; + this.trace(`openDiff failed: ${e instanceof Error ? e.message : String(e)}`); } } @@ -396,102 +199,11 @@ export class GitService { return null; } } - - /** 审查开始前缓存 commit/branch 模式下的文件状态,供评论挂载查询。 */ - async prepareReviewFileStatus(ctx: ReviewContext): Promise { - this.reviewFileStatus.clear(); - if (ctx.mode === ReviewMode.Commit && ctx.commit) { - const files = await this.getCommitFiles(ctx.commit); - for (const f of files) this.reviewFileStatus.set(f.path, f.status); - } else if (ctx.mode === ReviewMode.Branch && ctx.from && ctx.to) { - const files = await this.getBranchDiff(ctx.from, ctx.to); - for (const f of files) this.reviewFileStatus.set(f.path, f.status); - } - } - - async getReviewFileStatus(path: string): Promise { - return this.reviewFileStatus.get(path) ?? null; - } - - async readFileAtRef(ref: string, relPath: string): Promise { - const root = await this.repoRoot(); - if (!root) return null; - try { - return await runGit(root, ['show', `${ref}:${relPath}`]); - } catch { - return null; - } - } - - async readWorkspaceFile(relPath: string): Promise { - const root = await this.repoRoot(); - if (!root) return null; - try { - return await readFile(`${root}/${relPath}`, 'utf8'); - } catch { - return null; - } - } - - /** 构造评论挂载用的 diff 两侧 URI 与挂载 ref。 */ - async buildCommentDiffUris( - relPath: string, - status: FileChange['status'], - ctx: ReviewContext, - ): Promise<{ - left: vscode.Uri; - right: vscode.Uri; - title: string; - mountRef: string; - mountSide: 'left' | 'right'; - leftRef: string | null; - rightRef: string | null; - } | null> { - const api = await this.ensureApi(); - const root = await this.repoRoot(); - if (!api || !root) return null; - - if (ctx.mode === ReviewMode.Commit && ctx.commit) { - const parent = `${ctx.commit}^`; - const label = `${ctx.commit}^ ↔ ${ctx.commit}`; - const leftRef = status === 'added' ? null : parent; - const rightRef = status === 'deleted' ? null : ctx.commit; - const mountRef = status === 'deleted' ? parent : ctx.commit; - const mountSide: 'left' | 'right' = status === 'deleted' ? 'left' : 'right'; - const left = await this.resolveDiffSide(api, root, relPath, leftRef, status, 'left'); - const right = await this.resolveDiffSide(api, root, relPath, rightRef, status, 'right'); - return { left, right, title: `${relPath} (${label})`, mountRef, mountSide, leftRef, rightRef }; - } - - if (ctx.mode === ReviewMode.Branch && ctx.from && ctx.to) { - const resolvedFrom = await this.resolveGitRef(root, ctx.from); - const resolvedTo = await this.resolveGitRef(root, ctx.to); - if (!resolvedFrom || !resolvedTo) return null; - const base = (await this.mergeBase(root, resolvedFrom, resolvedTo)) || resolvedFrom; - const label = `${resolvedFrom}...${resolvedTo}`; - const leftRef = status === 'added' ? null : base; - const rightRef = status === 'deleted' ? null : resolvedTo; - const mountRef = status === 'deleted' ? base : resolvedTo; - const mountSide: 'left' | 'right' = status === 'deleted' ? 'left' : 'right'; - const left = await this.resolveDiffSide(api, root, relPath, leftRef, status, 'left'); - const right = await this.resolveDiffSide(api, root, relPath, rightRef, status, 'right'); - return { left, right, title: `${relPath} (${label})`, mountRef, mountSide, leftRef, rightRef }; - } - - return null; - } - - async createGitFileUri(relPath: string, ref: string): Promise { - const api = await this.ensureApi(); - const root = await this.repoRoot(); - if (!api || !root) return null; - return api.toGitUri(vscode.Uri.file(`${root}/${relPath}`), ref); - } } function runGit(cwd: string, args: string[]): Promise { return new Promise((resolve, reject) => { - execFile('git', ['-c', 'core.quotepath=false', ...args], { cwd, maxBuffer: 10 * 1024 * 1024 }, (err, stdout) => { + execFile('git', args, { cwd, maxBuffer: 10 * 1024 * 1024 }, (err, stdout) => { if (err) reject(err); else resolve(stdout); }); diff --git a/extensions/vscode/src/extension/services/__tests__/cliParse.test.ts b/extensions/vscode/src/extension/services/__tests__/cliParse.test.ts index ca8fea1..25145a7 100644 --- a/extensions/vscode/src/extension/services/__tests__/cliParse.test.ts +++ b/extensions/vscode/src/extension/services/__tests__/cliParse.test.ts @@ -1,29 +1,28 @@ import { buildReviewArgs, extractCliError, parseCliResult, parseLogLine } from '../cliParse'; -import { ReviewMode } from '../../../shared/types'; describe('buildReviewArgs', () => { it('workspace 模式加 --format json', () => { - expect(buildReviewArgs({ mode: ReviewMode.Workspace })) + expect(buildReviewArgs({ mode: 'workspace' })) .toEqual(['review', '--format', 'json']); }); it('branch 模式加 --from/--to', () => { - expect(buildReviewArgs({ mode: ReviewMode.Branch, from: 'main', to: 'dev' })) + expect(buildReviewArgs({ mode: 'branch', from: 'main', to: 'dev' })) .toEqual(['review', '--from', 'main', '--to', 'dev', '--format', 'json']); }); it('commit 模式加 --commit', () => { - expect(buildReviewArgs({ mode: ReviewMode.Commit, commit: 'abc123' })) + expect(buildReviewArgs({ mode: 'commit', commit: 'abc123' })) .toEqual(['review', '--commit', 'abc123', '--format', 'json']); }); it('customPrompt 追加 --background', () => { - expect(buildReviewArgs({ mode: ReviewMode.Workspace, customPrompt: '关注安全' })) + expect(buildReviewArgs({ mode: 'workspace', customPrompt: '关注安全' })) .toEqual(['review', '--format', 'json', '--background', '关注安全']); }); it('concurrency 追加 --concurrency', () => { - expect(buildReviewArgs({ mode: ReviewMode.Workspace, concurrency: 4 })) + expect(buildReviewArgs({ mode: 'workspace', concurrency: 4 })) .toEqual(['review', '--format', 'json', '--concurrency', '4']); }); }); diff --git a/extensions/vscode/src/extension/services/__tests__/gitMap.test.ts b/extensions/vscode/src/extension/services/__tests__/gitMap.test.ts index b8eb991..3cf15a1 100644 --- a/extensions/vscode/src/extension/services/__tests__/gitMap.test.ts +++ b/extensions/vscode/src/extension/services/__tests__/gitMap.test.ts @@ -1,18 +1,5 @@ // src/extension/services/__tests__/gitMap.test.ts -import { execFile } from 'child_process'; -import path from 'path'; -import { promisify } from 'util'; -import { - buildWorkspaceFiles, - branchRefCandidates, - mapStatusCode, - mergeWorkspaceFiles, - parsePorcelain, - parseNameStatus, - parseUntrackedList, - pickRepoRoot, - unquoteGitPath, -} from '../gitMap'; +import { mapStatusCode, parsePorcelain, parseNameStatus, pickRepoRoot } from '../gitMap'; describe('mapStatusCode', () => { it('VSCode git Status 枚举映射到 FileChange.status', () => { @@ -94,82 +81,6 @@ describe('parseNameStatus', () => { }); }); -describe('buildWorkspaceFiles', () => { - it('合并 diff HEAD 与未跟踪文件', () => { - const files = buildWorkspaceFiles( - 'M\tsrc/a.ts\nA\tsrc/b.ts', - '', - 'src/c.ts\n', - ); - expect(files).toEqual([ - { path: 'src/a.ts', status: 'modified' }, - { path: 'src/b.ts', status: 'added' }, - { path: 'src/c.ts', status: 'added' }, - ]); - }); - - it('diff HEAD 为空时回退 staged', () => { - const files = buildWorkspaceFiles('', 'M\tsrc/staged.ts', ''); - expect(files).toEqual([{ path: 'src/staged.ts', status: 'modified' }]); - }); - - it('按路径去重,已跟踪优先于未跟踪', () => { - const files = buildWorkspaceFiles('M\tsrc/a.ts', '', 'src/a.ts'); - expect(files).toEqual([{ path: 'src/a.ts', status: 'modified' }]); - }); -}); - -describe('parseUntrackedList', () => { - it('解析未跟踪路径并忽略空行', () => { - expect(parseUntrackedList('src/a.ts\n\n src/b.ts \n')).toEqual(['src/a.ts', 'src/b.ts']); - }); -}); - -describe('mergeWorkspaceFiles', () => { - it('合并并去重', () => { - expect(mergeWorkspaceFiles( - [{ path: 'a.ts', status: 'modified' }], - ['b.ts', 'a.ts'], - )).toEqual([ - { path: 'a.ts', status: 'modified' }, - { path: 'b.ts', status: 'added' }, - ]); - }); -}); - -describe('unquoteGitPath', () => { - it('解码 Git quotepath 八进制转义的中文路径', () => { - const quoted = '"\\344\\273\\243\\347\\240\\201\\344\\277\\256\\346\\224\\271\\346\\234\\200\\345\\260\\217\\345\\271\\262\\351\\242\\204\\350\\247\\204\\345\\210\\231.md"'; - expect(unquoteGitPath(quoted)).toBe('代码修改最小干预规则.md'); - }); - - it('普通路径原样返回', () => { - expect(unquoteGitPath('src/a.ts')).toBe('src/a.ts'); - }); -}); - -describe('parseNameStatus', () => { - it('解析 quotepath 转义路径', () => { - expect(parseNameStatus('A\t"\\344\\273\\243\\347\\240\\201.md"')).toEqual([ - { path: '代码.md', status: 'added' }, - ]); - }); -}); - -describe('branchRefCandidates', () => { - it('本地分支名补充 origin/ 前缀', () => { - expect(branchRefCandidates('dev')).toEqual(['dev', 'origin/dev']); - }); - - it('master 回退到 main 候选', () => { - expect(branchRefCandidates('master')).toEqual(['master', 'origin/master', 'main', 'origin/main']); - }); - - it('已是远程引用时不重复拼接', () => { - expect(branchRefCandidates('origin/main')).toEqual(['origin/main']); - }); -}); - describe('pickRepoRoot', () => { const ws = '/Users/lost/tre/copilot-union/code-chat'; @@ -206,19 +117,3 @@ describe('pickRepoRoot', () => { expect(pickRepoRoot(roots, undefined)).toBe('/a/repo'); }); }); - -describe('getCommitFiles: git show revision placement', () => { - const repoRoot = path.resolve(__dirname, '../../../../../..'); - const execGit = (args: string[]) => - promisify(execFile)('git', ['-c', 'core.quotepath=false', ...args], { cwd: repoRoot }) - .then((r) => r.stdout.trim()); - - it('revision 必须在 -- 之前,否则会被当成 pathspec 导致空列表', async () => { - const good = await execGit(['show', '--name-status', '--format=', 'HEAD']); - const bad = await execGit(['show', '--name-status', '--format=', '--', 'HEAD']); - expect(good.length).toBeGreaterThan(0); - expect(bad).toBe(''); - expect(parseNameStatus(good).length).toBeGreaterThan(0); - expect(parseNameStatus(bad)).toEqual([]); - }); -}); diff --git a/extensions/vscode/src/extension/services/cliParse.ts b/extensions/vscode/src/extension/services/cliParse.ts index 383faa7..c219bce 100644 --- a/extensions/vscode/src/extension/services/cliParse.ts +++ b/extensions/vscode/src/extension/services/cliParse.ts @@ -1,11 +1,11 @@ -import { CliResult, CliRunOptions, LogLine, ReviewComment, ReviewMode } from '../../shared/types'; +import { CliResult, CliRunOptions, LogLine, ReviewComment } from '../../shared/types'; export function buildReviewArgs(opts: CliRunOptions): string[] { const args: string[] = ['review']; - if (opts.mode === ReviewMode.Branch) { + if (opts.mode === 'branch') { if (opts.from) args.push('--from', opts.from); if (opts.to) args.push('--to', opts.to); - } else if (opts.mode === ReviewMode.Commit) { + } else if (opts.mode === 'commit') { if (opts.commit) args.push('--commit', opts.commit); } args.push('--format', 'json'); diff --git a/extensions/vscode/src/extension/services/gitMap.ts b/extensions/vscode/src/extension/services/gitMap.ts index 68b3e21..0c92b4f 100644 --- a/extensions/vscode/src/extension/services/gitMap.ts +++ b/extensions/vscode/src/extension/services/gitMap.ts @@ -36,7 +36,6 @@ export function parsePorcelain(output: string): FileChange[] { const c = x !== ' ' && x !== '?' ? x : y; code = c; } - path = unquoteGitPath(path); if (seen.has(path)) continue; seen.add(path); files.push({ path, status: mapStatusCode(code) }); @@ -44,40 +43,6 @@ export function parsePorcelain(output: string): FileChange[] { return files; } -/** 解析 `git ls-files --others --exclude-standard` 输出的未跟踪路径列表。 */ -export function parseUntrackedList(output: string): string[] { - return output.split('\n').map((line) => unquoteGitPath(line.trim())).filter(Boolean); -} - -/** 合并已跟踪变更与未跟踪文件,按路径去重。 */ -export function mergeWorkspaceFiles(tracked: FileChange[], untrackedPaths: string[]): FileChange[] { - const files: FileChange[] = []; - const seen = new Set(); - for (const file of tracked) { - if (seen.has(file.path)) continue; - seen.add(file.path); - files.push(file); - } - for (const path of untrackedPaths) { - if (seen.has(path)) continue; - seen.add(path); - files.push({ path, status: 'added' }); - } - return files; -} - -/** - * 从 git diff / ls-files 输出构建工作区文件列表。 - * 与 OCR CLI workspace 模式一致:先 diff HEAD,空则回退 staged,再合并未跟踪文件。 - */ -export function buildWorkspaceFiles(diffHeadOut: string, diffCachedOut: string, untrackedOut: string): FileChange[] { - let tracked = parseNameStatus(diffHeadOut); - if (tracked.length === 0) { - tracked = parseNameStatus(diffCachedOut); - } - return mergeWorkspaceFiles(tracked, parseUntrackedList(untrackedOut)); -} - /** * 从候选仓库根路径中选出与 workspace 匹配的那个。 * VSCode git 扩展异步扫描嵌套仓库,repositories 顺序不稳定,直接取 [0] 会漂移到子仓库。 @@ -98,50 +63,6 @@ export function pickRepoRoot(roots: string[], workspacePath?: string): string | return roots[0]; } -/** 生成用于 rev-parse 验证的分支引用候选列表。 */ -export function branchRefCandidates(ref: string): string[] { - const candidates = [ref]; - if (!ref.includes('/')) { - candidates.push(`origin/${ref}`); - } - if (ref === 'master') { - candidates.push('main', 'origin/main'); - } else if (ref === 'main') { - candidates.push('master', 'origin/master'); - } - return [...new Set(candidates)]; -} - -/** - * 解码 Git quotepath 转义路径(core.quotepath=true 时中文等会显示为 "\344\273...")。 - */ -export function unquoteGitPath(path: string): string { - if (!path.startsWith('"') || !path.endsWith('"')) return path; - - const bytes: number[] = []; - const inner = path.slice(1, -1); - for (let i = 0; i < inner.length; i++) { - if (inner[i] !== '\\' || i + 1 >= inner.length) { - bytes.push(inner.charCodeAt(i)); - continue; - } - if (i + 3 < inner.length && /^\d{3}$/.test(inner.slice(i + 1, i + 4))) { - bytes.push(parseInt(inner.slice(i + 1, i + 4), 8)); - i += 3; - continue; - } - i += 1; - const esc = inner[i]; - if (esc === 'n') bytes.push(0x0a); - else if (esc === 't') bytes.push(0x09); - else if (esc === 'r') bytes.push(0x0d); - else if (esc === '\\') bytes.push(0x5c); - else if (esc === '"') bytes.push(0x22); - else bytes.push(esc.charCodeAt(0)); - } - return Buffer.from(bytes).toString('utf8'); -} - /** * 解析 `git diff --name-status` / `git show --name-status` 输出。 * 每行制表符分隔:statuspath,重命名为 Roldnew(取 new)。 @@ -154,7 +75,7 @@ export function parseNameStatus(output: string): FileChange[] { const parts = rawLine.split('\t'); if (parts.length < 2) continue; const codeChar = parts[0][0]; - const path = unquoteGitPath(parts.length >= 3 ? parts[parts.length - 1] : parts[1]); + const path = parts.length >= 3 ? parts[parts.length - 1] : parts[1]; if (seen.has(path)) continue; seen.add(path); files.push({ path, status: mapStatusCode(codeChar) }); diff --git a/extensions/vscode/src/shared/i18n.ts b/extensions/vscode/src/shared/i18n.ts index 6086566..346af78 100644 --- a/extensions/vscode/src/shared/i18n.ts +++ b/extensions/vscode/src/shared/i18n.ts @@ -153,9 +153,6 @@ const messages: Record> = { 'ext.comment.statusFalsePositive': '✅ [False Positive]', 'ext.comment.jumpFailed': 'Cannot locate ', 'ext.comment.jumpNotAFile': ': is not an openable file.', - 'ext.comment.jumpLineUnresolved': 'Cannot jump to {path}: line number could not be resolved.', - 'ext.comment.jumpFileMissing': 'Cannot jump to {path}: file not found in the review snapshot.', - 'ext.comment.applyWorkspaceOnly': 'Apply is only available in Workspace review mode.', 'ext.deleteProviderConfirm': 'Delete custom provider "{name}"?', 'ext.deleteProviderConfirmBtn': 'Delete', 'ext.git.justNow': 'just now', @@ -298,9 +295,6 @@ const messages: Record> = { 'ext.comment.statusFalsePositive': '✅ [已误报]', 'ext.comment.jumpFailed': '无法定位到 ', 'ext.comment.jumpNotAFile': ':该路径不是可打开的文件。', - 'ext.comment.jumpLineUnresolved': '无法跳转到 {path}:未能解析行号。', - 'ext.comment.jumpFileMissing': '无法跳转到 {path}:在审查快照中找不到该文件。', - 'ext.comment.applyWorkspaceOnly': '仅工作区审查模式支持应用建议。', 'ext.deleteProviderConfirm': '确定删除自定义 Provider「{name}」?', 'ext.deleteProviderConfirmBtn': '删除', 'ext.git.justNow': '刚刚', diff --git a/extensions/vscode/src/shared/types.ts b/extensions/vscode/src/shared/types.ts index 8219bbd..b6e54ac 100644 --- a/extensions/vscode/src/shared/types.ts +++ b/extensions/vscode/src/shared/types.ts @@ -1,8 +1,4 @@ -export enum ReviewMode { - Workspace = 'workspace', - Branch = 'branch', - Commit = 'commit', -} +export type ReviewMode = 'workspace' | 'branch' | 'commit'; export type ReviewState = | 'idle' | 'running' | 'done' | 'empty' | 'cancelled' | 'failed'; @@ -109,11 +105,7 @@ export interface CliRunOptions { concurrency?: number; } -/** 审查完成后的评论挂载上下文(与 CliRunOptions 字段一致)。 */ -export type ReviewContext = Pick; - export interface CommentSyncState { index: number; status: CommentStatus; - jumpable?: boolean; } diff --git a/extensions/vscode/src/webview/App.tsx b/extensions/vscode/src/webview/App.tsx index 2291095..caebc28 100644 --- a/extensions/vscode/src/webview/App.tsx +++ b/extensions/vscode/src/webview/App.tsx @@ -55,14 +55,14 @@ export function App() {
{state.view === 'running' && bridge.post({ type: 'cancelReview' })} />} {state.view === 'done' && state.session.result && ( - bridge.post({ type: 'jumpToComment', index: i })} onAction={(i, action) => bridge.post({ type: 'commentAction', index: i, action })} /> )} {state.view === 'empty' && } {state.view === 'cancelled' && } - {state.view === 'failed' && start({ mode: ReviewMode.Workspace })} />} + {state.view === 'failed' && start({ mode: 'workspace' })} />}
)} diff --git a/extensions/vscode/src/webview/ConfigPanelApp.tsx b/extensions/vscode/src/webview/ConfigPanelApp.tsx index 24e9836..1439a12 100644 --- a/extensions/vscode/src/webview/ConfigPanelApp.tsx +++ b/extensions/vscode/src/webview/ConfigPanelApp.tsx @@ -1,5 +1,5 @@ import { I18nContext, resolveLocale } from './I18nProvider'; -import { useCallback, useEffect, useReducer } from 'preact/hooks'; +import { useEffect, useReducer } from 'preact/hooks'; import { bridge } from './bridge'; import { ConfigView } from './views/ConfigView'; import { configPanelInitialState, configPanelReducer } from './configStore'; @@ -12,7 +12,6 @@ function runEnvCheck(dispatch: (action: { type: 'checkingEnv' }) => void): void export function ConfigPanelApp() { const [state, dispatch] = useReducer(configPanelReducer, configPanelInitialState); - const clearConnTest = useCallback(() => dispatch({ type: 'clearConnTest' }), []); useEffect(() => { const unsub = bridge.onMessage((msg) => dispatch(msg)); @@ -60,7 +59,7 @@ export function ConfigPanelApp() { onCopy={(text) => bridge.post({ type: 'copyToClipboard', text })} onTest={(entries) => { dispatch({ type: 'testingConn' }); bridge.post({ type: 'testConnection', entries }); }} onSave={(entries) => bridge.post({ type: 'setConfigBatch', entries })} - onClearConnTest={clearConnTest} + onClearConnTest={() => dispatch({ type: 'clearConnTest' })} onDeleteCustomProvider={(name) => bridge.post({ type: 'deleteCustomProvider', name })} onActivateCustomProvider={(name) => bridge.post({ type: 'activateCustomProvider', name })} onClose={() => bridge.post({ type: 'closeConfigPanel' })} diff --git a/extensions/vscode/src/webview/__tests__/store.test.ts b/extensions/vscode/src/webview/__tests__/store.test.ts index c5171e8..85f022c 100644 --- a/extensions/vscode/src/webview/__tests__/store.test.ts +++ b/extensions/vscode/src/webview/__tests__/store.test.ts @@ -1,5 +1,4 @@ import { describeActiveProvider, isConfigReady } from '../../shared/configUtils'; -import { ReviewMode } from '../../shared/types'; import { initialState, reducer } from '../store'; const baseConfig = { @@ -151,7 +150,7 @@ describe('modeFiles 消息', () => { it('保存 mode 对应文件列表', () => { const next = reducer(initialState, { type: 'modeFiles', - mode: ReviewMode.Branch, + mode: 'branch', files: [{ path: 'src/a.ts', status: 'modified' }], }); expect(next.modeFiles).toEqual([{ path: 'src/a.ts', status: 'modified' }]); diff --git a/extensions/vscode/src/webview/components/CommentCard.tsx b/extensions/vscode/src/webview/components/CommentCard.tsx index 680d2a9..2e6b9d2 100644 --- a/extensions/vscode/src/webview/components/CommentCard.tsx +++ b/extensions/vscode/src/webview/components/CommentCard.tsx @@ -12,24 +12,16 @@ interface Props { export function CommentCard({ comment, index, status, canJump, onOpen, onAction }: Props) { const t = useT(); - const open = () => onOpen(index); return (
-
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(); } } : undefined} - role={canJump ? 'button' : undefined} - tabIndex={canJump ? 0 : undefined} - title={canJump ? t('cmp.comment.view') : undefined} - > +
{comment.path} L{comment.startLine}
{comment.content}
- {canJump && } - + {canJump && } +
); diff --git a/extensions/vscode/src/webview/store.ts b/extensions/vscode/src/webview/store.ts index 46c0209..d9e6493 100644 --- a/extensions/vscode/src/webview/store.ts +++ b/extensions/vscode/src/webview/store.ts @@ -13,7 +13,6 @@ export interface AppState { logs: LogLine[]; session: { state: ReviewState; result: CliResult | null; error?: string }; commentStatus: Record; - commentJumpable: Record; reviewMode: ReviewMode; locale: SupportedLocale; } @@ -27,8 +26,7 @@ export const initialState: AppState = { logs: [], session: { state: 'idle', result: null }, commentStatus: {}, - commentJumpable: {}, - reviewMode: ReviewMode.Workspace, + reviewMode: 'workspace', locale: 'en', }; @@ -68,30 +66,18 @@ export function reducer(state: AppState, msg: HostToWebview | LocalAction): AppS ...state, logs: starting ? [] : state.logs, commentStatus: starting ? {} : state.commentStatus, - commentJumpable: starting ? {} : state.commentJumpable, session: { state: msg.state, result: starting ? null : state.session.result, error: msg.error }, view: STATE_TO_VIEW[msg.state], }; } case 'logLine': return { ...state, logs: [...state.logs, msg.line] }; - case 'reviewDone': { - const commentJumpable: Record = {}; - msg.result.comments.forEach((_, i) => { commentJumpable[i] = true; }); - return { - ...state, - session: { ...state.session, result: msg.result }, - commentJumpable, - }; - } + case 'reviewDone': + return { ...state, session: { ...state.session, result: msg.result } }; case 'commentSync': { const commentStatus = { ...state.commentStatus }; - const commentJumpable = { ...state.commentJumpable }; - for (const c of msg.comments) { - commentStatus[c.index] = c.status; - if (c.jumpable !== undefined) commentJumpable[c.index] = c.jumpable; - } - return { ...state, commentStatus, commentJumpable }; + for (const c of msg.comments) commentStatus[c.index] = c.status; + return { ...state, commentStatus }; } default: return state; diff --git a/extensions/vscode/src/webview/styles/global.css b/extensions/vscode/src/webview/styles/global.css index 06a5e67..78f8dac 100644 --- a/extensions/vscode/src/webview/styles/global.css +++ b/extensions/vscode/src/webview/styles/global.css @@ -824,8 +824,6 @@ body { margin-bottom: 10px; transition: opacity 0.3s, max-height 0.3s, padding 0.3s, margin 0.3s; max-height: 400px; - display: flex; - flex-direction: column; overflow: hidden; } @@ -844,18 +842,6 @@ body { flex-wrap: wrap; } -.comment-header.jumpable { - cursor: pointer; - border-radius: 6px; - margin: -4px -6px 4px; - padding: 4px 6px; - transition: background 0.15s; -} - -.comment-header.jumpable:hover { - background: var(--card-quiet); -} - .comment-file { font-family: var(--font-mono); font-size: 11.5px; @@ -900,15 +886,11 @@ body { line-height: 1.6; color: var(--ink); margin-bottom: 10px; - flex: 1; - min-height: 0; - overflow-y: auto; } .comment-actions { display: flex; gap: 6px; - flex-shrink: 0; } .comment-actions button { diff --git a/extensions/vscode/src/webview/views/ConfigView.tsx b/extensions/vscode/src/webview/views/ConfigView.tsx index 587405b..1e75ef1 100644 --- a/extensions/vscode/src/webview/views/ConfigView.tsx +++ b/extensions/vscode/src/webview/views/ConfigView.tsx @@ -68,17 +68,17 @@ export function ConfigView({ setTab(next.tab); setCustomView(next.customView); setCustomSelection(next.customSelection); - }, [panelFocus, config]); + }, [panelFocus, config, onClearConnTest]); const wide = layout === 'panel'; const t = useT(); const stepper = (
-
+
1 {t('view.config.step1')}
-
+
2 {t('view.config.step2')}
diff --git a/extensions/vscode/src/webview/views/DoneView.tsx b/extensions/vscode/src/webview/views/DoneView.tsx index c862753..4a408c9 100644 --- a/extensions/vscode/src/webview/views/DoneView.tsx +++ b/extensions/vscode/src/webview/views/DoneView.tsx @@ -7,13 +7,13 @@ import { useT } from '../I18nProvider'; interface Props { result: CliResult; commentStatus: Record; - commentJumpable: Record; logs: LogLine[]; + canJump: boolean; onOpen: (index: number) => void; onAction: (index: number, action: 'apply' | 'discard' | 'falsePositive') => void; } -export function DoneView({ result, commentStatus, commentJumpable, logs, onOpen, onAction }: Props) { +export function DoneView({ result, commentStatus, logs, canJump, onOpen, onAction }: Props) { const [showLogs, setShowLogs] = useState(false); const t = useT(); const s = result.summary; @@ -35,8 +35,7 @@ export function DoneView({ result, commentStatus, commentJumpable, logs, onOpen, )} {result.comments.map((c, i) => ( - ))}
diff --git a/extensions/vscode/src/webview/views/IdleView.tsx b/extensions/vscode/src/webview/views/IdleView.tsx index 0e810f2..7de4974 100644 --- a/extensions/vscode/src/webview/views/IdleView.tsx +++ b/extensions/vscode/src/webview/views/IdleView.tsx @@ -20,7 +20,7 @@ interface Props { } export function IdleView({ gitState, modeFiles, filesLoading, configured, onModeChange, onRequestModeFiles, onOpenFile, onStart, onOpenConfig, onOpenCustomProviders, running }: Props) { - const [mode, setMode] = useState(ReviewMode.Workspace); + const [mode, setMode] = useState('workspace'); const [from, setFrom] = useState(''); const [to, setTo] = useState(''); const [commit, setCommit] = useState(''); @@ -31,7 +31,7 @@ export function IdleView({ gitState, modeFiles, filesLoading, configured, onMode if (!configured) return t('view.idle.configFirst'); if (running) return t('view.idle.reviewing'); if (!selectionReady) { - return mode === ReviewMode.Branch ? t('view.idle.selectBranch') : t('view.idle.selectCommit'); + return mode === 'branch' ? t('view.idle.selectBranch') : t('view.idle.selectCommit'); } if (files.length === 0) return t('view.idle.noFiles'); return t('view.idle.reviewAll'); @@ -41,21 +41,21 @@ export function IdleView({ gitState, modeFiles, filesLoading, configured, onMode // 分支两端都选好后,拉取 diff 文件列表 useEffect(() => { - if (mode === ReviewMode.Branch && from && to) onRequestModeFiles(ReviewMode.Branch, from, to); + if (mode === 'branch' && from && to) onRequestModeFiles('branch', from, to); }, [mode, from, to]); // 选中某 commit 后,拉取该 commit 文件列表 useEffect(() => { - if (mode === ReviewMode.Commit && commit) onRequestModeFiles(ReviewMode.Commit, undefined, undefined, commit); + if (mode === 'commit' && commit) onRequestModeFiles('commit', undefined, undefined, commit); }, [mode, commit]); - const files = mode === ReviewMode.Workspace ? gitState.workspaceFiles : modeFiles; + const files = mode === 'workspace' ? gitState.workspaceFiles : modeFiles; // 仅在「确实发起了请求」时显示 loading:分支需选满两端,提交需选中 commit。 - const willRequest = mode === ReviewMode.Workspace || (mode === ReviewMode.Branch && !!from && !!to) || (mode === ReviewMode.Commit && !!commit); + const willRequest = mode === 'workspace' || (mode === 'branch' && !!from && !!to) || (mode === 'commit' && !!commit); const loading = filesLoading && willRequest; // 可发起审查的前置条件:按 tab 校验选择已就绪,且有待审查文件、不在加载/审查中。 const selectionReady = - mode === ReviewMode.Workspace || (mode === ReviewMode.Branch && !!from && !!to) || (mode === ReviewMode.Commit && !!commit); + mode === 'workspace' || (mode === 'branch' && !!from && !!to) || (mode === 'commit' && !!commit); const canReview = configured && !running && !loading && selectionReady && files.length > 0; const primaryDisabled = configured ? !canReview : running || loading; @@ -70,14 +70,14 @@ export function IdleView({ gitState, modeFiles, filesLoading, configured, onMode return (
- {([ReviewMode.Workspace, ReviewMode.Branch, ReviewMode.Commit]).map((m) => ( + {(['workspace', 'branch', 'commit'] as ReviewMode[]).map((m) => ( ))}
- {mode === ReviewMode.Branch && ( + {mode === 'branch' && (
{t('view.idle.baseRef')}
setSearchQuery(e.target.value)} - onKeyDown={handleSearchKeyDown} - placeholder={t('blog.search.placeholder')} - style={{ - flex: 1, - marginLeft: 12, - background: 'transparent', - border: 'none', - outline: 'none', - color: '#ffffff', - fontSize: 14, - fontFamily, - }} - /> -
-
- {searchQuery && searchResults.length === 0 && ( -
- {t('blog.search.noResults')} -
- )} - {searchResults.map((result, idx) => ( - - ))} -
-
-
- )} -
- ); -}; - -export default BlogPage; diff --git a/pages/src/styles/blog.css b/pages/src/styles/blog.css deleted file mode 100644 index 6bec8c2..0000000 --- a/pages/src/styles/blog.css +++ /dev/null @@ -1,131 +0,0 @@ -/* Blog-specific styles */ - -.blog-card { - display: flex; - flex-direction: column; - gap: 12px; - padding: 24px; - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 12px; - background: rgba(255, 255, 255, 0.02); - cursor: pointer; - transition: background 0.2s, border-color 0.2s; -} - -.blog-card:hover { - background: rgba(255, 255, 255, 0.05); - border-color: rgba(255, 255, 255, 0.15); -} - -.blog-card-header { - display: flex; - justify-content: space-between; - align-items: flex-start; - gap: 16px; -} - -.blog-card-title { - font-size: 18px; - font-weight: 600; - color: #ffffff; - line-height: 26px; - margin: 0; -} - -.blog-card-date { - font-size: 13px; - color: rgba(255, 255, 255, 0.4); - white-space: nowrap; - flex-shrink: 0; -} - -.blog-card-summary { - font-size: 14px; - color: rgba(255, 255, 255, 0.6); - line-height: 22px; - margin: 0; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; -} - -.blog-card-footer { - display: flex; - justify-content: space-between; - align-items: center; - gap: 12px; -} - -.blog-card-tags { - display: flex; - flex-wrap: wrap; - gap: 6px; -} - -.blog-card-author { - font-size: 13px; - color: rgba(255, 255, 255, 0.4); - flex-shrink: 0; -} - -/* Tag pills */ -.blog-tag { - display: inline-flex; - align-items: center; - padding: 2px 10px; - border-radius: 12px; - font-size: 12px; - line-height: 20px; - border: 1px solid rgba(255, 255, 255, 0.12); - background: transparent; - color: rgba(255, 255, 255, 0.5); - cursor: pointer; - transition: all 0.15s; -} - -.blog-tag:hover { - border-color: rgba(43, 222, 94, 0.4); - color: rgba(43, 222, 94, 0.8); -} - -.blog-tag--active { - border-color: rgba(43, 222, 94, 0.6); - background: rgba(43, 222, 94, 0.1); - color: #2BDE5E; -} - -/* Blog detail metadata */ -.blog-detail-meta { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 12px; - margin-bottom: 32px; - font-size: 14px; - color: rgba(255, 255, 255, 0.5); -} - -.blog-detail-meta-separator { - width: 3px; - height: 3px; - border-radius: 50%; - background: rgba(255, 255, 255, 0.3); -} - -.blog-detail-tags { - display: flex; - gap: 6px; - flex-wrap: wrap; -} - -.blog-detail-tag { - display: inline-flex; - align-items: center; - padding: 1px 8px; - border-radius: 10px; - font-size: 12px; - line-height: 18px; - border: 1px solid rgba(255, 255, 255, 0.12); - color: rgba(255, 255, 255, 0.5); -} diff --git a/pages/webpack.config.js b/pages/webpack.config.js index 55e8b9f..2228fb2 100644 --- a/pages/webpack.config.js +++ b/pages/webpack.config.js @@ -1,6 +1,5 @@ const path = require('path'); const HtmlWebpackPlugin = require('html-webpack-plugin'); -const CopyPlugin = require('copy-webpack-plugin'); module.exports = { mode: process.env.NODE_ENV === 'production' ? 'production' : 'development', @@ -51,10 +50,7 @@ module.exports = { port: 3030, host: '0.0.0.0', allowedHosts: 'all', - static: [ - { directory: path.resolve(__dirname, 'public') }, - { directory: __dirname } - ], + static: { directory: __dirname }, historyApiFallback: { index: '/index.html', rewrites: [{ from: /^\/\_p\/\d+\//, to: '/index.html' }] @@ -64,11 +60,6 @@ module.exports = { new HtmlWebpackPlugin({ template: './index.html', inject: 'body' - }), - new CopyPlugin({ - patterns: [ - { from: 'public', to: '.', noErrorOnMissing: true } - ] }) ] }; diff --git a/plugins/open-code-review/commands/review.md b/plugins/open-code-review/commands/review.md index 1e2e19a..82a1e99 100644 --- a/plugins/open-code-review/commands/review.md +++ b/plugins/open-code-review/commands/review.md @@ -17,7 +17,6 @@ ocr review --audience agent [user-args] - If the user provides `--commit` or `--c`: pass through as-is. - If the user provides `--from` and `--to`: pass through as-is. - (Optional) Provide `--background "requirement context"` to review whether the requirements are correctly implemented. -- (Optional) Provide `--background-file ./requirements.md` to load the same context from a Markdown file (sanitised and limited to 8000 characters). Combined with `--background` the inline value is given first. - Capture full stdout. Set a 5-minute timeout. - If the `ocr` command is not found, install it by running `npm i -g @alibaba-group/open-code-review`. @@ -33,4 +32,4 @@ Silently discard low-confidence comments. Display the remaining comments. ### Step 3: Fix -Automatically fix issues and suggestions that are worth adopting. +Automatically fix issues and suggestions that are worth adopting. \ No newline at end of file diff --git a/scripts/github-actions/post-review-comments.js b/scripts/github-actions/post-review-comments.js deleted file mode 100644 index fba461e..0000000 --- a/scripts/github-actions/post-review-comments.js +++ /dev/null @@ -1,1203 +0,0 @@ -"use strict"; - -// OpenCodeReview PR review comment poster. -// -// Extracted from the inline actions/github-script step that used to live in -// examples/github_actions/ocr-review.yml and .github/workflows/ocr-review.yml, -// so that the reusable composite action (action/action.yml) and the in-repo -// workflows share a single source of truth. -// -// Dependencies are injected by the caller (actions/github-script provides -// `github`/`context`/`core`; `fs` is required by the caller). The module has -// no external (npm) requires — only the Node.js built-in `crypto` — which -// keeps it runnable inside actions/github-script without bundling. - -const crypto = require("crypto"); - -const SUMMARY_MARKER = ""; - -// Reason attached to comments that have no valid line range and therefore can -// never be posted as inline comments. Surfaced in the summary via the same -// `⚠️ GitHub could not post this as an inline comment: ` line as -// posting failures, so every summary-only comment explains why it is here. -const NO_LINE_REASON = "No line information provided"; - -// Default IoU threshold for the incremental multi-line overlap test. Two -// multi-line comments are considered the same when their line-range -// intersection-over-union exceeds this value. Exposed for tuning via the -// incrementalOverlapThreshold option / incremental_overlap_threshold input. -const DEFAULT_OVERLAP_THRESHOLD = 0.6; - -async function runPostReviewComments({ - github, - context, - core, - fs, - resultPath = "/tmp/ocr-result.json", - stderrPath = "/tmp/ocr-stderr.log", - stickySummary = true, - incremental = false, - incrementalOverlapThreshold = DEFAULT_OVERLAP_THRESHOLD, -}) { - const log = (msg) => { - if (core && typeof core.info === "function") core.info(msg); - else console.log(msg); - }; - const out = (name, value) => { - if (core && typeof core.setOutput === "function") core.setOutput(name, value); - }; - - const owner = context.repo.owner; - const repo = context.repo.repo; - const prNumber = context.issue.number; - - // Per-run idempotency tags. context.runId / context.runAttempt come from - // @actions/github's Context (parsed from GITHUB_RUN_ID / GITHUB_RUN_ATTEMPT). - // Number.isFinite guards against NaN when the env vars are missing, falling - // back to safe defaults. The tags are embedded in review/comment bodies as - // HTML comments so the idempotency check can detect whether a batch - // createReview actually landed on the server before retrying, which prevents - // duplicate review posts on retry. - 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 = ``; - const SUMMARY_TAG = ``; - - const stats = { - total: 0, - inline: 0, - skipped: 0, - failed: 0, - summaryUrl: "", - }; - - // Read OCR output. - let result; - try { - const raw = fs.readFileSync(resultPath, "utf8"); - result = JSON.parse(raw); - } catch (e) { - log(`Failed to parse OCR output: ${e.message}`); - const stderr = safeRead(fs, stderrPath).trim(); - if (stderr) { - const body = `${SUMMARY_MARKER}\n⚠️ **OpenCodeReview** encountered an error:\n${fencedBlock(stderr)}`; - const posted = await postSummary({ github, owner, repo, prNumber, body, sticky: stickySummary, log }); - stats.summaryUrl = posted.url; - } - setStatsOutputs(out, stats); - return; - } - - const comments = result.comments || []; - const warnings = result.warnings || []; - stats.total = comments.length; - - // No comments: post a "looks good" summary. - if (comments.length === 0) { - const message = result.message || "No comments generated. Looks good to me."; - const body = `${SUMMARY_MARKER}\n✅ **OpenCodeReview**: ${message}`; - const posted = await postSummary({ github, owner, repo, prNumber, body, sticky: stickySummary, log }); - stats.summaryUrl = posted.url; - setStatsOutputs(out, stats); - return; - } - - // Resolve the PR head commit sha to attach the review to. - let commitSha; - if (context.eventName === "pull_request_target") { - commitSha = context.payload.pull_request.head.sha; - } else { - const { data: pullRequest } = await github.rest.pulls.get({ - owner, - repo, - pull_number: prNumber, - }); - commitSha = pullRequest.head.sha; - } - - // Partition: inline (with valid line info) vs summary (without). - // Each inline comment gets a random per-comment ID (assigned once) embedded - // in its body as an HTML comment, so the retry/idempotency logic can detect - // whether a comment already landed on the server and avoid posting a - // duplicate. Random (not content-derived) so two distinct comments that - // share path/line/content still get different IDs. - const reviewComments = []; - const commentsWithoutLine = []; - for (const comment of comments) { - const hasValidLine = comment.start_line >= 1 || comment.end_line >= 1; - if (!hasValidLine) { - commentsWithoutLine.push({ comment, body: formatComment(comment), reason: NO_LINE_REASON }); - continue; - } - const id = newCommentId(RUN_TAG); - const reviewComment = { path: comment.path, body: formatComment(comment, id) }; - if (comment.start_line >= 1 && comment.end_line >= 1 && comment.start_line !== comment.end_line) { - reviewComment.start_line = comment.start_line; - reviewComment.line = comment.end_line; - reviewComment.start_side = "RIGHT"; - reviewComment.side = "RIGHT"; - } else if (comment.end_line >= 1) { - reviewComment.line = comment.end_line; - reviewComment.side = "RIGHT"; - } else if (comment.start_line >= 1) { - reviewComment.line = comment.start_line; - reviewComment.side = "RIGHT"; - } - reviewComments.push({ comment, reviewComment, id }); - } - - // Incremental filtering (non-destructive): drop current inline comments - // whose (path, line range) overlaps an existing bot review comment, so we - // only append comments on lines not yet covered. History is never deleted. - let toSend = reviewComments; - if (incremental && reviewComments.length > 0) { - const existing = await listExistingReviewComments(github, owner, repo, prNumber, log); - const botLogin = await getAuthenticatedLogin(github, log); - const hist = existing.filter((c) => isBotComment(c, botLogin)); - toSend = reviewComments.filter( - ({ reviewComment }) => !overlapsHistory(reviewComment, hist, incrementalOverlapThreshold) - ); - stats.skipped = reviewComments.length - toSend.length; - if (stats.skipped > 0) { - log(`[incremental] skipped ${stats.skipped} overlapping comment(s); ${toSend.length} to post.`); - } - } - - // ---- Summary anchor (before the review) ---- - // Create the summary issue comment BEFORE posting the review so that on a - // cold start (the first review on this PR) the summary's timeline position is - // above the review. GitHub orders issue comments oldest-first, so creating - // the summary first pins it at the top; later runs merely update it in place - // (sticky) or post a fresh per-run comment (non-sticky), so the ordering - // stays stable and the summary is never sandwiched between review blocks. - // The anchor carries a pre-review body (issue count, warnings, and comments - // without line info — all known upfront); final posting statistics are - // written in the finalize phase below, once the review has landed. - const wrapSummary = (content) => `${SUMMARY_MARKER}\n${SUMMARY_TAG}\n${content}`; - const anchor = await ensureSummaryAnchor({ - github, - owner, - repo, - prNumber, - sticky: stickySummary, - tag: SUMMARY_TAG, - body: wrapSummary(buildPreReviewSummaryBody(stats.total, commentsWithoutLine, warnings)), - log, - }); - - // Submit inline comments (the to-send set) as a single PR review. - let successCount = 0; - let failedCount = 0; - const failedComments = []; - - if (toSend.length > 0) { - // The summary lives in its own issue comment (anchored above), so the - // review body carries only the per-run REVIEW_TAG. The tag lets the - // idempotency check locate the batch review on retry (a batch createReview - // may have landed on the server even though we received a 5xx response). - const reviewBody = REVIEW_TAG; - - try { - const batchRes = await github.rest.pulls.createReview({ - owner, - repo, - pull_number: prNumber, - commit_id: commitSha, - body: reviewBody, - event: "COMMENT", - comments: toSend.map(({ reviewComment }) => reviewComment), - }); - successCount = toSend.length; - log(`Successfully posted review with ${successCount} inline comment(s) (${commentsWithoutLine.length} in summary).`); - logRateLimitQuota(batchRes, "after batch createReview", log); - } catch (e) { - log(`Failed to post review with inline comments: ${e.message}`); - - // 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. - const MAX_RETRIES = parseNonNegInt(process.env.OCR_MAX_RETRIES, 3); - const SUCCESS_DELAY = parseNonNegInt(process.env.OCR_SUCCESS_DELAY, 2000); - const FAILURE_DELAY = parseNonNegInt(process.env.OCR_FAILURE_DELAY, 1000); - 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); - - // Rate-limit cooldown: honor the batch error's retry/rate-limit headers - // BEFORE any further API call — including the idempotency reads below. - // Firing reads immediately after a rate-limit/5xx would further pressure - // the already-struggling API; this is the same cool-down-before-read - // discipline the per-comment loop applies before isCommentAlreadyPosted. - const batchRetry = computeRetryDelayMs(e, 0); - if (batchRetry != null) { - const secs = (batchRetry.delayMs / 1000).toFixed(1); - log( - `Batch createReview failed (HTTP ${e.status}). ` + - `Cooling down ${secs}s via '${batchRetry.source}' (${batchRetry.detail}) before any retry or read.` - ); - await sleep(batchRetry.delayMs); - } - - // The idempotency read ("did the batch land?") is only meaningful when the - // request MAY have reached the server: 5xx, 408 timeout, or a network - // error with no status. For a pure rate-limit (429 / 403 abuse) or a - // validation error (422), the request was rejected before the review was - // created, so the batch definitely did not land — querying would be both - // pointless AND an extra read fired during a rate-limit episode. Skip it - // and retry all comments. This mirrors the per-comment maybeReachedServer - // predicate so the two layers stay consistent. - const batchStatus = e.status; - const batchMaybeReachedServer = - (typeof batchStatus === "number" && (batchStatus >= 500 || batchStatus === 408)) || - batchStatus == null; // network errors (ECONNRESET, ETIMEDOUT, ...) - - let existingReview = null; - if (batchMaybeReachedServer) { - log("Checking whether the batch review actually landed on the server before retrying..."); - try { - existingReview = await findExistingBatchReview({ github, owner, repo, prNumber, tag: REVIEW_TAG, log }); - } catch (checkErr) { - log(`Idempotency check failed (${checkErr.message}). Degrading to original fallback (accepting duplicate risk).`); - } - } else { - log(`Batch did not reach the server (HTTP ${batchStatus || "n/a"}); skipping idempotency check and retrying all comments.`); - } - - // 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 = toSend; - if (existingReview && existingReview.found) { - const postedIds = await getPostedCommentIds({ github, owner, repo, prNumber, log }); - toRetry = toSend.filter((item) => !postedIds.has(item.id)); - successCount = toSend.length - toRetry.length; - log( - `Batch review already exists (review_id=${existingReview.review.id}). ` + - `${successCount}/${toSend.length} inline comments already posted. ` + - `${toRetry.length} missing, will retry only those.` - ); - } else { - log("Batch review not found on server. Falling back to per-comment posting..."); - } - - for (const { comment, reviewComment, id } of toRetry) { - let posted = false; - for (let attempt = 0; attempt <= MAX_RETRIES && !posted; attempt++) { - try { - const res = await github.rest.pulls.createReview({ - owner, - repo, - pull_number: prNumber, - commit_id: commitSha, - body: "", - event: "COMMENT", - comments: [reviewComment], - }); - successCount++; - posted = true; - log(`Successfully posted comment for ${reviewComment.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 ${reviewComment.path}`, log); - const lowQuota = remaining != null && remaining <= LOW_REMAINING_THRESHOLD; - if (lowQuota) { - 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), 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); - log( - `Cooling down ${secs}s before idempotency check for ${reviewComment.path} ` + - `(HTTP ${innerE.status || "n/a"}, attempt ${attempt + 1}/${MAX_RETRIES + 1}).` - ); - await sleep(coolDownMs); - } - const alreadyPosted = await isCommentAlreadyPosted({ github, owner, repo, prNumber, id, log }); - if (alreadyPosted === true) { - successCount++; - posted = true; - log(`Comment for ${reviewComment.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}]` }); - log(`Cannot verify whether comment for ${reviewComment.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"; - log(`Failed to post comment for ${reviewComment.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); - log( - `Rate-limited on ${reviewComment.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 }); - log(`Failed to post comment for ${reviewComment.path} (non-retryable error, HTTP ${innerE.status || "n/a"}): ${innerE.message}`); - await sleep(FAILURE_DELAY); - break; - } - } - } - } - } - } else { - log("No inline comments to post after filtering (all overlapping or none had line info)."); - } - - stats.inline = successCount; - stats.failed = failedCount; - - // ---- Finalize the summary with the complete body ---- - // Now that the review has landed (or failed per-comment), write the final - // summary body. Posting statistics are merged into the leading summary - // header (see buildSummaryBody), so here we only append the per-comment - // renderings: every comment that did not go out as inline — whether because - // it had no line info or because posting failed — is rendered as one - // continuous block, each carrying the reason it ended up in the summary (so - // the reader always knows why it is here). - let summaryBody = buildSummaryBody({ - total: stats.total, - inline: successCount, - summary: commentsWithoutLine.length, - skipped: stats.skipped, - failed: failedCount, - warnings, - }); - summaryBody += formatSummaryComments(commentsWithoutLine); - for (const { comment, error } of failedComments) { - summaryBody += "\n\n---\n\n"; - summaryBody += formatCommentMarkdown(comment, error); - } - if (toSend.length === 0 && stats.skipped > 0) { - summaryBody += "\n\n---\n\nℹ️ All inline comments overlapped with existing reviews; nothing new was posted."; - } - summaryBody += formatWarnings(warnings); - - // Update the anchored comment directly when its id is known (no extra read); - // otherwise upsert (find-then-update-or-create), which also covers the case - // where the anchor phase was skipped because the read API was unavailable. - // Returns null only when the summary could not be written without risking a - // duplicate; the review content remains available via inline comments. - const finalized = await finalizeSummary({ - github, - owner, - repo, - prNumber, - anchor, - sticky: stickySummary, - tag: SUMMARY_TAG, - body: wrapSummary(summaryBody), - log, - }); - if (finalized) stats.summaryUrl = finalized.url; - - setStatsOutputs(out, stats); -} - -function setStatsOutputs(out, stats) { - out("comments_total", String(stats.total)); - out("comments_inline", String(stats.inline)); - out("comments_skipped", String(stats.skipped)); - out("comments_failed", String(stats.failed)); - out("summary_comment_url", stats.summaryUrl || ""); -} - -// ---- Summary posting (sticky vs new) ---- - -async function postSummary({ github, owner, repo, prNumber, body, sticky, log }) { - const fullBody = body; - if (sticky) { - const existing = await findExistingSummaryComment({ github, owner, repo, prNumber, log }); - if (existing) { - const { data: updated } = await github.rest.issues.updateComment({ - owner, - repo, - comment_id: existing.id, - body: fullBody, - }); - return { id: updated.id, url: updated.html_url, updated: true }; - } - } - const { data: created } = await github.rest.issues.createComment({ - owner, - repo, - issue_number: prNumber, - body: fullBody, - }); - return { id: created.id, url: created.html_url, updated: false }; -} - -async function findExistingSummaryComment({ github, owner, repo, prNumber, log }) { - const comments = await readAllPages("listIssueComments", (page, per_page) => - github.rest.issues.listComments({ owner, repo, issue_number: prNumber, per_page, page }), log - ); - // Issue comments are returned oldest-first; pick the newest matching. - for (let i = comments.length - 1; i >= 0; i--) { - const body = comments[i].body; - if (typeof body === "string" && body.includes(SUMMARY_MARKER)) { - return comments[i]; - } - } - return null; -} - -// ---- Summary anchor + finalize (cold-start ordering) ---- -// -// The summary issue comment is created BEFORE the review so that on a cold -// start (first review on the PR) it lands above the review in the timeline -// (GitHub orders issue comments oldest-first). It is then updated in place -// with the final body once the review has posted. This keeps the summary from -// being sandwiched between review blocks on subsequent sticky runs. - -// Find the issue comment that should carry the summary, or null if none. -// Sticky matches the persistent cross-run marker (SUMMARY_MARKER); non-sticky -// matches this run's tag (SUMMARY_TAG) so each run gets its own comment while -// retries within a run reuse it. Throws on read failure so callers can degrade. -async function findSummaryIssueComment({ github, owner, repo, prNumber, sticky, tag, log }) { - const comments = await readAllPages("listIssueComments", (page, per_page) => - github.rest.issues.listComments({ owner, repo, issue_number: prNumber, per_page, page }), log - ); - for (let i = comments.length - 1; i >= 0; i--) { - const body = comments[i].body || ""; - if (sticky ? body.includes(SUMMARY_MARKER) : body.includes(tag)) { - return comments[i]; - } - } - return null; -} - -// Phase 1 (before review): create a summary comment only if none exists yet, so -// its timeline position is pinned above the not-yet-posted review. Returns -// { id, url } for the existing/created comment, or null when the existence -// check fails (read API unavailable) — callers then defer to finalizeSummary. -async function ensureSummaryAnchor({ github, owner, repo, prNumber, body, sticky, tag, log }) { - let existing = null; - try { - existing = await findSummaryIssueComment({ github, owner, repo, prNumber, sticky, tag, log }); - } catch (e) { - log(`[summary] cannot check for existing summary before review (${e.message}); skipping anchor.`); - return null; - } - if (existing) { - return { id: existing.id, url: existing.html_url }; - } - const { data: created } = await github.rest.issues.createComment({ - owner, - repo, - issue_number: prNumber, - body, - }); - return { id: created.id, url: created.html_url }; -} - -// Phase 2 (after review): write the final summary body. When the anchor's id is -// known, update it directly (no extra read). Otherwise upsert: find then update -// or create. Returns { id, url }, or null when the read API is unavailable and -// the summary cannot be safely written without risking a duplicate. -async function finalizeSummary({ github, owner, repo, prNumber, anchor, body, sticky, tag, log }) { - if (anchor && anchor.id != null) { - const { data: updated } = await github.rest.issues.updateComment({ - owner, - repo, - comment_id: anchor.id, - body, - }); - return { id: updated.id, url: updated.html_url }; - } - let existing = null; - try { - existing = await findSummaryIssueComment({ github, owner, repo, prNumber, sticky, tag, log }); - } catch (e) { - log(`[summary] cannot check for existing summary at finalize (${e.message}); skipping to avoid duplicate.`); - return null; - } - if (existing) { - const { data: updated } = await github.rest.issues.updateComment({ - owner, - repo, - comment_id: existing.id, - body, - }); - return { id: updated.id, url: updated.html_url }; - } - const { data: created } = await github.rest.issues.createComment({ - owner, - repo, - issue_number: prNumber, - body, - }); - return { id: created.id, url: created.html_url }; -} - -// ---- Incremental helpers ---- - -async function getAuthenticatedLogin(github, log) { - try { - const { data: user } = await github.rest.users.getAuthenticated(); - return user && user.login ? user.login : null; - } catch (e) { - log(`[incremental] could not resolve authenticated user: ${e.message}`); - return null; - } -} - -async function listExistingReviewComments(github, owner, repo, prNumber, log) { - const all = []; - let page = 1; - // Cap pagination so a pathological PR cannot stall the job; 10 pages = 1000. - const MAX_PAGES = 10; - // Sort newest-first so the page cap keeps the most recent comments: the - // incremental dedup cares about the latest coverage state, and on truncation - // we'd rather drop ancient comments than the recent ones the bot just posted. - // GitHub's default is ascending (oldest-first), which would keep the oldest - // 1000 and silently drop the newest — the exact comments dedup needs most. - try { - while (page <= MAX_PAGES) { - const res = await github.rest.pulls.listReviewComments({ - owner, - repo, - pull_number: prNumber, - sort: "created", - direction: "desc", - per_page: 100, - page, - }); - const items = res.data || []; - all.push(...items); - if (items.length < 100) break; - page++; - } - } catch (e) { - log(`[incremental] listing review comments failed (${e.message}); degrading to no history.`); - return []; - } - if (page > MAX_PAGES) { - log(`[incremental] listing review comments reached max page limit (${MAX_PAGES}); results may be incomplete.`); - } - return all; -} - -function isBotComment(comment, botLogin) { - if (!comment || !comment.user) return false; - if (botLogin && comment.user.login === botLogin) return true; - // GITHUB_TOKEN posts as "github-actions[bot]"; GitHub Apps post as the app. - const login = comment.user.login || ""; - return /github-actions\[bot\]$/i.test(login) || (botLogin != null && login === botLogin); -} - -// Incremental overlap test. The current comment is considered a duplicate of -// an existing bot comment (and thus skipped) when they target the same path -// and RIGHT side AND one of these holds: -// 1. both are single-line comments on the same line; -// 2. both are multi-line comments whose line-range IoU (intersection over -// union) exceeds `threshold`. -// A single-line comment is NEVER considered the same as a multi-line one, so -// revisiting a line with a finer-grained single-line note is not suppressed by -// a prior multi-line block (and vice versa). -function overlapsHistory(reviewComment, history, threshold = DEFAULT_OVERLAP_THRESHOLD) { - const t = resolveThreshold(threshold); - const path = reviewComment.path; - const cur = lineSpan(reviewComment); - if (!cur) return false; - for (const h of history) { - if (h.path !== path) continue; - if (h.side && h.side !== "RIGHT") continue; - const other = lineSpan(h); - if (!other) continue; - if (sameCommentSpan(cur, other, t)) return true; - } - return false; -} - -// Clamp/validate the caller-provided threshold to a sane (0, 1] number, -// falling back to the default when it is missing, NaN, or out of range. This -// keeps the public overlapsHistory API robust even when the value arrives from -// an env var / action input as a malformed string. -function resolveThreshold(threshold) { - const n = Number(threshold); - return Number.isFinite(n) && n > 0 && n <= 1 ? n : DEFAULT_OVERLAP_THRESHOLD; -} - -// Resolve a comment into a line span tagged as single- or multi-line. -// Returns { start, end, multiline } or null when no line can be resolved. -// Handles both our own reviewComment shape ({start_line, line}) and GitHub's -// historical comment shape ({start_line, line}; start_line null for -// single-line). A comment is multi-line only when start_line and line are both -// present and differ; start_line === line (or a missing start_line) is treated -// as a single-line comment on that line. -function lineSpan(c) { - const start = num(c.start_line); - const end = num(c.line != null ? c.line : c.end_line); - if (start == null && end == null) return null; - if (start != null && end != null && start !== end) { - return { start: Math.min(start, end), end: Math.max(start, end), multiline: true }; - } - const single = end != null ? end : start; - return { start: single, end: single, multiline: false }; -} - -// Same-comment predicate implementing the incremental rules. The IoU -// comparison is strict (>), so a span that exactly meets the threshold is NOT -// treated as a duplicate. -function sameCommentSpan(cur, other, threshold) { - if (cur.multiline !== other.multiline) return false; - if (!cur.multiline) return cur.start === other.start; - const overlap = Math.min(cur.end, other.end) - Math.max(cur.start, other.start) + 1; - if (overlap <= 0) return false; - const union = cur.end - cur.start + 1 + (other.end - other.start + 1) - overlap; - if (union <= 0) return false; - return overlap / union > threshold; -} - -function num(v) { - if (v == null || v === "") return null; - const n = Number(v); - return Number.isFinite(n) && n >= 1 ? n : null; -} - -// ---- Rate-limit / retry helpers (ported verbatim) ---- - -function sleep(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -// Parse a non-negative integer env value, falling back to defaultVal when the -// value is missing, NaN, or negative. Unlike `parseInt(...) || default`, this -// guards against negative numbers: a negative parseInt result is truthy, so -// `parseInt || default` would let a nonsensical negative value bypass the -// fallback. -function parseNonNegInt(val, defaultVal) { - const n = parseInt(val, 10); - return Number.isFinite(n) && n >= 0 ? n : defaultVal; -} - -// 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); - - const cap = parseNonNegInt(process.env.OCR_RETRY_MAX_DELAY, 300000); - const base = parseNonNegInt(process.env.OCR_RETRY_BASE_DELAY, 60000); - - 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. - 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})` }; - } - - 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, log) { - 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) { - log( - `[rate-limit] ${tag}: remaining=${remaining}/${limit != null ? limit : "?"}` + - (reset != null ? `, reset epoch=${reset}` : "") - ); - } - return remaining != null ? Number(remaining) : null; - } catch (_) { - return null; - } -} - -// ---- Read API + idempotency helpers ---- -// -// The helpers below back the "prevent duplicate review posts on retry" -// strategy: when a batch createReview fails with a 5xx, the request may still -// have landed on the server. Before retrying, we query existing reviews and -// review comments (each tagged with a per-run HTML comment) and only retry the -// comments that are actually missing. Read calls are paced (shorter delays -// than writes) and degrade to "unknown" (null) when the read API itself fails, -// so the caller skips retrying rather than risking a duplicate. - -// 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, log) { - const MAX_RETRIES = parseNonNegInt(process.env.OCR_MAX_RETRIES, 3); - let lastErr; - for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { - try { - return await fn(); - } catch (e) { - lastErr = e; - const retryInfo = computeRetryDelayMs(e, attempt); - const willRetry = retryInfo != null && attempt < MAX_RETRIES; - if (willRetry) { - const secs = (retryInfo.delayMs / 1000).toFixed(1); - 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 { - log(`[${tag}] failed after ${attempt + 1} attempts: ${e.message}`); - throw e; - } - } - } - throw lastErr != null - ? lastErr - : new Error(`withRetry(${tag}): exhausted retries with no error captured`); -} - -// 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, log) { - const res = await withRetry(tag, fn, log); - const remaining = logRateLimitQuota(res, tag, log); - const LOW_REMAINING_THRESHOLD = parseNonNegInt(process.env.OCR_LOW_REMAINING_THRESHOLD, 3); - const lowQuota = remaining != null && remaining <= LOW_REMAINING_THRESHOLD; - if (lowQuota) { - const READ_LOW_REMAINING_SPACING = parseNonNegInt(process.env.OCR_READ_LOW_REMAINING_SPACING, 5000); - log(`[rate-limit] quota low after read (${remaining} <= ${LOW_REMAINING_THRESHOLD}); spacing ${READ_LOW_REMAINING_SPACING}ms.`); - await sleep(READ_LOW_REMAINING_SPACING); - } else { - const READ_SUCCESS_DELAY = parseNonNegInt(process.env.OCR_READ_SUCCESS_DELAY, 500); - 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, log, 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), log); - 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 catches it and returns 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) { - 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 the original fallback). -async function findExistingBatchReview({ github, owner, repo, prNumber, tag, log }) { - const reviews = await readAllPages("listReviews", (page, per_page) => - github.rest.pulls.listReviews({ owner, repo, pull_number: prNumber, per_page, page }), log - ); - 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({ github, owner, repo, prNumber, log }) { - const comments = await readAllPages("listReviewComments", (page, per_page) => - github.rest.pulls.listReviewComments({ owner, repo, pull_number: prNumber, per_page, page }), log - ); - const ids = new Set(); - // Anchor the regex to the HTML comment wrapper () so - // user-generated content or code suggestions cannot trigger false positives - // in the idempotency check. The ID format is `ocr--` where - // RUN_TAG is `-` and is a per-comment random - // hex token. Capture group 1 holds the bare ID, so we can add it directly - // without stripping comment markers. - const ID_RE = //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({ github, owner, repo, prNumber, id, log }) { - try { - const posted = await getPostedCommentIds({ github, owner, repo, prNumber, log }); - return posted.has(id); - } catch (e) { - log(`[isCommentAlreadyPosted] check failed for ${id} (${e.message}); treating as unknown to avoid duplicates.`); - 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(runTag) { - return `ocr-${runTag}-${crypto.randomBytes(8).toString("hex")}`; -} - -// ---- Formatting helpers (ported verbatim) ---- - -// Assemble the visible comment body. When `id` is provided (inline comments), -// the per-comment ID tag is prepended as an HTML comment (invisible when -// rendered) so getPostedCommentIds can match it back on retry for the -// idempotency check. The code suggestion block is then appended if present. -function formatComment(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
💡 Suggested Change\n\n"; - md += "**Before:**\n" + fencedBlock(comment.existing_code) + "\n\n"; - md += "**After:**\n" + fencedBlock(comment.suggestion_code) + "\n\n"; - md += "
"; - } - return md; -} - -// Merged summary header. All posting-outcome counts are surfaced here (and -// ONLY here) so the numbers add up to the total and the reader no longer has -// to reconcile two separately presented breakdowns (the old "posted as -// inline / posted as summary" header vs. the trailing "Posting Statistics" -// block, whose overlapping definitions made the summary hard to interpret). -// -// The four counts are mutually exclusive and, together with `inline`, sum to -// `total`: -// inline — comments that landed as review inline comments -// summary — comments without line info, rendered in the summary body below -// skipped — comments suppressed by incremental overlap filtering -// failed — comments that had line info but could not be posted (also -// rendered in the body below, each tagged with its failure reason) -function buildSummaryBody({ total, inline, summary, skipped, failed, warnings }) { - let body = `🔍 **OpenCodeReview** found **${total}** issue(s) in this PR.`; - if (total > 0) { - body += `\n- ✅ Successfully posted inline: ${inline} comment(s)`; - if (summary > 0) { - body += `\n- 📝 In summary (no line info): ${summary} comment(s)`; - } - if (skipped > 0) { - body += `\n- ⏭️ Skipped (overlap with history): ${skipped} comment(s)`; - } - if (failed > 0) { - body += `\n- ❌ Failed to post inline: ${failed} comment(s)`; - } - } - if (warnings && warnings.length > 0) { - body += `\n\n⚠️ ${warnings.length} warning(s) occurred during review.`; - } - return body; -} - -// Pre-review summary body: shown in the anchor comment while inline comments -// are being posted. Includes only what is known before the review lands (issue -// count, warnings, comments without line info) — final posting statistics are -// added by the finalize phase. Kept informative (not an empty placeholder) so -// the summary is useful even if the run is interrupted before finalize. -function buildPreReviewSummaryBody(totalCount, summaryComments, warnings) { - let body = `🔍 **OpenCodeReview** found **${totalCount}** issue(s) in this PR.`; - if (totalCount > 0) { - body += `\n- ⏳ _Posting review comments…_`; - } - if (warnings.length > 0) { - body += `\n\n⚠️ ${warnings.length} warning(s) occurred during review.`; - } - body += formatSummaryComments(summaryComments); - body += formatWarnings(warnings); - return body; -} - -function formatSummaryComments(summaryComments) { - let body = ""; - for (const { comment, reason } of summaryComments) { - body += "\n\n---\n\n"; - body += formatCommentMarkdown(comment, reason); - } - return body; -} - -// Render the warning contents as a bulleted list under a "⚠️ Warnings" heading. -// Returns "" when there are no warnings, so callers can append unconditionally. -// OCR warning objects carry `file`, `message`, and `type`; each present field is -// surfaced so the summary shows where/why the warning happened. Plain-string -// warnings (and any unknown shape) degrade gracefully to their textual form. -function formatWarnings(warnings) { - if (!warnings || warnings.length === 0) return ""; - let body = "\n\n---\n\n⚠️ **Warnings:**"; - for (const w of warnings) { - body += `\n- ${formatWarningEntry(w)}`; - } - return body; -} - -// Format a single warning as a compact bullet. Builds a `file (type): message` -// prefix from whichever of file/type are present, then appends the message. -// Missing fields are skipped so a partial warning still reads naturally. -function formatWarningEntry(w) { - if (w == null) return ""; - if (typeof w === "string") return w; - if (typeof w === "object") { - const prefixParts = []; - if (w.file != null && String(w.file) !== "") prefixParts.push(`\`${w.file}\``); - if (w.type != null && String(w.type) !== "") prefixParts.push(`(\`${w.type}\`)`); - const prefix = prefixParts.join(" "); - const msg = w.message != null ? String(w.message) : ""; - if (prefix && msg) return `${prefix}: ${msg}`; - if (msg) return msg; - if (prefix) return prefix; - try { - return JSON.stringify(w); - } catch (_) { - return String(w); - } - } - return String(w); -} - -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)); -} - -function safeRead(fs, p) { - try { - return fs.readFileSync(p, "utf8"); - } catch (_) { - return ""; - } -} - -module.exports = { - runPostReviewComments, - postSummary, - findExistingSummaryComment, - findSummaryIssueComment, - ensureSummaryAnchor, - finalizeSummary, - listExistingReviewComments, - getAuthenticatedLogin, - isBotComment, - overlapsHistory, - lineSpan, - sameCommentSpan, - resolveThreshold, - DEFAULT_OVERLAP_THRESHOLD, - computeRetryDelayMs, - getHeader, - logRateLimitQuota, - parseNonNegInt, - withRetry, - readWithPacing, - readAllPages, - findExistingBatchReview, - getPostedCommentIds, - isCommentAlreadyPosted, - newCommentId, - formatComment, - formatCommentMarkdown, - buildSummaryBody, - buildPreReviewSummaryBody, - formatSummaryComments, - formatWarnings, - fencedBlock, - safeFence, - SUMMARY_MARKER, - NO_LINE_REASON, -}; diff --git a/scripts/github-actions/post-review-comments.test.js b/scripts/github-actions/post-review-comments.test.js index 78679e9..9fc1dcd 100644 --- a/scripts/github-actions/post-review-comments.test.js +++ b/scripts/github-actions/post-review-comments.test.js @@ -1,39 +1,47 @@ #!/usr/bin/env node "use strict"; -// Unit tests for scripts/github-actions/post-review-comments.js. -// -// Run via: node scripts/github-actions/post-review-comments.test.js -// (also wired as `npm run test:github-actions`). -// -// These tests drive runPostReviewComments directly with an injected mock -// github/core/fs, replacing the previous approach of regex-extracting the -// inline script from workflow YAML. - const assert = require("assert"); +const fs = require("fs"); const path = require("path"); -const { runPostReviewComments, safeFence, fencedBlock, lineSpan, sameCommentSpan, overlapsHistory, resolveThreshold, DEFAULT_OVERLAP_THRESHOLD, newCommentId, getPostedCommentIds, computeRetryDelayMs, formatWarnings } = require(path.join(__dirname, "post-review-comments.js")); +const vm = require("vm"); -// Make all retry/pacing delays effectively zero so tests run fast. -// computeRetryDelayMs reads OCR_RETRY_MAX_DELAY / OCR_RETRY_BASE_DELAY via -// parseNonNegInt; "1" keeps the cap/base at 1ms so any transient/rate-limit -// backoff sleep is effectively instant. -process.env.OCR_MAX_RETRIES = "0"; -process.env.OCR_SUCCESS_DELAY = "0"; -process.env.OCR_FAILURE_DELAY = "0"; -process.env.OCR_LOW_REMAINING_SPACING = "0"; -process.env.OCR_LOW_REMAINING_THRESHOLD = "0"; -process.env.OCR_RETRY_MAX_DELAY = "1"; -process.env.OCR_RETRY_BASE_DELAY = "1"; -process.env.OCR_READ_SUCCESS_DELAY = "0"; -process.env.OCR_READ_LOW_REMAINING_SPACING = "0"; +const repoRoot = path.join(__dirname, "..", ".."); +const workflowFiles = [ + ".github/workflows/ocr-review.yml", + "examples/github_actions/ocr-review.yml", +]; -const context = { - repo: { owner: "owner", repo: "repo" }, - issue: { number: 123 }, - eventName: "pull_request_target", - payload: { pull_request: { head: { sha: "head-sha" } } }, -}; +function extractPostReviewScript(workflowPath) { + const text = fs.readFileSync(path.join(repoRoot, workflowPath), "utf8"); + const lines = text.split("\n"); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const marker = line.match(/^(\s*)script:\s*\|\s*$/); + if (!marker) continue; + + const blockIndent = marker[1].length + 2; + const block = []; + for (let j = i + 1; j < lines.length; j++) { + const current = lines[j]; + if (current.trim() === "") { + block.push(""); + continue; + } + const indent = current.match(/^ */)[0].length; + if (indent < blockIndent) break; + block.push(current.slice(blockIndent)); + } + + const script = block.join("\n"); + if (script.includes("/tmp/ocr-result.json")) { + return script; + } + } + + throw new Error(`post review script not found in ${workflowPath}`); +} function mockFs(resultText, stderrText) { return { @@ -45,241 +53,64 @@ function mockFs(resultText, stderrText) { }; } -function makeErr(message, status, headers) { - const e = new Error(message); - if (status != null) e.status = status; - if (headers) e.response = { headers }; - return e; -} - -// Identity key for a single inline review comment, used to drive per-comment -// error injection. Two comments on the same path but different lines get -// different keys, so one can fail (e.g. 422 line-unresolvable) while another on -// the same file succeeds. Mirrors the (path, line range) identity the bot uses -// for incremental dedup and the idempotency check. -function commentKey(rc) { - if (!rc) return "?"; - return `${rc.path}|${rc.start_line != null ? rc.start_line : "-"}|${rc.line != null ? rc.line : "-"}`; -} - -// Temporarily override env vars for a single (sync or async) test body, always -// restoring originals afterwards. Used for retry/quota tests that need a -// different OCR_MAX_RETRIES / OCR_LOW_REMAINING_THRESHOLD than the fast default. -async function withEnv(env, fn) { - const saved = {}; - for (const k of Object.keys(env)) { - saved[k] = process.env[k]; - process.env[k] = env[k]; - } - try { - return await fn(); - } finally { - for (const k of Object.keys(env)) { - if (saved[k] === undefined) delete process.env[k]; - else process.env[k] = saved[k]; - } - } -} - -function makeGithub(opts = {}) { +function mockGithub(options) { const createReviewCalls = []; const issueComments = []; - const updatedComments = []; - const listCommentsCalls = []; - const listReviewCommentsCalls = []; - const listReviewsCalls = []; - // Interleaved log of write operations (createReview / createComment / - // updateComment) in call order, so tests can assert positioning invariants - // such as "summary created before review" without timing the calls. - const ops = []; - // Per-comment attempt counter, keyed by commentKey, so perCommentError can be - // attempt-aware (e.g. "429 on attempt 0, succeed on attempt 1"). - const perCommentAttempts = new Map(); - - function successRemaining() { - return opts.successRemaining != null ? String(opts.successRemaining) : "5000"; - } - - // Inline comment objects recorded in the BATCH createReview call (index 0) - // only, so tests can simulate "this comment already landed on the server" - // without predicting the random IDs from newCommentId(). Scoped to the batch - // call so batch-level landing (echoPosted) stays disjoint from per-comment - // landing (landedKeys, which reads per-comment calls at index >= 1). - function batchPostedComments() { - const out = []; - const call = createReviewCalls[0]; - if (call) { - for (const c of call.comments || []) { - const m = //.exec(c.body || ""); - if (m) { - out.push({ - path: c.path, - body: c.body, - side: c.side || "RIGHT", - start_line: c.start_line, - line: c.line, - }); - } - } - } - return out; - } return { createReviewCalls, issueComments, - updatedComments, - listCommentsCalls, - listReviewCommentsCalls, - listReviewsCalls, - ops, rest: { - users: { - getAuthenticated: async () => ({ data: { login: "github-actions[bot]" } }), - }, pulls: { get: async () => ({ data: { head: { sha: "head-sha" } } }), createReview: async (params) => { createReviewCalls.push(params); - ops.push({ type: "createReview", params }); - const callIdx = createReviewCalls.length - 1; - const successRes = () => ({ data: {}, headers: { "x-ratelimit-remaining": successRemaining() } }); - if (callIdx === 0) { - // Batch call. bulkErrorSpec (rich: with headers) takes precedence - // over the legacy bulkError/bulkErrorStatus pair. - if (opts.bulkErrorSpec) { - throw makeErr(opts.bulkErrorSpec.message, opts.bulkErrorSpec.status, opts.bulkErrorSpec.headers); - } - if (opts.bulkError) { - throw makeErr(opts.bulkError, opts.bulkErrorStatus, opts.bulkHeaders); - } - return successRes(); + if (createReviewCalls.length === 1 && options.bulkError) { + throw new Error(options.bulkError); } - // Per-comment call (index >= 1). perCommentError(rc, attempt) lets a - // test fail some comments and not others (partial failure), and be - // attempt-aware (retry-then-succeed). Falls back to the legacy - // individualError (applies to all per-comment calls) for older tests. - if (typeof opts.perCommentError === "function") { - const rc = params.comments && params.comments[0]; - const key = commentKey(rc); - const attempt = perCommentAttempts.get(key) || 0; - perCommentAttempts.set(key, attempt + 1); - const spec = opts.perCommentError(rc, attempt); - if (spec) throw makeErr(spec.message, spec.status, spec.headers); - return successRes(); + if (createReviewCalls.length > 1 && options.individualError) { + throw new Error(options.individualError); } - if (opts.individualError) { - throw makeErr(opts.individualError, opts.individualErrorStatus, opts.individualHeaders); - } - return successRes(); - }, - listReviews: async (params) => { - listReviewsCalls.push(params); - // Consume a queued sequence of read errors (e.g. a transient 429 on - // the read itself) before falling through to the normal response, so - // withRetry's rate-limit backoff on reads can be exercised. - if (opts.listReviewsErrorSeq && opts.listReviewsErrorSeq.length) { - const spec = opts.listReviewsErrorSeq.shift(); - throw makeErr(spec.message, spec.status, spec.headers); - } - if (opts.listReviewsThrow) { - throw makeErr("listReviews unavailable", 503); - } - // Simulate the batch review having landed on the server even though - // createReview threw: echo the batch call's body (which carries the - // REVIEW_TAG) as an existing review's body so findExistingBatchReview - // matches it. - if (opts.batchLanded && createReviewCalls[0]) { - return { data: [{ id: 999, body: createReviewCalls[0].body || "" }] }; - } - return { data: opts.reviews || [] }; - }, - listReviewComments: async (params) => { - listReviewCommentsCalls.push(params); - if (opts.listReviewCommentsThrow) { - throw makeErr(opts.listReviewCommentsError || "read api unavailable", 503); - } - // Build the visible comment set from two disjoint, deduped sources: - // - echoPosted: comments carried by the BATCH call (index 0) that - // "already landed" — drives the batch-level getPostedCommentIds. - // - landedKeys: per-comment calls (index >= 1) that landed despite - // a 5xx/network error — drives per-comment isCommentAlreadyPosted. - // Deduping by embedded comment id keeps them composable. - if (opts.echoPosted || opts.landedKeys) { - const byId = new Map(); - const add = (c) => { - const m = //.exec(c.body || ""); - const k = m ? m[1] : `${c.path}|${c.start_line != null ? c.start_line : "-"}|${c.line != null ? c.line : "-"}|${c.body}`; - if (!byId.has(k)) byId.set(k, c); - }; - if (opts.echoPosted) { - const posted = batchPostedComments(); - const n = opts.postedCount != null ? opts.postedCount : posted.length; - for (const c of posted.slice(0, n)) add(c); - } - if (opts.landedKeys) { - for (let i = 1; i < createReviewCalls.length; i++) { - const rc = createReviewCalls[i].comments && createReviewCalls[i].comments[0]; - if (rc && opts.landedKeys.has(commentKey(rc))) { - add({ path: rc.path, body: rc.body, side: rc.side || "RIGHT", start_line: rc.start_line, line: rc.line }); - } - } - } - return { data: [...byId.values()] }; - } - return { data: opts.history || [] }; + return { data: {} }; }, }, issues: { - listComments: async (params) => { - listCommentsCalls.push(params); - return { data: opts.existingSummary || [] }; - }, createComment: async (params) => { issueComments.push(params); - ops.push({ type: "createComment", params }); - return { data: { id: 1000 + issueComments.length, html_url: `http://ex/c${issueComments.length}` } }; - }, - updateComment: async (params) => { - updatedComments.push(params); - ops.push({ type: "updateComment", params }); - return { data: { id: params.comment_id, html_url: `http://ex/u${updatedComments.length}` } }; + return { data: {} }; }, }, }, }; } -function mockCore() { - const outputs = {}; - return { - outputs, - setOutput(name, value) { outputs[name] = value; }, - info() {}, +async function runPostReviewScript(workflowPath, options) { + const script = extractPostReviewScript(workflowPath); + const github = mockGithub(options); + const context = { + repo: { owner: "owner", repo: "repo" }, + issue: { number: 123 }, + eventName: "pull_request_target", + payload: { pull_request: { head: { sha: "head-sha" } } }, }; -} - -async function run({ result, stderr = "", opts = {}, githubOpts = {} }) { - const resultText = typeof result === "string" ? result : JSON.stringify(result); - const fs = mockFs(resultText, stderr); - const github = makeGithub(githubOpts); - const core = mockCore(); - const options = Object.assign({ stickySummary: true, incremental: false }, opts); - await runPostReviewComments({ + const sandbox = { github, context, - core, - fs, - resultPath: "/tmp/ocr-result.json", - stderrPath: "/tmp/ocr-stderr.log", - ...options, + console: { log() {} }, + require(name) { + if (name === "fs") return options.fs; + throw new Error(`unexpected require: ${name}`); + }, + }; + + await vm.runInNewContext(`(async () => {\n${script}\n})()`, sandbox, { + timeout: 1000, }); - return { github, core, outputs: core.outputs }; + + return github; } -// ---- Test cases (mirror PLAN §7) ---- - -async function testFailedInlineCommentsAreSummarized() { +async function testFailedInlineCommentsAreSummarized(workflowPath) { const result = { comments: [ { @@ -303,1032 +134,35 @@ async function testFailedInlineCommentsAreSummarized() { warnings: [], }; - const { github } = await run({ - result, - githubOpts: { - bulkError: 'Unprocessable Entity: "Line could not be resolved"', - individualError: 'Unprocessable Entity: "Line could not be resolved"', - }, - opts: { stickySummary: true }, + const github = await runPostReviewScript(workflowPath, { + fs: mockFs(JSON.stringify(result), ""), + bulkError: 'Unprocessable Entity: "Line could not be resolved"', + individualError: 'Unprocessable Entity: "Line could not be resolved"', }); - assert.strictEqual(github.createReviewCalls.length, 2, "bulk + one per-comment attempt"); - assert.strictEqual(github.issueComments.length, 1, "summary anchor created (no existing)"); - assert.strictEqual(github.updatedComments.length, 1, "anchor finalized with the full body"); - const body = github.updatedComments[0].body; + assert.strictEqual(github.createReviewCalls.length, 2); + assert.strictEqual(github.issueComments.length, 1); + const body = github.issueComments[0].body; assert.match(body, /No-line content with a fenced block/); assert.match(body, /Failed inline content must remain visible/); assert.match(body, /Line could not be resolved/); - // The no-line comment now carries the same reason line as a posting failure. - assert.match(body, /GitHub could not post this as an inline comment: No line information provided/); - // Posting statistics are merged into the leading summary header (the trailing - // "📊 Posting Statistics" section is gone), so the merged stats must appear - // BEFORE the per-comment renderings. - assert.doesNotMatch(body, /Inline comments shown in summary/); - assert.doesNotMatch(body, /📊 \*\*Posting Statistics:\*\*/); - const statsIdx = body.indexOf("❌ Failed to post inline"); - const noLineIdx = body.indexOf("No-line content with a fenced block"); - const failedIdx = body.indexOf("Failed inline content must remain visible"); - assert.ok(statsIdx !== -1, "merged stats present in the header"); - assert.ok(statsIdx < noLineIdx, "merged stats rendered before no-line comment"); - assert.ok(statsIdx < failedIdx, "merged stats rendered before failed comment"); } -async function testWarningsListedAfterSummaryComments() { - const result = { - comments: [ - { path: "src/a.js", content: "Inline comment content.", start_line: 1, end_line: 1 }, - { path: "docs/no-line.md", content: "No-line comment content.", start_line: 0, end_line: 0 }, - ], - warnings: [ - "file too large to review fully", - { file: "assets/logo.png", message: "skipped binary asset", type: "binary_asset" }, - ], - }; - - const { github } = await run({ result, opts: { stickySummary: true } }); - - assert.strictEqual(github.updatedComments.length, 1, "anchor finalized with the full body"); - const body = github.updatedComments[0].body; - // Both the count line and the detailed list must be present... - assert.match(body, /2 warning\(s\) occurred during review/); - assert.match(body, /⚠️ \*\*Warnings:\*\*/); - assert.match(body, /file too large to review fully/); - // Object warnings surface file, type, and message. - assert.match(body, /`assets\/logo\.png` \(`binary_asset`\): skipped binary asset/); - // ...and the list must come AFTER the non-inline (no-line) review comment. - const noLineIdx = body.indexOf("No-line comment content."); - const warningsIdx = body.indexOf("⚠️ **Warnings:**"); - assert.ok(noLineIdx !== -1 && warningsIdx > noLineIdx, "warnings list placed after summary comments"); - // The pre-review anchor body must also surface the warning contents. - assert.match(github.issueComments[0].body, /`assets\/logo\.png` \(`binary_asset`\): skipped binary asset/); -} - -function testFormatWarnings() { - assert.strictEqual(formatWarnings([]), ""); - assert.strictEqual(formatWarnings(null), ""); - assert.strictEqual(formatWarnings(undefined), ""); - // Plain string warnings. - assert.match(formatWarnings(["a", "b"]), /⚠️ \*\*Warnings:\*\*/); - assert.match(formatWarnings(["a", "b"]), /\n- a\n- b/); - // Object warnings surface file, type, and message together. - assert.match( - formatWarnings([{ file: "internal/llm/resolver.go", message: "context deadline exceeded", type: "subtask_error" }]), - /\n- `internal\/llm\/resolver\.go` \(`subtask_error`\): context deadline exceeded/ - ); - // Partial objects: only message. - assert.match(formatWarnings([{ message: "boom" }]), /\n- boom/); - // Partial objects: file + message, no type. - assert.match(formatWarnings([{ file: "a.go", message: "m" }]), /\n- `a\.go`: m/); - // Unknown object shapes degrade to a stable JSON stringification. - assert.match(formatWarnings([{ code: 42 }]), /\n- \{"code":42\}/); -} - -async function testErrorCommentUsesSafeFence() { - const { github } = await run({ - result: "not json", - stderr: "stderr includes a fence\n```js\nbroken();\n```", - opts: { stickySummary: true }, +async function testErrorCommentUsesSafeFence(workflowPath) { + const github = await runPostReviewScript(workflowPath, { + fs: mockFs("not json", "stderr includes a fence\n```js\nbroken();\n```"), }); assert.strictEqual(github.issueComments.length, 1); const body = github.issueComments[0].body; - // stderr contains a 3-backtick fence, so safeFence must use 4 backticks. assert.match(body, /\n````\nstderr includes a fence/); } -async function testStickyUpdatesExistingSummary() { - const existing = [{ id: 42, body: "\nold summary", user: { login: "github-actions[bot]" } }]; - const result = { comments: [{ path: "src/a.js", content: "x", start_line: 1, end_line: 1 }], warnings: [] }; - - const { github, outputs } = await run({ - result, - githubOpts: { existingSummary: existing }, - opts: { stickySummary: true }, - }); - - assert.strictEqual(github.updatedComments.length, 1, "existing summary updated"); - assert.strictEqual(github.issueComments.length, 0, "no new comment created"); - assert.strictEqual(github.updatedComments[0].comment_id, 42); - assert.strictEqual(outputs.comments_inline, "1"); - assert.strictEqual(outputs.comments_skipped, "0"); - assert.strictEqual(outputs.summary_comment_url, "http://ex/u1"); -} - -// Non-sticky + batch fails (e.g. rate-limit) but the per-comment fallback then -// succeeds for every comment. The summary must still be posted as its own issue -// comment (the summary never rides in the review body anymore) and finalized -// with the success statistics. -async function testNonStickyFallbackAllSuccessStillPostsSummary() { - const result = { comments: [{ path: "src/a.js", content: "comment A", start_line: 1, end_line: 1 }], warnings: [] }; - - const { github, outputs } = await run({ - result, - githubOpts: { - // Batch fails (rate-limit)... - bulkError: "rate limited", - bulkErrorStatus: 429, - // ...but the per-comment fallback succeeds (no individualError). - }, - opts: { stickySummary: false }, - }); - - // batch (call #1, failed) + one per-comment retry (call #2, succeeded). - assert.strictEqual(github.createReviewCalls.length, 2, "batch + per-comment fallback"); - assert.strictEqual(github.issueComments.length, 1, "summary anchor posted as issue comment"); - assert.strictEqual(github.updatedComments.length, 1, "anchor finalized with the success stats"); - assert.strictEqual(outputs.comments_inline, "1"); - assert.strictEqual(outputs.comments_failed, "0"); -} - -async function testNonStickyCreatesNewCommentOnFallback() { - const result = { comments: [{ path: "src/a.js", content: "Failed inline content.", start_line: 10, end_line: 10 }], warnings: [] }; - - const { github } = await run({ - result, - githubOpts: { - bulkError: 'Unprocessable Entity: "Line could not be resolved"', - individualError: 'Unprocessable Entity: "Line could not be resolved"', - }, - opts: { stickySummary: false }, - }); - - assert.strictEqual(github.issueComments.length, 1, "anchor summary comment created"); - assert.strictEqual(github.updatedComments.length, 1, "anchor finalized with full body"); - assert.match(github.updatedComments[0].body, /Failed inline content/); -} - -async function testNoCommentsStickyUpdate() { - const existing = [{ id: 7, body: "\nold good", user: { login: "github-actions[bot]" } }]; - const result = { comments: [], message: "All clear." }; - - const { github } = await run({ - result, - githubOpts: { existingSummary: existing }, - opts: { stickySummary: true }, - }); - - assert.strictEqual(github.updatedComments.length, 1); - assert.strictEqual(github.issueComments.length, 0); - assert.match(github.updatedComments[0].body, /All clear\./); -} - -async function testIncrementalSkipsOverlapping() { - const history = [{ path: "src/a.js", line: 10, start_line: 10, side: "RIGHT", user: { login: "github-actions[bot]" } }]; - const result = { - comments: [ - { path: "src/a.js", content: "overlap", start_line: 10, end_line: 10 }, - { path: "src/b.js", content: "new", start_line: 5, end_line: 5 }, - ], - warnings: [], - }; - - const { github, outputs } = await run({ - result, - githubOpts: { history }, - opts: { stickySummary: true, incremental: true }, - }); - - assert.strictEqual(github.createReviewCalls.length, 1, "one batch review"); - const sent = github.createReviewCalls[0].comments; - assert.strictEqual(sent.length, 1, "only non-overlapping comment sent"); - assert.strictEqual(sent[0].path, "src/b.js"); - assert.strictEqual(outputs.comments_skipped, "1"); - assert.strictEqual(outputs.comments_inline, "1"); -} - -async function testIncrementalAllOverlapPostsNoReview() { - const history = [{ path: "src/a.js", line: 10, start_line: 10, side: "RIGHT", user: { login: "github-actions[bot]" } }]; - const result = { comments: [{ path: "src/a.js", content: "overlap", start_line: 10, end_line: 10 }], warnings: [] }; - - const { github, outputs } = await run({ - result, - githubOpts: { history }, - opts: { stickySummary: true, incremental: true }, - }); - - assert.strictEqual(github.createReviewCalls.length, 0, "no review posted"); - assert.strictEqual(github.issueComments.length, 1, "summary anchor created"); - assert.strictEqual(github.updatedComments.length, 1, "anchor finalized with status body"); - assert.match(github.updatedComments[0].body, /nothing new was posted/); - assert.strictEqual(outputs.comments_skipped, "1"); - assert.strictEqual(outputs.comments_inline, "0"); -} - -// Multi-line IoU dedup end-to-end at the default threshold (0.6). History -// covers [8,10]; of the three new multi-line comments, the identical span -// (IoU 1.0) is skipped while the low-IoU one (0.5) and a different file are -// posted. Also verifies a single-line comment is NOT suppressed by a prior -// multi-line block on an overlapping line. -async function testIncrementalMultiLineIoUDefaultThreshold() { - const history = [{ path: "src/a.js", line: 10, start_line: 8, side: "RIGHT", user: { login: "github-actions[bot]" } }]; - const result = { - comments: [ - { path: "src/a.js", content: "identical", start_line: 8, end_line: 10 }, // IoU 1.0 -> skipped - { path: "src/a.js", content: "low-iou", start_line: 9, end_line: 11 }, // IoU 0.5 -> posted - { path: "src/a.js", content: "single", start_line: 9, end_line: 9 }, // single vs multi -> posted - { path: "src/b.js", content: "new", start_line: 1, end_line: 3 }, // other file -> posted - ], - warnings: [], - }; - - const { github, outputs } = await run({ - result, - githubOpts: { history }, - opts: { stickySummary: true, incremental: true }, - }); - - assert.strictEqual(github.createReviewCalls.length, 1, "one batch review"); - const sent = github.createReviewCalls[0].comments; - assert.strictEqual(sent.length, 3, "identical multi-line span skipped, rest posted"); - const aJsLow = sent.find((c) => c.path === "src/a.js" && c.start_line === 9 && c.line === 11); - const aJsSingle = sent.find((c) => c.path === "src/a.js" && c.line === 9 && c.start_line == null); - assert.ok(aJsLow, "low-IoU multi-line comment was posted"); - assert.ok(aJsSingle, "single-line comment was not suppressed by multi-line history"); - assert.strictEqual(outputs.comments_skipped, "1"); - assert.strictEqual(outputs.comments_inline, "3"); -} - -// Threshold propagation: lowering incrementalOverlapThreshold to 0.4 makes the -// previously low-IoU span (0.5) now overlap, so it is skipped. Exercises the -// runPostReviewComments -> overlapsHistory wiring end-to-end. -async function testIncrementalOverlapThresholdPropagated() { - const history = [{ path: "src/a.js", line: 10, start_line: 8, side: "RIGHT", user: { login: "github-actions[bot]" } }]; - const result = { - comments: [{ path: "src/a.js", content: "low-iou", start_line: 9, end_line: 11 }], // IoU 0.5 - warnings: [], - }; - - const { github, outputs } = await run({ - result, - githubOpts: { history }, - opts: { stickySummary: true, incremental: true, incrementalOverlapThreshold: 0.4 }, - }); - - assert.strictEqual(github.createReviewCalls.length, 0, "no review posted (0.5 > 0.4 now overlaps)"); - assert.strictEqual(outputs.comments_skipped, "1"); - assert.strictEqual(outputs.comments_inline, "0"); -} - -// ---- Idempotency tests (prevent duplicate review posts on retry) ---- - -// Batch createReview fails with 5xx but the batch actually landed on the -// server. The retry must post ONLY the comments that are missing, not all of -// them (which would create duplicates). -async function testBatchLandedRetriesOnlyMissingComments() { - const result = { - comments: [ - { path: "src/a.js", content: "comment A", start_line: 1, end_line: 1 }, - { path: "src/b.js", content: "comment B", start_line: 2, end_line: 2 }, - { path: "src/c.js", content: "comment C", start_line: 3, end_line: 3 }, - ], - warnings: [], - }; - - const { github, outputs } = await run({ - result, - githubOpts: { - // Batch createReview fails with 5xx ... - bulkError: "Bad Gateway", - bulkErrorStatus: 502, - // ... but the batch actually landed on the server (listReviews echoes - // the batch call's REVIEW_TAG-tagged body back as an existing review). - batchLanded: true, - // 2 of the 3 inline comments are already posted (echoed from the batch - // call's comment bodies via listReviewComments). - echoPosted: true, - postedCount: 2, - }, - opts: { stickySummary: true }, - }); - - // batch (call #1) + only the 1 missing comment retried (call #2). NOT 3 - // per-comment calls -> no duplicates. - assert.strictEqual(github.createReviewCalls.length, 2, "batch + only the missing comment retried"); - assert.strictEqual(github.createReviewCalls[1].comments.length, 1, "exactly one comment retried"); - assert.strictEqual(github.createReviewCalls[1].comments[0].path, "src/c.js", "the missing comment is retried"); - assert.strictEqual(outputs.comments_inline, "3", "2 already-posted + 1 retried = 3 successes"); - assert.strictEqual(outputs.comments_failed, "0"); -} - -// Per-comment createReview fails with 5xx but the comment already landed on -// the server. It must be treated as a success (no retry, no duplicate). -async function testPerComment5xxAlreadyPostedTreatedAsSuccess() { - const result = { comments: [{ path: "src/a.js", content: "comment A", start_line: 1, end_line: 1 }], warnings: [] }; - - const { github, outputs } = await run({ - result, - githubOpts: { - bulkError: "Bad Gateway", - bulkErrorStatus: 502, - individualError: "Bad Gateway", - individualErrorStatus: 502, - // The comment is already on the server (echoed from the batch call's - // comment body via listReviewComments). - echoPosted: true, - }, - opts: { stickySummary: true }, - }); - - // batch (call #1) + one per-comment attempt (call #2) that 5xx'd. The - // idempotency check finds the comment already posted -> no retry. - assert.strictEqual(github.createReviewCalls.length, 2, "no retry after already-posted detection"); - assert.strictEqual(outputs.comments_inline, "1", "already-posted counted as success"); - assert.strictEqual(outputs.comments_failed, "0"); -} - -// Per-comment createReview fails with 5xx and the read API is unavailable, so -// the idempotency check cannot tell whether the comment landed. The retry must -// be SKIPPED (to avoid a duplicate) and the comment recorded as failed. -async function testPerComment5xxIdempotencyUnavailableSkipsRetry() { - const result = { comments: [{ path: "src/a.js", content: "comment A", start_line: 1, end_line: 1 }], warnings: [] }; - - const { github, outputs } = await run({ - result, - githubOpts: { - bulkError: "Bad Gateway", - bulkErrorStatus: 502, - individualError: "Bad Gateway", - individualErrorStatus: 502, - // Read API unavailable -> isCommentAlreadyPosted returns null (unknown). - listReviewCommentsThrow: true, - }, - opts: { stickySummary: true }, - }); - - // batch (call #1) + one per-comment attempt (call #2). No retry despite 5xx - // (unknown -> skip to avoid duplicate). - assert.strictEqual(github.createReviewCalls.length, 2, "no retry when idempotency check is unavailable"); - assert.strictEqual(outputs.comments_failed, "1", "recorded as failed, not retried"); - // The uncertainty is surfaced in the finalized summary. - assert.strictEqual(github.issueComments.length, 1, "anchor created"); - assert.strictEqual(github.updatedComments.length, 1, "anchor finalized"); - assert.match(github.updatedComments[0].body, /idempotency check unavailable/); -} - -// A summary comment already exists (e.g. a previous attempt within the run -// posted it). The anchor phase must reuse it (no duplicate created) and the -// finalize phase must refresh it in place with the final body. -async function testSummaryDoesNotDuplicateWhenAlreadyPosted() { - // context.runId/runAttempt are unset -> RUN_TAG = "0-1" -> SUMMARY_TAG = - // "". A real summary carries both the persistent - // SUMMARY_MARKER and the per-run SUMMARY_TAG. - const existing = [ - { id: 5, body: "\n\nold summary", user: { login: "github-actions[bot]" } }, - ]; - const result = { comments: [{ path: "src/a.js", content: "x", start_line: 1, end_line: 1 }], warnings: [] }; - - const { github, outputs } = await run({ - result, - githubOpts: { existingSummary: existing }, - opts: { stickySummary: true }, - }); - - // Batch review posted normally; the existing summary is reused and refreshed, - // never duplicated. - assert.strictEqual(github.createReviewCalls.length, 1, "batch review posted"); - assert.strictEqual(github.issueComments.length, 0, "no duplicate summary created"); - assert.strictEqual(github.updatedComments.length, 1, "existing summary refreshed in place"); - assert.strictEqual(github.updatedComments[0].comment_id, 5, "the existing comment is the one updated"); - assert.strictEqual(outputs.comments_inline, "1"); - assert.strictEqual(outputs.summary_comment_url, "http://ex/u1"); - assert.match(github.updatedComments[0].body, /Successfully posted inline: 1 comment/, "final body reflects the run outcome"); -} - -// Cold-start ordering: on the first review on a PR, the summary issue comment -// must be created BEFORE the batch review so its timeline position is above the -// review (GitHub orders issue comments oldest-first). It is then finalized -// (updated in place) after the review lands. This is the core fix for the -// "summary sandwiched between review blocks" defect on sticky PRs. -async function testSummaryAnchorCreatedBeforeReviewColdStart() { - const result = { comments: [{ path: "src/a.js", content: "x", start_line: 1, end_line: 1 }], warnings: [] }; - - const { github } = await run({ - result, - githubOpts: { existingSummary: [] }, // cold start: no existing summary - opts: { stickySummary: true }, - }); - - const types = github.ops.map((o) => o.type); - const anchorIdx = types.indexOf("createComment"); - const reviewIdx = types.indexOf("createReview"); - const finalizeIdx = types.lastIndexOf("updateComment"); - assert.notStrictEqual(anchorIdx, -1, "summary anchor created"); - assert.notStrictEqual(reviewIdx, -1, "batch review posted"); - assert.notStrictEqual(finalizeIdx, -1, "summary finalized"); - assert.ok(anchorIdx < reviewIdx, "summary anchor created BEFORE the review (cold-start positioning)"); - assert.ok(reviewIdx < finalizeIdx, "summary finalized AFTER the review"); - // The anchor body is a pre-review placeholder; the final body carries stats. - assert.match(github.issueComments[0].body, /Posting review comments/); - assert.match(github.updatedComments[0].body, /Successfully posted inline: 1 comment/); -} - -// Cold start + non-sticky: the per-run summary is also anchored before the -// review (non-sticky still creates a fresh comment each run, but within the run -// it must lead the review for a natural reading order). -async function testSummaryAnchorCreatedBeforeReviewNonSticky() { - const result = { comments: [{ path: "src/a.js", content: "x", start_line: 1, end_line: 1 }], warnings: [] }; - - const { github } = await run({ - result, - githubOpts: { existingSummary: [] }, - opts: { stickySummary: false }, - }); - - const types = github.ops.map((o) => o.type); - assert.ok(types.indexOf("createComment") < types.indexOf("createReview"), "anchor before review"); - assert.ok(types.indexOf("createReview") < types.lastIndexOf("updateComment"), "finalize after review"); -} - -function testNewCommentIdFormat() { - const id = newCommentId("12-3"); - // Format: ocr---<16 hex chars> (crypto.randomBytes(8)). - assert.match(id, /^ocr-12-3-[a-f0-9]{16}$/, "id format is ocr--"); - // Random -> two calls produce distinct IDs (so two comments that share - // path/line/content still get different IDs and the check never mistakes - // one for the other). - assert.notStrictEqual(newCommentId("1-1"), newCommentId("1-1"), "IDs are random per call"); -} - -async function testGetPostedCommentIdsExtractsEmbeddedIds() { - const github = { - rest: { - pulls: { - listReviewComments: async () => ({ - data: [ - { body: "\ncontent a" }, - { body: "no id here" }, - { body: "\ncontent c" }, - // User content that mentions the bare id string must NOT match: - // the regex is anchored to wrappers, defending against - // false positives in the idempotency check. - { body: "see ocr-0-1-aaaa0000bbbb1111 somewhere" }, - ], - headers: {}, - }), - }, - }, - }; - const ids = await getPostedCommentIds({ github, owner: "o", repo: "r", prNumber: 1, log: () => {} }); - assert.strictEqual(ids.size, 2, "only IDs inside HTML comment wrappers are extracted"); - assert.ok(ids.has("ocr-0-1-aaaa0000bbbb1111")); - assert.ok(ids.has("ocr-0-1-cccc2222dddd3333")); - assert.ok(!ids.has("ocr-0-1-zzzz0000"), "non-hex tokens do not match"); -} - -// ---- computeRetryDelayMs unit tests ---- -// -// The rate-limit retry strategy is a pure function of the error (status + -// response headers) and attempt number. The integration tests below cap every -// delay to ~1ms via OCR_RETRY_MAX_DELAY=1, so they cannot assert that specific -// headers are honored; these unit tests pin down each branch of the strategy -// directly. They run under realistic cap/base values (overridden locally) so -// the returned delayMs is meaningful. - -function testComputeRetryDelayMs() { - // Use realistic cap/base so delayMs reflects the strategy rather than the - // 1ms test-harness cap. Restored at the end. - const realCap = process.env.OCR_RETRY_MAX_DELAY; - const realBase = process.env.OCR_RETRY_BASE_DELAY; - process.env.OCR_RETRY_MAX_DELAY = "300000"; - process.env.OCR_RETRY_BASE_DELAY = "60000"; - try { - // Non-error / non-retryable -> null (no retry). - assert.strictEqual(computeRetryDelayMs(null, 0), null); - assert.strictEqual(computeRetryDelayMs(makeErr("validation", 422), 0), null); - - // 429 honoring retry-after (seconds form): delay = secs * 1000. - let r = computeRetryDelayMs(makeErr("rate", 429, { "retry-after": "5" }), 0); - assert.strictEqual(r.source, "retry-after"); - assert.strictEqual(r.delayMs, 5000); - - // 429 honoring retry-after (HTTP-date form): source tagged accordingly, - // delay ~ the time until the given date. - const dateMs = Date.now() + 5000; - r = computeRetryDelayMs(makeErr("rate", 429, { "retry-after": new Date(dateMs).toUTCString() }), 0); - assert.strictEqual(r.source, "retry-after (HTTP-date)"); - assert.ok(r.delayMs > 0 && r.delayMs <= 5000, "HTTP-date retry-after within 5s window"); - - // 429 with primary limit exhausted (remaining=0): wait until reset epoch. - const reset = Math.floor(Date.now() / 1000) + 10; - r = computeRetryDelayMs(makeErr("rate", 429, { "x-ratelimit-remaining": "0", "x-ratelimit-reset": String(reset) }), 0); - assert.strictEqual(r.source, "x-ratelimit-reset"); - assert.strictEqual(r.delayMs, 10000); - - // remaining > 0 must NOT trigger the reset branch even with a reset header. - r = computeRetryDelayMs(makeErr("rate", 429, { "x-ratelimit-remaining": "1", "x-ratelimit-reset": String(reset) }), 0); - assert.strictEqual(r.source, "exponential-backoff"); - - // 429 with no hint: exponential backoff, base*2^attempt + 0..999 jitter. - r = computeRetryDelayMs(makeErr("rate", 429), 0); - assert.strictEqual(r.source, "exponential-backoff"); - assert.ok(r.delayMs >= 60000 && r.delayMs <= 60999, "attempt 0 backoff = 60000 + jitter"); - r = computeRetryDelayMs(makeErr("rate", 429), 2); - assert.ok(r.delayMs >= 240000 && r.delayMs <= 240999, "attempt 2 backoff = 240000 + jitter"); - - // 403 is a rate-limit ONLY when the message mentions rate limit/abuse/secondary. - assert.ok(computeRetryDelayMs(makeErr("rate limit exceeded", 403), 0) != null, "403 + 'rate limit' retryable"); - assert.ok(computeRetryDelayMs(makeErr("abuse detection", 403), 0) != null, "403 + 'abuse' retryable"); - assert.ok(computeRetryDelayMs(makeErr("secondary rate", 403), 0) != null, "403 + 'secondary' retryable"); - assert.strictEqual(computeRetryDelayMs(makeErr("forbidden", 403), 0), null, "plain 403 not retryable"); - - // 5xx transient: shorter base (2000ms) than rate-limit, grows with attempt. - r = computeRetryDelayMs(makeErr("Bad Gateway", 502), 0); - assert.strictEqual(r.source, "transient-backoff"); - assert.ok(r.delayMs >= 2000 && r.delayMs <= 2999, "502 attempt 0 = 2000 + jitter"); - // 408 timeout is also treated as transient. - assert.strictEqual(computeRetryDelayMs(makeErr("timeout", 408), 0).source, "transient-backoff"); - - // Cap: a huge retry-after is clamped to OCR_RETRY_MAX_DELAY. - r = computeRetryDelayMs(makeErr("rate", 429, { "retry-after": "1000000" }), 0); - assert.strictEqual(r.delayMs, 300000, "capped to 300000ms"); - assert.match(r.detail, /CAPPED/, "capping is surfaced in detail"); - } finally { - if (realCap === undefined) delete process.env.OCR_RETRY_MAX_DELAY; - else process.env.OCR_RETRY_MAX_DELAY = realCap; - if (realBase === undefined) delete process.env.OCR_RETRY_BASE_DELAY; - else process.env.OCR_RETRY_BASE_DELAY = realBase; - } -} - -// ---- Cross-scenario integration tests ---- -// -// rate-limit × partial-invalid-content × landed-on-server intersect on the -// per-comment fallback loop, where EACH comment can independently succeed, -// fail with a non-retryable 4xx, retry on 429, or be recovered (or not) via -// the idempotency check after a 5xx/network error. The mock's perCommentError -// (comment-keyed, attempt-aware) + landedKeys/echoPosted drive these. - -// P0-1: batch rate-limit (429) triggers the per-comment fallback, where SOME -// comments succeed and SOME fail with 422 (invalid content, e.g. line gone). -// Verifies success/failed counts split correctly and ONLY the failed comment -// is surfaced in the summary (successful inline comments are not duplicated -// into the summary). -async function testBatchRateLimitWithPartialInvalidContent() { - const result = { - comments: [ - { path: "src/a.js", content: "valid A", start_line: 1, end_line: 1 }, - { path: "src/b.js", content: "invalid B (line gone)", start_line: 99, end_line: 99 }, - { path: "src/c.js", content: "valid C", start_line: 3, end_line: 3 }, - ], - warnings: [], - }; - - const { github, outputs } = await run({ - result, - githubOpts: { - bulkErrorSpec: { message: "rate limited", status: 429, headers: { "retry-after": "1" } }, - perCommentError: (rc) => { - // b.js is invalid (422); a.js and c.js succeed. - if (commentKey(rc) === "src/b.js|-|99") { - return { status: 422, message: 'Unprocessable Entity: "Line could not be resolved"' }; - } - return null; - }, - }, - opts: { stickySummary: true }, - }); - - // batch (429) + 3 per-comment calls (a ok, b 422, c ok). - assert.strictEqual(github.createReviewCalls.length, 4, "batch + 3 per-comment attempts"); - assert.strictEqual(outputs.comments_inline, "2", "a and c posted"); - assert.strictEqual(outputs.comments_failed, "1", "b failed (invalid content)"); - // Fix B: a pure 429 never reached the server, so the idempotency reads must - // be skipped entirely (no listReviews / listReviewComments). - assert.strictEqual(github.listReviewsCalls.length, 0, "429 batch skips listReviews idempotency read"); - assert.strictEqual(github.listReviewCommentsCalls.length, 0, "no per-comment idempotency reads (422 non-retryable, successes need none)"); - // Summary surfaces ONLY the failed comment (in the finalized body). - assert.strictEqual(github.issueComments.length, 1, "anchor created"); - assert.strictEqual(github.updatedComments.length, 1, "anchor finalized"); - const body = github.updatedComments[0].body; - assert.match(body, /invalid B/, "failed comment content appears in summary"); - assert.doesNotMatch(body, /valid A/, "successful comment not duplicated into summary"); - assert.doesNotMatch(body, /valid C/, "successful comment not duplicated into summary"); -} - -// P0-2: per-comment rate-limit with retries. One comment recovers after a -// retry (429 then success); another stays rate-limited until retries are -// exhausted. Requires OCR_MAX_RETRIES >= 1 (overridden locally). -async function testPerCommentRateLimitRetryThenSuccessAndExhausted() { - const result = { - comments: [ - { path: "src/a.js", content: "recovers after retry", start_line: 1, end_line: 1 }, - { path: "src/b.js", content: "always rate limited", start_line: 2, end_line: 2 }, - ], - warnings: [], - }; - - return withEnv({ OCR_MAX_RETRIES: "1" }, async () => { - const { github, outputs } = await run({ - result, - githubOpts: { - bulkErrorSpec: { message: "rate limited", status: 429 }, - perCommentError: (rc, attempt) => { - if (commentKey(rc) === "src/a.js|-|1") { - // a.js: 429 on attempt 0, success on attempt 1. - return attempt === 0 ? { status: 429, message: "rate limited" } : null; - } - // b.js: always 429 -> retries exhausted -> failed. - return { status: 429, message: "rate limited" }; - }, - }, - opts: { stickySummary: true }, - }); - - // batch + a(2 attempts: 429 then ok) + b(2 attempts: 429, 429 exhausted). - assert.strictEqual(github.createReviewCalls.length, 5, "batch + a(2) + b(2)"); - assert.strictEqual(outputs.comments_inline, "1", "a recovered via retry"); - assert.strictEqual(outputs.comments_failed, "1", "b exhausted all retries"); - }); -} - -// P0-3: batch 5xx but the batch LANDED on the server. The batch-level -// idempotency check finds some comments already posted; the MISSING ones are -// retried per-comment, where one fails with 422 (invalid content). Verifies -// batch-level dedup and per-comment failure compose without double-counting. -async function testBatchLandedWithPerCommentPartialInvalid() { - const result = { - comments: [ - { path: "src/a.js", content: "already landed A", start_line: 1, end_line: 1 }, - { path: "src/b.js", content: "already landed B", start_line: 2, end_line: 2 }, - { path: "src/c.js", content: "invalid C", start_line: 99, end_line: 99 }, - ], - warnings: [], - }; - - const { github, outputs } = await run({ - result, - githubOpts: { - bulkError: "Bad Gateway", - bulkErrorStatus: 502, - batchLanded: true, - echoPosted: true, - postedCount: 2, // a and b already on the server - perCommentError: (rc) => { - if (commentKey(rc) === "src/c.js|-|99") { - return { status: 422, message: 'Unprocessable Entity: "Line could not be resolved"' }; - } - return null; - }, - }, - opts: { stickySummary: true }, - }); - - // batch (502, landed) + only the 1 missing comment (c) retried, which 422s. - assert.strictEqual(github.createReviewCalls.length, 2, "batch + only missing c retried"); - assert.strictEqual(outputs.comments_inline, "2", "a,b recovered via batch-landing; c failed"); - assert.strictEqual(outputs.comments_failed, "1", "c invalid content"); -} - -// P0-4: the full four-state mix under a landed batch. Combines batch-level -// landing with per-comment: success, 422-invalid, 5xx-landed (recovered via -// idempotency), and 5xx-NOT-landed (failed). This is the most entangled -// intersection of all three scenarios. -async function testBatchLandedWithPerCommentMixedStates() { - const result = { - comments: [ - { path: "src/a.js", content: "batch-landed A", start_line: 1, end_line: 1 }, - { path: "src/b.js", content: "success B", start_line: 2, end_line: 2 }, - { path: "src/c.js", content: "invalid C", start_line: 99, end_line: 99 }, - { path: "src/d.js", content: "5xx landed D", start_line: 4, end_line: 4 }, - { path: "src/e.js", content: "5xx not landed E", start_line: 5, end_line: 5 }, - ], - warnings: [], - }; - - const landedKeys = new Set(["src/d.js|-|4"]); - const { outputs } = await run({ - result, - githubOpts: { - bulkError: "Bad Gateway", - bulkErrorStatus: 502, - batchLanded: true, - echoPosted: true, - postedCount: 1, // only a batch-landed - landedKeys, // d lands despite its per-comment 502 - perCommentError: (rc) => { - const key = commentKey(rc); - if (key === "src/c.js|-|99") return { status: 422, message: "Line could not be resolved" }; - if (key === "src/d.js|-|4") return { status: 502, message: "Bad Gateway" }; - if (key === "src/e.js|-|5") return { status: 502, message: "Bad Gateway" }; - return null; // b succeeds - }, - }, - opts: { stickySummary: true }, - }); - - // a(batch-landed) + b(success) + d(5xx-landed) = 3 successes; - // c(422) + e(5xx-not-landed) = 2 failures. - assert.strictEqual(outputs.comments_inline, "3", "a+b+d succeed across three different recovery paths"); - assert.strictEqual(outputs.comments_failed, "2", "c(422) + e(5xx not landed) fail"); -} - -// P1: a network-layer error (no HTTP status) is treated as "maybe reached the -// server", so the idempotency check runs. A comment that landed is recovered; -// one that did not is recorded as failed (no blind retry that would duplicate). -async function testNetworkErrorLandedRecoveredAndNotLandedFailed() { - const result = { - comments: [ - { path: "src/a.js", content: "net landed", start_line: 1, end_line: 1 }, - { path: "src/b.js", content: "net not landed", start_line: 2, end_line: 2 }, - ], - warnings: [], - }; - - const landedKeys = new Set(["src/a.js|-|1"]); - const { outputs } = await run({ - result, - githubOpts: { - bulkError: "Bad Gateway", - bulkErrorStatus: 502, - landedKeys, - // status omitted -> typeof status !== "number" && status == null -> - // maybeReachedServer=true -> idempotency check decides. - perCommentError: () => ({ message: "ECONNRESET" }), - }, - opts: { stickySummary: true }, - }); - - assert.strictEqual(outputs.comments_inline, "1", "a recovered (landed) via idempotency check"); - assert.strictEqual(outputs.comments_failed, "1", "b not landed -> failed, no blind retry"); -} - -// P1: the batch-level idempotency check itself throws (listReviews -// unavailable). The code degrades to the original fallback (retry ALL -// comments, accepting duplicate risk) rather than aborting. -async function testBatchIdempotencyCheckFailureDegradesToFullRetry() { - const result = { - comments: [ - { path: "src/a.js", content: "A", start_line: 1, end_line: 1 }, - { path: "src/b.js", content: "B", start_line: 2, end_line: 2 }, - ], - warnings: [], - }; - - const { github, outputs } = await run({ - result, - githubOpts: { - bulkError: "Bad Gateway", - bulkErrorStatus: 502, - listReviewsThrow: true, // findExistingBatchReview fails -> degrade - perCommentError: () => null, // all per-comment succeed - }, - opts: { stickySummary: true }, - }); - - // Degrade retries ALL (no filtering) -> batch + 2 per-comment. - assert.strictEqual(github.createReviewCalls.length, 3, "degraded to full retry of all comments"); - assert.strictEqual(outputs.comments_inline, "2"); - assert.strictEqual(outputs.comments_failed, "0"); -} - -// P1 (smoke): low remaining quota on a per-comment success triggers the -// proactive throttle branch. We cannot spy on the internal sleep, so this -// only verifies the branch executes without breaking the flow or counts. -async function testLowQuotaProactiveThrottleDoesNotBreakFlow() { - return withEnv({ OCR_LOW_REMAINING_THRESHOLD: "3" }, async () => { - const result = { comments: [{ path: "src/a.js", content: "A", start_line: 1, end_line: 1 }], warnings: [] }; - const { outputs } = await run({ - result, - githubOpts: { - bulkError: "rate limited", - bulkErrorStatus: 429, // force the per-comment fallback path - successRemaining: 2, // <= threshold -> low-quota branch - perCommentError: () => null, - }, - opts: { stickySummary: true }, - }); - assert.strictEqual(outputs.comments_inline, "1", "low-quota throttle does not impede success"); - }); -} - -// Fix B (focused): a pure rate-limit (429) on the batch means the request never -// reached the server, so the batch did not land. The idempotency reads -// (listReviews / listReviewComments) must be SKIPPED entirely — querying would -// be pointless and would pressure the API during an ongoing rate-limit episode. -// The batch rate-limit cooldown still runs before the per-comment retry. -async function testBatchRateLimitSkipsIdempotencyReads() { - const result = { - comments: [{ path: "src/a.js", content: "A", start_line: 1, end_line: 1 }], - warnings: [], - }; - - const { github, outputs } = await run({ - result, - githubOpts: { - bulkErrorSpec: { message: "rate limited", status: 429, headers: { "retry-after": "1" } }, - perCommentError: () => null, // per-comment succeeds - }, - opts: { stickySummary: true }, - }); - - assert.strictEqual(github.listReviewsCalls.length, 0, "listReviews not called (429 never reached server)"); - assert.strictEqual(github.listReviewCommentsCalls.length, 0, "listReviewComments not called"); - assert.strictEqual(github.createReviewCalls.length, 2, "batch + 1 per-comment"); - assert.strictEqual(outputs.comments_inline, "1"); -} - -// Fix A + read self-protection: a 5xx batch MAY have landed, so the idempotency -// read runs — but only AFTER cooling down. The read itself can also hit a -// rate-limit; withRetry (wrapping readWithPacing) must back off and recover so -// the batch-landing detection still works. Requires OCR_MAX_RETRIES >= 1. -async function testBatchReadRateLimitRetriedViaWithRetry() { - const result = { - comments: [ - { path: "src/a.js", content: "A", start_line: 1, end_line: 1 }, - { path: "src/b.js", content: "B", start_line: 2, end_line: 2 }, - ], - warnings: [], - }; - - return withEnv({ OCR_MAX_RETRIES: "1" }, async () => { - const { github, outputs } = await run({ - result, - githubOpts: { - bulkError: "Bad Gateway", - bulkErrorStatus: 502, - batchLanded: true, - echoPosted: true, - postedCount: 2, // both comments already on the server - // The idempotency read (listReviews) itself is rate-limited once, then - // succeeds: withRetry must honor retry-after and recover. - listReviewsErrorSeq: [ - { status: 429, message: "rate limited", headers: { "retry-after": "1" } }, - ], - }, - opts: { stickySummary: true }, - }); - - // listReviews: 1st call 429, 2nd call success -> read recovered via retry. - assert.strictEqual(github.listReviewsCalls.length, 2, "read retried after its own 429"); - assert.strictEqual(outputs.comments_inline, "2", "both recovered as already-posted"); - assert.strictEqual(outputs.comments_failed, "0"); - assert.strictEqual(github.createReviewCalls.length, 1, "no per-comment retry (all already posted)"); - }); -} - -// ---- Pure helper unit tests ---- - -function testSafeFenceAndFencedBlock() { - assert.strictEqual(safeFence("plain"), "```"); - // single backticks -> maxTicks=1 -> max(3, 2) = 3 - assert.strictEqual(safeFence("a `backtick` here"), "```"); - // 5 backticks -> maxTicks=5 -> 6 - assert.strictEqual(safeFence("`````"), "``````"); - const block = fencedBlock("```js\nx\n```"); - assert.ok(block.startsWith("````")); - assert.ok(block.endsWith("````")); -} - -function testLineSpan() { - assert.deepStrictEqual(lineSpan({ line: 10, start_line: 5 }), { start: 5, end: 10, multiline: true }); - assert.deepStrictEqual(lineSpan({ line: 7 }), { start: 7, end: 7, multiline: false }); - assert.deepStrictEqual(lineSpan({ start_line: 3 }), { start: 3, end: 3, multiline: false }); - // start_line === line collapses to a single-line span. - assert.deepStrictEqual(lineSpan({ line: 9, start_line: 9 }), { start: 9, end: 9, multiline: false }); - assert.strictEqual(lineSpan({}), null); - // Invalid line numbers (0, negative, NaN) are dropped by num(); a span with - // no usable line resolves to null. - assert.strictEqual(lineSpan({ line: 0 }), null); - assert.strictEqual(lineSpan({ line: -3 }), null); - assert.strictEqual(lineSpan({ line: NaN }), null); - // An invalid start_line but valid line degrades to a single-line span. - assert.deepStrictEqual(lineSpan({ line: 5, start_line: 0 }), { start: 5, end: 5, multiline: false }); - assert.deepStrictEqual(lineSpan({ line: 5, start_line: -1 }), { start: 5, end: 5, multiline: false }); - // Reversed order (start_line > line) is normalized via min/max. - assert.deepStrictEqual(lineSpan({ line: 3, start_line: 8 }), { start: 3, end: 8, multiline: true }); -} - -function testSameCommentSpan() { - const sl = (n) => ({ start: n, end: n, multiline: false }); - const ml = (a, b) => ({ start: a, end: b, multiline: true }); - // Rule 1: single vs multi never match. - assert.strictEqual(sameCommentSpan(sl(9), ml(8, 10), 0.6), false); - assert.strictEqual(sameCommentSpan(ml(8, 10), sl(9), 0.6), false); - // Rule 2: single-line, same line matches; different line does not. - assert.strictEqual(sameCommentSpan(sl(9), sl(9), 0.6), true); - assert.strictEqual(sameCommentSpan(sl(9), sl(10), 0.6), false); - // Rule 3: multi-line IoU. [8,10] vs [9,11] => overlap 2 / union 4 = 0.5. - assert.strictEqual(sameCommentSpan(ml(8, 10), ml(9, 11), 0.6), false); - assert.strictEqual(sameCommentSpan(ml(8, 10), ml(9, 11), 0.4), true); - // [8,10] vs [8,9] => overlap 2 / union 3 ~= 0.67. - assert.strictEqual(sameCommentSpan(ml(8, 10), ml(8, 9), 0.6), true); - // Identical spans => IoU 1. - assert.strictEqual(sameCommentSpan(ml(8, 10), ml(8, 10), 0.6), true); - // Disjoint multi-line spans never match. - assert.strictEqual(sameCommentSpan(ml(1, 3), ml(8, 10), 0.6), false); - // IoU comparison is strict: exactly at the threshold is NOT a match. - // [8,10] vs [9,11] => IoU 0.5; threshold 0.5 => 0.5 > 0.5 is false. - assert.strictEqual(sameCommentSpan(ml(8, 10), ml(9, 11), 0.5), false); - // Single-line matching (rule 2) ignores threshold entirely: same line still - // matches even at threshold = 1. - assert.strictEqual(sameCommentSpan(sl(9), sl(9), 1), true); - // threshold = 1 is unreachable for multi-line under strict >: even identical - // spans (IoU 1) do not satisfy 1 > 1, so nothing ever matches. Locks the - // strict-> semantics. - assert.strictEqual(sameCommentSpan(ml(8, 10), ml(8, 10), 1), false); -} - -function testResolveThreshold() { - // Valid values in (0, 1] pass through unchanged. - assert.strictEqual(resolveThreshold(0.6), 0.6); - assert.strictEqual(resolveThreshold(0.5), 0.5); - assert.strictEqual(resolveThreshold(1), 1); - // Numeric strings are accepted (mirrors parseFloat(action input)). - assert.strictEqual(resolveThreshold("0.4"), 0.4); - // Out-of-range values fall back to the default. - assert.strictEqual(resolveThreshold(0), DEFAULT_OVERLAP_THRESHOLD); - assert.strictEqual(resolveThreshold(-0.5), DEFAULT_OVERLAP_THRESHOLD); - assert.strictEqual(resolveThreshold(1.5), DEFAULT_OVERLAP_THRESHOLD); - // Non-numeric / missing values fall back to the default. - assert.strictEqual(resolveThreshold(NaN), DEFAULT_OVERLAP_THRESHOLD); - assert.strictEqual(resolveThreshold("abc"), DEFAULT_OVERLAP_THRESHOLD); - assert.strictEqual(resolveThreshold(undefined), DEFAULT_OVERLAP_THRESHOLD); - assert.strictEqual(resolveThreshold(null), DEFAULT_OVERLAP_THRESHOLD); -} - -function testOverlapsHistory() { - // Rule 2: single-line, same line => overlap; different line => no overlap. - const sl = [{ path: "a.js", line: 9, side: "RIGHT" }]; - assert.strictEqual(overlapsHistory({ path: "a.js", line: 9, start_line: 9, side: "RIGHT" }, sl), true); - assert.strictEqual(overlapsHistory({ path: "a.js", line: 20, start_line: 20, side: "RIGHT" }, sl), false); - // Rule 1: single-line vs multi-line never overlap. - const ml = [{ path: "a.js", line: 10, start_line: 8, side: "RIGHT" }]; - assert.strictEqual(overlapsHistory({ path: "a.js", line: 9, start_line: 9, side: "RIGHT" }, ml), false); - // Rule 3: multi-line IoU vs default threshold 0.6. - assert.strictEqual(overlapsHistory({ path: "a.js", line: 10, start_line: 8, side: "RIGHT" }, ml), true); - assert.strictEqual(overlapsHistory({ path: "a.js", line: 11, start_line: 9, side: "RIGHT" }, ml), false); - assert.strictEqual(overlapsHistory({ path: "a.js", line: 9, start_line: 8, side: "RIGHT" }, ml), true); - // Threshold argument lowers the bar (IoU 0.5 > 0.4). - assert.strictEqual(overlapsHistory({ path: "a.js", line: 11, start_line: 9, side: "RIGHT" }, ml, 0.4), true); - // Different path and LEFT-side history are still ignored. - assert.strictEqual(overlapsHistory({ path: "b.js", line: 10, start_line: 8, side: "RIGHT" }, ml), false); - const leftHist = [{ path: "a.js", line: 10, start_line: 8, side: "LEFT" }]; - assert.strictEqual(overlapsHistory({ path: "a.js", line: 10, start_line: 8, side: "RIGHT" }, leftHist), false); - // An unresolvable current comment (no usable line) never overlaps. - assert.strictEqual(overlapsHistory({ path: "a.js", side: "RIGHT" }, ml), false); - // Unresolvable history entries are skipped, not fatal: a later valid entry - // on the same path can still match. - const mixedHist = [ - { path: "a.js", side: "RIGHT" }, // no line info -> lineSpan null - { path: "a.js", line: 9, side: "RIGHT" }, // single-line 9 - ]; - assert.strictEqual(overlapsHistory({ path: "a.js", line: 9, start_line: 9, side: "RIGHT" }, mixedHist), true); - // Any-of semantics: multiple history entries, a match on any one wins. - const multiHist = [ - { path: "a.js", line: 5, start_line: 5, side: "RIGHT" }, // no match - { path: "a.js", line: 10, start_line: 8, side: "RIGHT" }, // matches [8,10] - ]; - assert.strictEqual(overlapsHistory({ path: "a.js", line: 10, start_line: 8, side: "RIGHT" }, multiHist), true); - // A history entry with no side field still participates (falsy side bypasses - // the RIGHT-only guard). - const noSideHist = [{ path: "a.js", line: 9 }]; - assert.strictEqual(overlapsHistory({ path: "a.js", line: 9, start_line: 9, side: "RIGHT" }, noSideHist), true); - // An invalid threshold falls back to the default (IoU 0.5 < 0.6 -> no match). - assert.strictEqual(overlapsHistory({ path: "a.js", line: 11, start_line: 9, side: "RIGHT" }, ml, "garbage"), false); -} - async function main() { - await testFailedInlineCommentsAreSummarized(); - await testWarningsListedAfterSummaryComments(); - await testErrorCommentUsesSafeFence(); - await testStickyUpdatesExistingSummary(); - await testNonStickyCreatesNewCommentOnFallback(); - await testNonStickyFallbackAllSuccessStillPostsSummary(); - await testNoCommentsStickyUpdate(); - await testIncrementalSkipsOverlapping(); - await testIncrementalAllOverlapPostsNoReview(); - await testIncrementalMultiLineIoUDefaultThreshold(); - await testIncrementalOverlapThresholdPropagated(); - // Idempotency - await testBatchLandedRetriesOnlyMissingComments(); - await testPerComment5xxAlreadyPostedTreatedAsSuccess(); - await testPerComment5xxIdempotencyUnavailableSkipsRetry(); - await testSummaryDoesNotDuplicateWhenAlreadyPosted(); - await testSummaryAnchorCreatedBeforeReviewColdStart(); - await testSummaryAnchorCreatedBeforeReviewNonSticky(); - await testGetPostedCommentIdsExtractsEmbeddedIds(); - // Rate-limit strategy (pure function) - testComputeRetryDelayMs(); - // Cross-scenario: rate-limit x partial-invalid x landed - await testBatchRateLimitWithPartialInvalidContent(); - await testPerCommentRateLimitRetryThenSuccessAndExhausted(); - await testBatchLandedWithPerCommentPartialInvalid(); - await testBatchLandedWithPerCommentMixedStates(); - await testNetworkErrorLandedRecoveredAndNotLandedFailed(); - await testBatchIdempotencyCheckFailureDegradesToFullRetry(); - await testLowQuotaProactiveThrottleDoesNotBreakFlow(); - await testBatchRateLimitSkipsIdempotencyReads(); - await testBatchReadRateLimitRetriedViaWithRetry(); - // Pure helpers - testSafeFenceAndFencedBlock(); - testFormatWarnings(); - testLineSpan(); - testSameCommentSpan(); - testResolveThreshold(); - testOverlapsHistory(); - testNewCommentIdFormat(); - console.log("All post-review-comments tests passed."); + for (const workflowPath of workflowFiles) { + await testFailedInlineCommentsAreSummarized(workflowPath); + await testErrorCommentUsesSafeFence(workflowPath); + } } main().catch((err) => {