diff --git a/.claude/commands/open-code-review.md b/.claude/commands/open-code-review.md index a58cb58..f7339cb 100644 --- a/.claude/commands/open-code-review.md +++ b/.claude/commands/open-code-review.md @@ -15,6 +15,7 @@ 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 31557d5..ac1420e 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.4 + image: golang:1.26.5 steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/ocr-review.yml b/.github/workflows/ocr-review.yml index 9abcdf8..1bb8775 100644 --- a/.github/workflows/ocr-review.yml +++ b/.github/workflows/ocr-review.yml @@ -1,58 +1,19 @@ # OpenCodeReview - GitHub Actions PR Auto-Review Pipeline # -# This workflow automatically reviews pull requests using OpenCodeReview -# and posts review comments directly on the PR. +# Reviews pull requests using the reusable composite action defined in +# action.yml at the repo root. See that file for the full list of inputs, +# outputs, and implementation details (retry, idempotency, artifact upload, etc.). # # Triggers: # - PR opened (uses pull_request_target for fork secret access) -# - Comment on PR containing '/open-code-review' or '@open-code-review' # -# Required secrets: -# OCR_LLM_URL - LLM API endpoint (e.g., https://api.openai.com/v1/chat/completions) -# OCR_LLM_AUTH_TOKEN - Authentication token for the LLM API -# -# Optional secrets: -# OCR_LLM_MODEL - Model name (default: gpt-4o) -# OCR_LLM_USE_ANTHROPIC - Set to 'true' if using Anthropic Claude models -# -# Optional variables (for retry/delay tuning): -# The retry strategy follows GitHub's documented guidance for REST API rate limits: -# https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api -# - Primary rate limit exhausted (x-ratelimit-remaining=0): wait until x-ratelimit-reset. -# - Secondary rate limit with a retry-after header: wait exactly that long. -# - Secondary rate limit with NO header: wait at least one minute, then use -# exponential backoff on continued failures. -# -# OCR_RETRY_BASE_DELAY - Base delay (ms) for exponential backoff when no retry -# header is present (default: 60000, per GitHub's -# "at least one minute" recommendation for secondary limits). -# OCR_RETRY_MAX_DELAY - Maximum delay (ms) cap applied to EVERY computed wait, -# including retry-after and x-ratelimit-reset, so a far-future -# reset cannot stall the job past its timeout (default: 300000 = 5 min). -# OCR_MAX_RETRIES - Max retry attempts per comment when rate-limited (default: 3). -# OCR_SUCCESS_DELAY - Delay (ms) between successful comment posts to pace requests (default: 2000). -# OCR_FAILURE_DELAY - Delay (ms) after a non-retryable failure to pace subsequent requests (default: 1000). -# OCR_LOW_REMAINING_THRESHOLD - When x-ratelimit-remaining is at or below this value, -# proactively increase request spacing to avoid hitting the limit -# (default: 3; GitHub best practice is to watch the header and slow down). -# OCR_LOW_REMAINING_SPACING - Request spacing (ms) used when remaining quota is low -# (default: 10000 = 10s). -# OCR_READ_SUCCESS_DELAY - Delay (ms) after a successful read API call (listReviews / -# listReviewComments / listIssueComments) used for the -# idempotency check. Reads are cheaper than writes, so the -# default is shorter (default: 500). -# OCR_READ_LOW_REMAINING_SPACING - Request spacing (ms) for read calls when remaining -# quota is low (default: 5000 = 5s). -# -# Idempotency: -# When the batch createReview fails with a 5xx, the request may still have landed on -# the server. Before retrying per-comment, the workflow queries existing reviews and -# review comments (tagged with a per-run HTML comment) and only retries the comments -# that are actually missing. This prevents duplicate review posts. +# Required secrets (mapped to action inputs): +# OCR_LLM_URL - LLM API endpoint (e.g., https://api.openai.com/v1/chat/completions) +# OCR_LLM_AUTH_TOKEN - Authentication token for the LLM API +# OCR_LLM_MODEL - Model name +# OCR_LLM_USE_ANTHROPIC - 'true' for Anthropic Claude, 'false' for OpenAI-compatible # # Note: GITHUB_TOKEN is automatically provided by GitHub Actions. -# Note: The workflow also configures llm.extra_body to '{"thinking": {"type": "disabled"}}' -# to disable thinking mode for compatibility with various LLM providers. name: OpenCodeReview PR Review @@ -74,805 +35,30 @@ permissions: jobs: code-review: runs-on: self-hosted + timeout-minutes: 30 container: image: node:24 if: github.event_name == 'pull_request_target' steps: - - name: Checkout repository + # Materialize action.yml + scripts/ into the workspace so the local + # `uses: ./` action below can be resolved and loaded. For + # pull_request_target this checks out the trusted base branch; the + # composite action performs its own full checkout (fetch-depth: 0) later. + - name: Checkout uses: actions/checkout@v4 - with: - fetch-depth: 0 # Full history needed for merge-base diff - ref: ${{ github.event.pull_request.head.sha }} - - name: Mark repository as safe directory + - name: Trust workspace run: git config --global --replace-all safe.directory '*' - - name: Fetch PR head ref (ensures fork commits are available) - run: git fetch origin pull/${{ github.event.pull_request.number }}/head - - - name: Install OpenCodeReview - run: npm install -g @alibaba-group/open-code-review - - - name: Configure OCR - run: | - ocr config set llm.url ${{ secrets.OCR_LLM_URL }} - ocr config set llm.auth_token ${{ secrets.OCR_LLM_AUTH_TOKEN }} - ocr config set llm.model ${{ secrets.OCR_LLM_MODEL }} - ocr config set llm.use_anthropic ${{ secrets.OCR_LLM_USE_ANTHROPIC }} - ocr config set llm.extra_body '{"enable_thinking": false}' - ocr config set language English - - name: Run OpenCodeReview - id: review - run: | - BASE_REF="${{ github.event.pull_request.base.ref }}" - HEAD_SHA="${{ github.event.pull_request.head.sha }}" - - echo "Reviewing PR: ${HEAD_SHA} against origin/${BASE_REF}" - - # Run OCR in range mode with JSON output - ocr review \ - --from "origin/${BASE_REF}" \ - --to "${HEAD_SHA}" \ - --format json \ - > /tmp/ocr-result.json 2>/tmp/ocr-stderr.log || true - - echo "OCR review completed. Output:" - cat /tmp/ocr-result.json - echo "OCR review completed. Error log:" - cat /tmp/ocr-stderr.log - - - name: Post review comments to PR - uses: actions/github-script@v7 + uses: ./ with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const fs = require('fs'); - const crypto = require('crypto'); - const path = '/tmp/ocr-result.json'; - - // Unique tag for this workflow run + attempt. Embedded in review/comment - // bodies as an HTML comment so the idempotency check can detect whether - // a batch createReview actually landed on the server before retrying. - // context.runId / context.runAttempt are numbers from @actions/github's - // Context (parsed from GITHUB_RUN_ID / GITHUB_RUN_ATTEMPT). Use - // Number.isFinite to guard against NaN when the env vars are missing, - // falling back to safe defaults. - const runId = Number.isFinite(context.runId) ? context.runId : 0; - const runAttempt = Number.isFinite(context.runAttempt) ? context.runAttempt : 1; - const RUN_TAG = `${runId}-${runAttempt}`; - const REVIEW_TAG = ``; - 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)); - } + 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' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 640c7a8..de4de5e 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.4 + image: golang:1.26.5 strategy: matrix: include: diff --git a/README.ja-JP.md b/README.ja-JP.md index c6bb4e2..d03931c 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -13,7 +13,6 @@

npm Build status - Go Report Card License Ask DeepWiki OpenSSF Best Practices @@ -108,6 +107,18 @@ 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): @@ -269,6 +280,10 @@ 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 # ディレクトリまたは特定のファイルをスキャン @@ -419,6 +434,21 @@ JSON出力ではこの2つのフィールドは`content`や`start_line`などと - [`gitlab_ci/`](./examples/gitlab_ci/) — GitLab CI統合の例 - [`gitflic_ci/`](./examples/gitflic_ci/) — GitFlic CI統合の例 +#### GitHub Action + +GitHub 向けに、本リポジトリはリポジトリルートにすぐ使える composite Action([`action.yml`](./action.yml))を同梱しています。自分で `ocr review` をスクリプト化する代わりに、これを直接参照するだけで、checkout、OCR のインストール、レビューの実行、インラインコメントとサマリーコメントの投稿、アーティファクトのアップロード、再試行・冪等性までの全パイプラインを処理できます: + +```yaml +- uses: alibaba/open-code-review@main + with: + llm_url: ${{ secrets.OCR_LLM_URL }} + llm_auth_token: ${{ secrets.OCR_LLM_AUTH_TOKEN }} + llm_model: ${{ vars.OCR_LLM_MODEL }} + llm_use_anthropic: ${{ vars.OCR_LLM_USE_ANTHROPIC }} +``` + +再現性を高めるため、バージョンタグまたはコミット SHA に固定してください。完全なワークフローデモ、inputs/outputs の全一覧、コメント投稿モード(スティッキーサマリー、非破壊的なインクリメンタル投稿)については [`examples/github_actions/`](./examples/github_actions/) ディレクトリを参照してください。 + ## コマンド | コマンド | エイリアス | 説明 | @@ -432,6 +462,8 @@ JSON出力ではこの2つのフィールドは`content`や`start_line`などと | `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` | — | バージョン情報を表示 | @@ -445,17 +477,54 @@ JSON出力ではこの2つのフィールドは`content`や`start_line`などと | `--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` を尊重するファイルシステムウォークにフォールバック)。 @@ -501,6 +570,12 @@ 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 @@ -508,6 +583,12 @@ 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 @@ -762,13 +843,22 @@ 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 74c67d9..d5ed2d8 100644 --- a/README.ko-KR.md +++ b/README.ko-KR.md @@ -13,7 +13,6 @@

npm Build status - Go Report Card License Ask DeepWiki OpenSSF Best Practices @@ -108,6 +107,18 @@ 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): @@ -269,6 +280,10 @@ 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 # 디렉터리 또는 특정 파일 스캔 @@ -419,6 +434,21 @@ JSON 출력에서 두 field는 `content`, `start_line` 등과 같은 수준의 s - [`gitlab_ci/`](./examples/gitlab_ci/): GitLab CI 통합 예시 - [`gitflic_ci/`](./examples/gitflic_ci/): GitFlic CI 통합 예시 +#### GitHub Action + +GitHub의 경우, 이 리포지터리는 루트에 바로 사용할 수 있는 composite Action([`action.yml`](./action.yml))을 제공합니다. 직접 `ocr review` 스크립트를 작성하는 대신 이를 참조하기만 하면 전체 파이프라인 — checkout, OCR 설치, review 실행, inline/summary comment 게시, artifact 업로드, 재시도 및 멱등성 — 을 모두 처리합니다: + +```yaml +- uses: alibaba/open-code-review@main + with: + llm_url: ${{ secrets.OCR_LLM_URL }} + llm_auth_token: ${{ secrets.OCR_LLM_AUTH_TOKEN }} + llm_model: ${{ vars.OCR_LLM_MODEL }} + llm_use_anthropic: ${{ vars.OCR_LLM_USE_ANTHROPIC }} +``` + +재현성을 위해 version tag나 commit SHA에 고정하세요. 전체 workflow 데모와 inputs/outputs, comment 게시 모드(sticky summary, incremental non-destructive posting)의 전체 목록은 [`examples/github_actions/`](./examples/github_actions/) 디렉터리를 참고하세요. + ## Commands | Command | Alias | Description | @@ -432,6 +462,8 @@ JSON 출력에서 두 field는 `content`, `start_line` 등과 같은 수준의 s | `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 정보 표시 | @@ -445,17 +477,54 @@ JSON 출력에서 두 field는 `content`, `start_line` 등과 같은 수준의 s | `--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`를 따르는 파일 시스템 탐색으로 폴백). @@ -501,6 +570,12 @@ 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 @@ -508,6 +583,12 @@ 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 @@ -719,13 +800,22 @@ 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 ba42e2d..3ed3d78 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,6 @@

npm Build status - Go Report Card License Ask DeepWiki OpenSSF Best Practices @@ -108,6 +107,18 @@ 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): @@ -269,6 +280,10 @@ 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 @@ -421,6 +436,21 @@ See the [`examples/`](./examples/) directory for integration examples: - [`gitlab_ci/`](./examples/gitlab_ci/) — GitLab CI integration example - [`gitflic_ci/`](./examples/gitflic_ci/) — GitFlic CI integration example +#### GitHub Action + +For GitHub, this repository also ships a ready-to-use composite Action at the repo root ([`action.yml`](./action.yml)). Instead of scripting `ocr review` yourself, reference it directly and it handles the full pipeline — checkout, OCR install, running the review, posting inline and summary comments, uploading artifacts, and retry/idempotency: + +```yaml +- uses: alibaba/open-code-review@main + with: + llm_url: ${{ secrets.OCR_LLM_URL }} + llm_auth_token: ${{ secrets.OCR_LLM_AUTH_TOKEN }} + llm_model: ${{ vars.OCR_LLM_MODEL }} + llm_use_anthropic: ${{ vars.OCR_LLM_USE_ANTHROPIC }} +``` + +Pin to a version tag or commit SHA for reproducibility. See the [`examples/github_actions/`](./examples/github_actions/) directory for a complete workflow demo and the full list of inputs, outputs, and comment-posting modes (sticky summary, incremental non-destructive posting). + ## Commands | Command | Alias | Description | @@ -434,6 +464,8 @@ See the [`examples/`](./examples/) directory for integration examples: | `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 | @@ -447,16 +479,55 @@ See the [`examples/`](./examples/) directory for integration examples: | `--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` | — | built-in | Max concurrent git subprocesses | -| `--tools` | — | — | Path to custom JSON tools config | +| `--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 | ### `ocr scan` Flags @@ -506,6 +577,12 @@ 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 @@ -513,6 +590,12 @@ 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 @@ -767,13 +850,23 @@ 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 -See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, coding guidelines, and how to submit pull requests. +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. -## 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 6535ce5..c1310fa 100644 --- a/README.ru-RU.md +++ b/README.ru-RU.md @@ -13,7 +13,6 @@

npm Build status - Go Report Card License Ask DeepWiki OpenSSF Best Practices @@ -108,6 +107,18 @@ 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): @@ -269,6 +280,10 @@ 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 # сканировать каталог или конкретные файлы @@ -421,6 +436,21 @@ ocr review \ - [`gitlab_ci/`](./examples/gitlab_ci/) — пример интеграции с GitLab CI - [`gitflic_ci/`](./examples/gitflic_ci/) — пример интеграции с GitFlic CI +#### GitHub Action + +Для GitHub в корне репозитория также поставляется готовая к использованию composite Action ([`action.yml`](./action.yml)). Вместо того чтобы вручную скриптовать `ocr review`, просто подключите её — она берёт на себя весь конвейер: checkout, установку OCR, запуск ревью, публикацию инлайн- и сводных комментариев, загрузку артефактов, а также повтор и идемпотентность: + +```yaml +- uses: alibaba/open-code-review@main + with: + llm_url: ${{ secrets.OCR_LLM_URL }} + llm_auth_token: ${{ secrets.OCR_LLM_AUTH_TOKEN }} + llm_model: ${{ vars.OCR_LLM_MODEL }} + llm_use_anthropic: ${{ vars.OCR_LLM_USE_ANTHROPIC }} +``` + +Для воспроизводимости зафиксируйте тег версии или SHA коммита. Полный демо-воркфлоу, а также полный список входов, выходов и режимов публикации комментариев (закреплённая сводка, инкрементальная неразрушающая публикация) см. в каталоге [`examples/github_actions/`](./examples/github_actions/). + ## Команды | Команда | Алиас | Описание | @@ -434,6 +464,8 @@ 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` | — | Показать информацию о версии | @@ -447,17 +479,56 @@ 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`). @@ -503,6 +574,12 @@ 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 @@ -510,6 +587,12 @@ 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 @@ -764,13 +847,22 @@ 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 1f2c2e4..13ae792 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -13,7 +13,6 @@

npm Build status - Go Report Card License Ask DeepWiki OpenSSF Best Practices @@ -108,6 +107,18 @@ 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): @@ -269,6 +280,10 @@ 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 # 扫描指定目录或文件 @@ -419,6 +434,21 @@ ocr review \ - [`gitlab_ci/`](./examples/gitlab_ci/) — GitLab CI 集成示例 - [`gitflic_ci/`](./examples/gitflic_ci/) — GitFlic CI 集成示例 +#### GitHub Action + +对于 GitHub,本仓库还在仓库根目录提供了一个开箱即用的 composite Action([`action.yml`](./action.yml))。你无需自己编写 `ocr review` 脚本,直接引用它即可完成完整流程——checkout、安装 OCR、执行审查、发布行内评论与汇总评论、上传 artifacts,以及重试与幂等处理: + +```yaml +- uses: alibaba/open-code-review@main + with: + llm_url: ${{ secrets.OCR_LLM_URL }} + llm_auth_token: ${{ secrets.OCR_LLM_AUTH_TOKEN }} + llm_model: ${{ vars.OCR_LLM_MODEL }} + llm_use_anthropic: ${{ vars.OCR_LLM_USE_ANTHROPIC }} +``` + +为保障可复现性,请固定到某个版本标签或 commit SHA。完整的 workflow 示例以及 inputs、outputs 与评论发布模式(置顶汇总、增量非破坏式发布)的完整列表,请参见 [`examples/github_actions/`](./examples/github_actions/) 目录。 + ## 命令 | 命令 | 别名 | 描述 | @@ -432,6 +462,8 @@ 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` | — | 显示版本信息 | @@ -445,17 +477,53 @@ 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` 的文件系统遍历)。 @@ -501,6 +569,12 @@ 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 @@ -508,6 +582,12 @@ 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 @@ -752,13 +832,22 @@ 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 4fa8ada..07d0b38 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -19,6 +19,9 @@ 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). @@ -30,13 +33,15 @@ OpenCodeReview currently provides: PyCharm, and other JetBrains IDEs with the same capabilities as the existing VSCode extension. -### MCP Integration +### Delegate Mode -- **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. +- **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. ### Ultra Mode diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..203b2e2 --- /dev/null +++ b/action.yml @@ -0,0 +1,305 @@ +name: OpenCodeReview PR Review +description: >- + AI-powered GitHub PR review with inline comments, sticky summary, and + incremental non-destructive posting. +author: 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 new file mode 100644 index 0000000..2c14a5c --- /dev/null +++ b/cmd/opencodereview/background_file.go @@ -0,0 +1,135 @@ +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 new file mode 100644 index 0000000..cb18f21 --- /dev/null +++ b/cmd/opencodereview/background_file_test.go @@ -0,0 +1,312 @@ +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 4082ca2..f153fe2 100644 --- a/cmd/opencodereview/emit_run_result_test.go +++ b/cmd/opencodereview/emit_run_result_test.go @@ -7,6 +7,9 @@ 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" ) @@ -22,6 +25,8 @@ 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 } @@ -34,6 +39,8 @@ 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} @@ -80,6 +87,32 @@ 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() { @@ -93,6 +126,19 @@ 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}} @@ -175,3 +221,78 @@ 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 ccda515..1f72bb1 100644 --- a/cmd/opencodereview/flags.go +++ b/cmd/opencodereview/flags.go @@ -101,10 +101,12 @@ 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 @@ -125,12 +127,14 @@ 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") @@ -162,6 +166,9 @@ 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": @@ -203,6 +210,9 @@ 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 @@ -214,22 +224,29 @@ 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 - -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)`) + --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)`) } // --- config subcommand --- diff --git a/cmd/opencodereview/flags_test.go b/cmd/opencodereview/flags_test.go index 15701ca..8e25996 100644 --- a/cmd/opencodereview/flags_test.go +++ b/cmd/opencodereview/flags_test.go @@ -5,6 +5,20 @@ 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 { @@ -22,6 +36,23 @@ 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 0630a4c..4528b29 100644 --- a/cmd/opencodereview/git_test.go +++ b/cmd/opencodereview/git_test.go @@ -23,11 +23,17 @@ func initTestGitRepo(t *testing.T) string { } } f := filepath.Join(dir, "README.md") - os.WriteFile(f, []byte("hello"), 0o644) + if err := os.WriteFile(f, []byte("hello"), 0o644); err != nil { + t.Fatalf("write README: %v", err) + } cmd := exec.Command("git", "-C", dir, "add", ".") - cmd.CombinedOutput() + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git add: %v: %s", err, out) + } cmd = exec.Command("git", "-C", dir, "commit", "-m", "initial commit") - cmd.CombinedOutput() + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git commit: %v: %s", err, out) + } return dir } @@ -93,9 +99,18 @@ func TestResolveRepoDir_NotGitRepo(t *testing.T) { func TestResolveRepoDir_EmptyUsesWd(t *testing.T) { dir := initTestGitRepo(t) - origDir, _ := os.Getwd() - defer os.Chdir(origDir) - os.Chdir(dir) + 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) + } resolved, err := resolveRepoDir("") if err != nil { diff --git a/cmd/opencodereview/main.go b/cmd/opencodereview/main.go index fac044e..378e22e 100644 --- a/cmd/opencodereview/main.go +++ b/cmd/opencodereview/main.go @@ -56,6 +56,8 @@ 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 @@ -77,6 +79,7 @@ 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: @@ -89,6 +92,7 @@ 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. @@ -96,6 +100,7 @@ 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 c87b696..0006164 100644 --- a/cmd/opencodereview/output.go +++ b/cmd/opencodereview/output.go @@ -240,12 +240,15 @@ 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 { @@ -263,9 +266,10 @@ 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) error { + duration time.Duration, projectSummary string, toolCalls map[string]int64, traceID string, resumeInfo *agent.ResumeInfo, sessionID string) error { out := jsonOutput{ Status: "success", + TraceID: traceID, Comments: comments, Summary: &jsonSummary{ FilesReviewed: filesReviewed, @@ -278,6 +282,8 @@ 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 { @@ -311,9 +317,10 @@ func outputJSONWithWarnings(comments []model.LlmComment, warnings []agent.AgentW return enc.Encode(out) } -func outputJSONNoFiles() error { +func outputJSONNoFiles(traceID string) 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 d0d25b2..80520b2 100644 --- a/cmd/opencodereview/output_helpers_test.go +++ b/cmd/opencodereview/output_helpers_test.go @@ -168,9 +168,8 @@ 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) - - w.Close() + err := outputJSONWithWarnings(nil, warnings, 1, 10, 5, 15, 0, 0, time.Second, "", nil, "abc123trace", nil, "") + _ = w.Close() os.Stdout = old if err != nil { @@ -178,16 +177,21 @@ func TestOutputJSONWithWarnings_NoCommentsSubtaskError(t *testing.T) { } var buf bytes.Buffer - buf.ReadFrom(r) + _, _ = buf.ReadFrom(r) var out jsonOutput - json.Unmarshal(buf.Bytes(), &out) + if err := json.Unmarshal(buf.Bytes(), &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } 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) { @@ -222,7 +226,7 @@ func TestOutputJSON(t *testing.T) { } err := outputJSON(comments) - w.Close() + _ = w.Close() os.Stdout = old if err != nil { @@ -230,7 +234,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 { @@ -251,7 +255,7 @@ func TestOutputJSON_NoComments(t *testing.T) { err := outputJSON(nil) - w.Close() + _ = w.Close() os.Stdout = old if err != nil { @@ -259,10 +263,12 @@ func TestOutputJSON_NoComments(t *testing.T) { } var buf bytes.Buffer - buf.ReadFrom(r) + _, _ = buf.ReadFrom(r) var out jsonOutput - json.Unmarshal(buf.Bytes(), &out) + if err := json.Unmarshal(buf.Bytes(), &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } if out.Message == "" { t.Error("expected non-empty message when no comments") } @@ -275,9 +281,8 @@ 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}) - - w.Close() + 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() os.Stdout = old if err != nil { @@ -285,10 +290,12 @@ func TestOutputJSONWithWarnings(t *testing.T) { } var buf bytes.Buffer - buf.ReadFrom(r) + _, _ = buf.ReadFrom(r) var out jsonOutput - json.Unmarshal(buf.Bytes(), &out) + if err := json.Unmarshal(buf.Bytes(), &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } if out.Status != "completed_with_errors" { t.Errorf("status = %q, want completed_with_errors", out.Status) } @@ -301,6 +308,9 @@ 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) { @@ -309,9 +319,8 @@ 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) - - w.Close() + err := outputJSONWithWarnings(nil, warnings, 2, 50, 20, 70, 0, 0, time.Second, "", nil, "", nil, "") + _ = w.Close() os.Stdout = old if err != nil { @@ -319,10 +328,12 @@ func TestOutputJSONWithWarnings_NoCommentsNoErrors(t *testing.T) { } var buf bytes.Buffer - buf.ReadFrom(r) + _, _ = buf.ReadFrom(r) var out jsonOutput - json.Unmarshal(buf.Bytes(), &out) + if err := json.Unmarshal(buf.Bytes(), &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } if out.Status != "completed_with_warnings" { t.Errorf("status = %q, want completed_with_warnings", out.Status) } @@ -336,9 +347,9 @@ func TestOutputJSONNoFiles(t *testing.T) { r, w, _ := os.Pipe() os.Stdout = w - err := outputJSONNoFiles() + err := outputJSONNoFiles("test-trace-id-456") - w.Close() + _ = w.Close() os.Stdout = old if err != nil { @@ -346,13 +357,18 @@ func TestOutputJSONNoFiles(t *testing.T) { } var buf bytes.Buffer - buf.ReadFrom(r) + _, _ = buf.ReadFrom(r) var out jsonOutput - json.Unmarshal(buf.Bytes(), &out) + if err := json.Unmarshal(buf.Bytes(), &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } 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 { @@ -364,10 +380,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 c5b8487..038cd84 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›after", "beforeafter"}, - {"strips C1 OSC (U+009D)", "beforeafter", "beforeafter"}, + {"strips C1 CSI (U+009B)", "before\u009bafter", "beforeafter"}, + {"strips C1 OSC (U+009D)", "before\u009dafter", "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 e8d84db..d1f03bb 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 4c9de47..624bc50 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 []rune("abc") { + for _, ch := range "abc" { result, _ := m.Update(charKey(ch)) m = result.(providerTUIModel) } diff --git a/cmd/opencodereview/review_cmd.go b/cmd/opencodereview/review_cmd.go index 118d398..8379156 100644 --- a/cmd/opencodereview/review_cmd.go +++ b/cmd/opencodereview/review_cmd.go @@ -11,8 +11,11 @@ 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 { @@ -44,10 +47,26 @@ 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 @@ -81,6 +100,7 @@ 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, @@ -95,6 +115,7 @@ 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 @@ -104,17 +125,68 @@ 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 { - telemetry.SetAttr(span, "error", err.Error()) + 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) + } 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 3757e98..46353ab 100644 --- a/cmd/opencodereview/scan_cmd.go +++ b/cmd/opencodereview/scan_cmd.go @@ -3,6 +3,7 @@ package main import ( "context" "fmt" + "os" "strings" "time" @@ -11,6 +12,8 @@ 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 @@ -207,11 +210,22 @@ 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 { - telemetry.SetAttr(span, "error", err.Error()) + span.SetStatus(codes.Error, err.Error()) + span.RecordError(err) + if id := ag.SessionID(); id != "" { + fmt.Fprintf(os.Stderr, "[ocr] Session: %s\n", id) + } return fmt.Errorf("scan failed: %w", err) } diff --git a/cmd/opencodereview/session_cmd.go b/cmd/opencodereview/session_cmd.go new file mode 100644 index 0000000..805b8a5 --- /dev/null +++ b/cmd/opencodereview/session_cmd.go @@ -0,0 +1,299 @@ +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 new file mode 100644 index 0000000..1c32a71 --- /dev/null +++ b/cmd/opencodereview/session_cmd_test.go @@ -0,0 +1,170 @@ +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 eaacf83..c99c1b2 100644 --- a/cmd/opencodereview/shared.go +++ b/cmd/opencodereview/shared.go @@ -251,6 +251,14 @@ 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 @@ -276,8 +284,10 @@ func emitRunResult( telemetry.RecordCommentsGenerated(ctx, int64(len(comments))) } + traceID := telemetry.TraceIDFromContext(ctx) + if outputFormat == "json" && len(comments) == 0 && ag.FilesReviewed() == 0 { - return outputJSONNoFiles() + return outputJSONNoFiles(traceID) } // Agent-text audiences need stdout back before PrintTraceSummary so the @@ -293,10 +303,14 @@ 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()) + ag.ProjectSummary(), ag.ToolCalls(), traceID, resumeInfo, ag.SessionID()) } outputTextWithWarnings(comments, ag.Warnings()) if summary := ag.ProjectSummary(); summary != "" { diff --git a/cmd/opencodereview/shared_test.go b/cmd/opencodereview/shared_test.go index 21e7553..417a65f 100644 --- a/cmd/opencodereview/shared_test.go +++ b/cmd/opencodereview/shared_test.go @@ -79,9 +79,18 @@ func TestQuietHandle_IdempotentRestore(t *testing.T) { func TestResolveWorkingDir_CurrentDir(t *testing.T) { dir := t.TempDir() - origDir, _ := os.Getwd() - defer os.Chdir(origDir) - os.Chdir(dir) + 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) + } absPath, isGit, err := resolveWorkingDir("", false) if err != nil { diff --git a/examples/github_actions/README.md b/examples/github_actions/README.md index 468c601..0236505 100644 --- a/examples/github_actions/README.md +++ b/examples/github_actions/README.md @@ -1,6 +1,40 @@ -# OpenCodeReview - GitHub Actions Demo +# OpenCodeReview - GitHub Actions Workflow -This demo shows how to integrate OpenCodeReview into your GitHub Actions workflow to automatically review Pull Requests and post review comments. +This directory provides a ready-to-use GitHub Actions workflow demo that integrates OpenCodeReview into your repository to automatically review Pull Requests and post inline review comments. Copy it into `.github/workflows/` and configure the required secrets/vars. + +## Quick Start: `ocr-review.yml` + +The simplest adoption path: this demo delegates every step — checkout, OCR install, review, comment posting, artifact upload — to the official reusable composite action at [`action.yml`](../../action.yml) via a single `uses: alibaba/open-code-review@main` step. It covers both automatic PR review (`pull_request_target: opened/synchronize/reopened`) and on-demand re-review via comments (`/open-code-review` or `@open-code-review`). No inline scripts to maintain — `@main` always runs the latest action; pin to a version tag or commit SHA when reproducibility matters. + +```bash +mkdir -p .github/workflows +cp ocr-review.yml .github/workflows/ocr-review.yml +``` + +The core of the demo is a single action step: + +```yaml +- uses: alibaba/open-code-review@main + with: + llm_url: ${{ secrets.OCR_LLM_URL }} + llm_auth_token: ${{ secrets.OCR_LLM_AUTH_TOKEN }} + llm_model: ${{ vars.OCR_LLM_MODEL }} + llm_use_anthropic: ${{ vars.OCR_LLM_USE_ANTHROPIC }} +``` + +See [`action.yml`](../../action.yml) for the full list of inputs, outputs, security guidance, and the four comment-posting modes (sticky summary + incremental). + +## Running on a self-hosted runner + +The demo above runs on GitHub-hosted runners (`runs-on: ubuntu-latest`) and pulls the action from `alibaba/open-code-review@main`. If you prefer to run OCR on your own self-hosted runner — to reach private network resources, keep LLM traffic on-prem, or avoid runner-minute costs — the OCR project itself does exactly this in its own CI. + +See [`.github/workflows/ocr-review.yml`](../../.github/workflows/ocr-review.yml) for that workflow. It runs on `runs-on: self-hosted` inside a `node:24` container. One important caveat: it invokes the action with `uses: ./` only because `action.yml` lives in that same repository — that is an internal shortcut and will not resolve in your repo. As an external user, keep `uses: alibaba/open-code-review@main` (the runner fetches the action automatically); only the runner environment needs to change. What is worth borrowing from it: + +- `runs-on: self-hosted`, optionally with a `container:` image such as `node:24` (the action needs Node.js; git is installed automatically if missing). +- Marking the workspace as a trusted git `safe.directory` when running inside a container (e.g. `git config --global --replace-all safe.directory '*'`) to avoid "dubious ownership" errors. Use `--replace-all` (not `--add`) so repeated runs across multiple self-hosted actions replace rather than accumulate entries in the global git config. +- Pinning action inputs explicitly (`sticky_summary`, `incremental`, `upload_artifacts`, `llm_extra_body`, etc.). + +The action performs its own full `fetch-depth: 0` checkout of the PR internally, so no extra checkout step is needed for the review diff. Adapt the runner settings to your environment and secret layout. ## How It Works @@ -10,40 +44,39 @@ PR Created/Updated → GitHub Actions Triggered → OCR Reviews Diff → Comment Comment with trigger keyword ↗ ``` -1. When a PR is opened, the workflow triggers (uses `pull_request_target` for fork secret access) -2. Alternatively, when a comment containing `/open-code-review` or `@open-code-review` is posted on a PR, the workflow triggers -3. It installs OCR via `npm install -g @alibaba-group/open-code-review` -4. Runs `ocr review --from origin/ --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 +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). ## Setup -### 1. Copy the workflow file +### Configure secrets and variables -Copy `ocr-review.yml` to your repository's `.github/workflows/` directory: +Go to your repository's **Settings → Secrets and variables → Actions**. -```bash -mkdir -p .github/workflows -cp ocr-review.yml .github/workflows/ocr-review.yml -``` - -### 2. Configure secrets - -Go to your repository's **Settings → Secrets and variables → Actions** and add: +**Secrets:** | Secret | Required | Description | |--------|----------|-------------| | `OCR_LLM_URL` | Yes | LLM API endpoint URL (e.g., `https://api.openai.com/v1/chat/completions`) | -| `OCR_LLM_AUTH_TOKEN` | Yes | API authentication token | -| `OCR_LLM_MODEL` | No | Model name (defaults to `gpt-4o`) | -| `OCR_LLM_USE_ANTHROPIC` | No | Set to `true` if using Anthropic Claude models | +| `OCR_LLM_AUTH_TOKEN` | Yes | API authentication token (mapped to env `OCR_LLM_TOKEN` internally) | -> **Note:** `GITHUB_TOKEN` is automatically provided by GitHub Actions with the required `pull-requests: write` permission. -> -> The workflow also configures `llm.extra_body` to disable thinking mode for compatibility with various LLM providers. +**Variables:** + +| Variable | Required | Description | +|----------|----------|-------------| +| `OCR_LLM_MODEL` | Yes | Model name | +| `OCR_LLM_USE_ANTHROPIC` | Yes | `true` for Anthropic Claude, `false` for OpenAI-compatible | + +> **Note:** `GITHUB_TOKEN` is automatically provided by GitHub Actions with the required `pull-requests: write` permission. The action also sets `llm.extra_body` to disable thinking mode for compatibility with various LLM providers. ## Customization +> These knobs are action inputs — they apply to the demo workflow and any workflow calling `alibaba/open-code-review@main`. + +See [`action.yml`](../../action.yml) for the full input list. Workflow-level settings (triggers, keywords) are edited in the workflow file itself. + ### Change the trigger events Modify the `on.pull_request_target.types` array in the workflow file: @@ -56,92 +89,152 @@ on: ### Customize comment trigger keywords -By default, the workflow triggers when a PR comment starts with `/open-code-review` or `@open-code-review`. You can customize these keywords by modifying the `if` condition in the workflow: +By default the workflow also re-reviews on demand when a PR comment starts with `/open-code-review` or `@open-code-review`. The `if` condition is more defensive than a bare keyword check — it gates comment triggers so only authorized humans can spend LLM quota: ```yaml if: | - github.event_name == 'pull_request_target' || - (github.event_name == 'issue_comment' && github.event.issue.pull_request && startsWith(github.event.comment.body, '/review')) || - (github.event_name == 'issue_comment' && github.event.issue.pull_request && startsWith(github.event.comment.body, '@mybot')) + github.event_name == 'pull_request_target' + || ( + github.event_name == 'issue_comment' + && github.event.issue.pull_request + && github.event.comment.user.type != 'Bot' + && ( + github.event.comment.author_association == 'MEMBER' + || github.event.comment.author_association == 'OWNER' + || github.event.comment.author_association == 'COLLABORATOR' + ) + && ( + startsWith(github.event.comment.body, '/open-code-review') + || startsWith(github.event.comment.body, '@open-code-review') + ) + ) ``` -Or use a more flexible pattern with `contains` to trigger on any comment containing the keyword: +Each clause guards against a different abuse vector: -```yaml -if: | - github.event_name == 'pull_request_target' || - (github.event_name == 'issue_comment' && github.event.issue.pull_request && contains(github.event.comment.body, '/review')) -``` +- `github.event.issue.pull_request` — the comment must be on a PR, not a regular issue. +- `github.event.comment.user.type != 'Bot'` — ignore bot comments. `GITHUB_TOKEN` already suppresses events from comments it posted, but a PAT or GitHub App token would not, so this is a safety net against self-triggering loops. +- `author_association == 'MEMBER' | 'OWNER' | 'COLLABORATOR'` — only repository collaborators can trigger a (billable) re-review, preventing arbitrary commenters from draining LLM quota. +- The `startsWith(...)` pair — the actual trigger keywords. -> **Note:** The condition `github.event.issue.pull_request` ensures the comment is on a PR, not a regular issue. +To change the keywords, edit only that final pair (e.g. `/review` and `@mybot`), or swap `startsWith` for `contains` to match a substring anywhere in the comment body. Keep the preceding guards intact. + +The same predicate is mirrored in the workflow's `concurrency.group`: matching events share a per-PR group (`ocr-`) 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. ### Use a specific OCR version ```yaml -- name: Install OpenCodeReview - run: npm install -g @alibaba-group/open-code-review@1.0.0 +- uses: alibaba/open-code-review@main + with: + ocr_version: 1.0.0 ``` ### Add custom review rules -Use the `--rule` flag to pass a custom rules JSON file: +```yaml +- uses: alibaba/open-code-review@main + with: + rule: ./my-rules.json +``` + +> Security: do not point `rule` at a file sourced from the PR branch when secrets are in scope; use a trusted rules file from your base branch. + +### Control comment posting (sticky summary & incremental) + +The action posts a summary issue comment plus inline review comments. Two inputs select the posting mode (combined, they give the four modes referenced above); a third tunes the incremental overlap test: + +| Input | Default | Description | +|-------|---------|-------------| +| `sticky_summary` | `'true'` | Update an existing summary comment in place instead of posting a new one each run. | +| `incremental` | `'false'` | Only append inline comments whose `(path, line range)` does not overlap an existing bot review comment. History is never deleted (non-destructive). | +| `incremental_overlap_threshold` | `'0.6'` | IoU threshold `incremental` uses to decide whether a multi-line comment overlaps an existing one. Two single-line comments match on the same line; single- vs multi-line never match. Ignored unless `incremental` is `'true'`. | ```yaml -- name: Run OCR review - run: ocr review --rule ./my-rules.json --from origin/${{ github.base_ref }} --to origin/${{ github.head_ref }} +- uses: alibaba/open-code-review@main + with: + sticky_summary: 'true' + incremental: 'true' + incremental_overlap_threshold: '0.75' ``` +> `sticky_summary` and `incremental` must be quoted strings (`'true'`/`'false'`); the action compares them as strings, so an unquoted YAML boolean will not match. + ### Adjust retry and delay settings -When posting review comments individually (fallback mode), the workflow includes rate-limit handling with exponential backoff. The retry strategy follows GitHub's documented guidance for REST API rate limits — see [Rate limits for the REST API](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api?apiVersion=2026-03-10) for details on primary/secondary rate limits and recommended retry behavior: +When posting review comments individually (fallback mode), the action honors GitHub rate-limit headers (`retry-after`, `x-ratelimit-*`) with exponential backoff. The retry strategy follows GitHub's documented guidance for REST API rate limits — see [Rate limits for the REST API](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api?apiVersion=2026-03-10) for details on primary/secondary rate limits and recommended retry behavior: - **Primary rate limit exhausted** (`x-ratelimit-remaining=0`): wait until `x-ratelimit-reset`. - **Secondary rate limit with a `retry-after` header**: wait exactly that long. - **Secondary rate limit with no header**: wait at least one minute, then use exponential backoff on continued failures. -You can configure the retry and delay behavior via **repository variables** (Settings → Secrets and variables → Actions → Variables): +These are environment variables read by the posting module with sensible defaults; set them at the **job `env:` level** to tune (they propagate into the action): | Variable | Default | Description | |----------|---------|-------------| -| `OCR_RETRY_BASE_DELAY` | `60000` | Base delay (ms) for exponential backoff when no retry header is present (per GitHub's "at least one minute" recommendation for secondary limits) | -| `OCR_RETRY_MAX_DELAY` | `300000` | Maximum delay (ms) cap applied to every computed wait, including retry-after and x-ratelimit-reset, so a far-future reset cannot stall the job past its timeout | +| `OCR_RETRY_BASE_DELAY` | `60000` | Base delay (ms) for exponential backoff when no retry header is present | +| `OCR_RETRY_MAX_DELAY` | `300000` | Maximum delay (ms) cap applied to every computed wait | | `OCR_MAX_RETRIES` | `3` | Maximum retry attempts per comment when rate-limited | -| `OCR_SUCCESS_DELAY` | `2000` | Delay (ms) after a successful comment post to pace subsequent requests | -| `OCR_FAILURE_DELAY` | `1000` | Delay (ms) after a non-rate-limit failure to pace subsequent requests | -| `OCR_LOW_REMAINING_THRESHOLD` | `3` | When x-ratelimit-remaining is at or below this value, proactively increase request spacing to avoid hitting the limit | +| `OCR_SUCCESS_DELAY` | `2000` | Delay (ms) after a successful comment post | +| `OCR_FAILURE_DELAY` | `1000` | Delay (ms) after a non-retryable failure | +| `OCR_LOW_REMAINING_THRESHOLD` | `3` | When x-ratelimit-remaining is at or below this value, proactively increase request spacing | | `OCR_LOW_REMAINING_SPACING` | `10000` | Request spacing (ms) used when remaining quota is low | | `OCR_READ_SUCCESS_DELAY` | `500` | Delay (ms) after a successful read API call (`listReviews` / `listReviewComments` / `listIssueComments`) used for the idempotency check. Reads are cheaper than writes, so the default is shorter | | `OCR_READ_LOW_REMAINING_SPACING` | `5000` | Request spacing (ms) for read calls when remaining quota is low | -These variables are optional — if not configured, sensible defaults are used. Consider increasing delays for repositories with many concurrent workflows or large PRs that generate numerous review comments. +For example, to raise the per-comment retry count to 5, set `OCR_MAX_RETRIES` on the **job's** `env:` — not on the `uses:` step. A composite action does not forward the caller's step-level `env:` into its internal steps' process environment, so a step-level value would be silently ignored; the job-level value is inherited by the action's comment-posting step and read via `process.env`: + +```yaml +jobs: + code-review: + runs-on: ubuntu-latest + env: + OCR_MAX_RETRIES: 5 + steps: + - uses: alibaba/open-code-review@main + with: + llm_url: ${{ secrets.OCR_LLM_URL }} + # ...other inputs +``` + +These variables are optional. See GitHub's [Rate limits for the REST API](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api). #### Idempotency: avoiding duplicate review comments -When the batch `createReview` call fails with a `5xx` error, the request may still have landed on the GitHub server (the response was simply lost). Before retrying per-comment, the workflow queries existing reviews and review comments — each tagged with a per-run HTML comment (e.g. ``) — and only retries the comments that are actually missing. This prevents duplicate review posts. +When the batch `createReview` call fails with a `5xx` error, the request may still have landed on the GitHub server (the response was simply lost). Before retrying per-comment, the action queries existing reviews and review comments — each tagged with a per-run HTML comment (e.g. ``) — and only retries the comments that are actually missing. This prevents duplicate review posts. -The same idempotency check is applied to the summary comment: before posting, the workflow verifies whether a summary with the same run tag already exists, and skips posting if so. +The summary comment is deduplicated too: in sticky mode (the default) the action finds the existing summary by its persistent marker and updates it in place rather than posting a new one; in non-sticky mode it reuses this run's summary if it already exists. If the read API is unavailable, it skips posting the summary rather than risking a duplicate. -If the read API itself is unavailable (rate-limited or `5xx`), the check returns *unknown* rather than assuming the comment was not posted. In that case the workflow **skips retrying** to avoid risking a duplicate, and surfaces the uncertainty in the summary instead of silently producing duplicates. +If the read API itself is unavailable (rate-limited or `5xx`), the check returns *unknown* rather than assuming the comment was not posted. In that case the action **skips retrying** to avoid risking a duplicate, and surfaces the uncertainty in the summary instead of silently producing duplicates. -### Limit concurrency - -Adjust the `--concurrency` flag for large PRs to control the number of concurrent LLM requests: +### Limit LLM concurrency ```yaml -- name: Run OCR review - run: ocr review --concurrency 5 --from origin/${{ github.base_ref }} --to origin/${{ github.head_ref }} +- uses: alibaba/open-code-review@main + with: + review_concurrency: 5 ``` ### Provide background context -Use the `--background` flag to pass additional context that helps OCR better understand the purpose of the changes: - ```yaml -- name: Run OCR review - run: ocr review --background "${{ github.event.pull_request.title }}" --from origin/${{ github.base_ref }} --to origin/${{ github.head_ref }} +- uses: alibaba/open-code-review@main + with: + background: ${{ github.event.pull_request.title }} ``` -This is particularly useful when your PR titles follow semantic conventions (e.g., `feat(auth): add OAuth2 support`) that clearly summarize what the PR implements. The background information helps OCR provide more relevant and context-aware review comments. +Particularly useful when PR titles follow semantic conventions (e.g., `feat(auth): add OAuth2 support`). + +> Note: `github.event.pull_request.title` is only present on `pull_request_target` events, so it is empty for comment-triggered re-reviews. To cover both trigger types, have the pr-context step also output the title and fall back to it: +> +> ```yaml +> # inside the pr-context script (which only runs for issue_comment): +> core.setOutput('title', pullRequest.title); +> ``` +> ```yaml +> - uses: alibaba/open-code-review@main +> with: +> background: ${{ steps.pr-context.outputs.title || github.event.pull_request.title }} +> ``` ### Customize the review comment author with GitHub App @@ -184,40 +277,53 @@ Add the following secrets to your repository (**Settings → Secrets and variabl |--------|-------------| | `GITHUB_APP_ID` | Your GitHub App's ID | | `GITHUB_APP_PRIVATE_KEY` | Contents of the `.pem` file (including `-----BEGIN RSA PRIVATE KEY-----` and `-----END RSA PRIVATE KEY-----`) | -| `GITHUB_APP_INSTALLATION_ID` | The Installation ID from Step 3 | +| `GITHUB_APP_INSTALLATION_ID` | (Optional) The Installation ID from Step 3 — only needed for apps with multiple installations | -#### Step 5: Update the Workflow +#### Step 5: Pass the App token to the action -Add a step to obtain a token from the GitHub App, then use it in the "Post review comments to PR" step: +Mint a token with `actions/create-github-app-token` and pass it via the `github_token` input: ```yaml - name: Get GitHub App Token id: app-token - uses: actions/create-github-app-token@v1 + uses: actions/create-github-app-token@main with: app-id: ${{ secrets.GITHUB_APP_ID }} private-key: ${{ secrets.GITHUB_APP_PRIVATE_KEY }} -- name: Post review comments to PR - uses: actions/github-script@v7 +- uses: alibaba/open-code-review@main with: - github-token: ${{ steps.app-token.outputs.token }} - script: | - # ... existing script + github_token: ${{ steps.app-token.outputs.token }} + llm_url: ${{ secrets.OCR_LLM_URL }} + llm_auth_token: ${{ secrets.OCR_LLM_AUTH_TOKEN }} + llm_model: ${{ vars.OCR_LLM_MODEL }} + llm_use_anthropic: ${{ vars.OCR_LLM_USE_ANTHROPIC }} ``` Now review comments will be posted with your custom GitHub App identity (e.g., `OpenCodeReview Bot`), providing a more professional and distinguishable appearance in your PRs. ## Example Output -When a PR is reviewed, comments appear directly in the PR's "Files changed" tab: +The action posts two kinds of output on the PR: a **summary issue comment** (in the PR conversation) and **inline review comments** (in the "Files changed" tab). -- ✅ If no issues found: A comment saying "No comments generated. Looks good to me." -- 🔍 If issues found: Inline review comments with suggestions using GitHub's native suggestion syntax +### Summary comment -### Inline Comment Example +A single comment — updated in place on each run when `sticky_summary` is `'true'` (the default) — carries the review outcome and posting statistics. -The workflow uses GitHub's `suggestion` code block syntax, so reviewers can apply fixes with one click: +- ✅ No issues: `✅ **OpenCodeReview**: No comments generated. Looks good to me.` +- 🔍 Issues found: a header line plus per-outcome counts, for example: + +```markdown +🔍 **OpenCodeReview** found **3** issue(s) in this PR. +- ✅ Successfully posted inline: 2 comment(s) +- 📝 In summary (no line info): 1 comment(s) +``` + +The counts are mutually exclusive and sum to the total: `inline` (landed as review inline comments), `summary` (no line info, rendered in the summary body), `skipped` (suppressed by incremental overlap filtering), and `failed` (had line info but could not be posted). Any warnings are appended as a bulleted list. + +### Inline comments + +Comments with valid line info are posted as PR review comments in "Files changed". Each carries the review content plus, when a fix is available, a GitHub-native `suggestion` block so reviewers can apply it with one click: ````markdown **Suggestion:** @@ -226,6 +332,8 @@ The workflow uses GitHub's `suggestion` code block syntax, so reviewers can appl ``` ```` +Comments that have no line info, or that could not be posted inline (e.g. their line fell outside the current diff), are rendered in the summary body instead — each under a `### 📄 ` heading, with a collapsible `

` "💡 Suggested Change" (Before/After) when a fix is available. + ## Supported LLM Providers OCR supports both OpenAI and Anthropic API formats: @@ -234,22 +342,25 @@ OCR supports both OpenAI and Anthropic API formats: - OpenAI (GPT-4o, GPT-4, etc.) - Azure OpenAI - Self-hosted models (vLLM, Ollama, etc.) -- **Anthropic APIs** (set `OCR_LLM_USE_ANTHROPIC: true`): +- **Anthropic APIs** (set variable `OCR_LLM_USE_ANTHROPIC=true`, i.e. `llm_use_anthropic: true`): - Anthropic Claude models ## Troubleshooting ### Common Issues -1. **"Failed to parse OCR output"**: Check that `OCR_LLM_URL` and `OCR_LLM_AUTH_TOKEN` secrets are correctly set -2. **"Cannot find merge-base"**: Ensure `fetch-depth: 0` is set in the checkout step -3. **Review comments not appearing on correct lines**: This can happen when the diff has changed since the review started; the workflow handles this gracefully with a fallback to issue comments +1. **Job fails / "Failed to parse OCR output"**: When `ocr review` exits non-zero the action fails the job with that exit code (the comment-posting step is skipped); a zero exit with malformed JSON surfaces as a parse error in the summary. In both cases, check that `OCR_LLM_URL` and `OCR_LLM_AUTH_TOKEN` are set correctly, then inspect the uploaded `ocr-stderr.log` artifact (also printed in the "Run OpenCodeReview" step log) for the underlying error. +2. **"Cannot find merge-base"**: The action fetches full history (`fetch-depth: 0`) and the PR head (`git fetch origin pull//head`); if this still fails, ensure `permissions: contents: read` is set and the base branch is accessible (e.g., not deleted). +3. **Review comments not on the expected lines**: Comments are attached to the PR head commit. If a comment's line falls outside the current diff (the PR was force-pushed or updated mid-review), GitHub rejects the inline post and the comment is rendered in the summary instead. The workflow's concurrency group cancels stale runs on new pushes. +4. **No summary or comments at all**: Confirm the job's `permissions` include `pull-requests: write`, and that `github_token` (defaults to `${{ github.token }}`) is not overridden with a token lacking those scopes. ### Debugging -Enable debug logging by adding to the OCR review step: +The action does not use an `OCR_DEBUG` flag. To diagnose a run: -```yaml -env: - OCR_DEBUG: "1" -``` +- **Artifacts**: with `upload_artifacts: 'true'` (the default), the raw `ocr-result.json` and `ocr-stderr.log` are uploaded as workflow artifacts named `ocr-review-result--`. 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'`. diff --git a/examples/github_actions/ocr-review.yml b/examples/github_actions/ocr-review.yml index 2073ae1..11a57a3 100644 --- a/examples/github_actions/ocr-review.yml +++ b/examples/github_actions/ocr-review.yml @@ -1,71 +1,64 @@ # OpenCodeReview - GitHub Actions PR Auto-Review Demo # -# This workflow automatically reviews pull requests using OpenCodeReview -# and posts review comments directly on the PR. +# 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'. # -# Triggers: -# - PR opened (uses pull_request_target for fork secret access) -# - Comment on PR containing '/open-code-review' or '@open-code-review' +# 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 # -# 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. +# For the full list of action inputs/outputs and the four comment-posting modes +# (sticky / incremental), see action.yml at the repo root. 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.workflow }}-${{ github.event.pull_request.number || github.ref }} + 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) + }} cancel-in-progress: true on: # Use pull_request_target instead of pull_request so that secrets are - # available even for PRs from forks. This is safe because OCR only reads - # the diff and does not execute any code from the PR. + # 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. pull_request_target: - types: [opened] + types: [opened, synchronize, reopened] issue_comment: types: [created] @@ -76,19 +69,38 @@ permissions: jobs: code-review: runs-on: ubuntu-latest - # Run on PR events, or on comments starting with trigger keywords + 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. if: | - 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')) + 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') + ) + ) steps: - name: Get PR context id: pr-context - if: github.event_name != 'pull_request_target' + if: github.event_name == 'issue_comment' uses: actions/github-script@v7 with: script: | - // For issue_comment events, get PR info + // 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). const prNumber = context.issue.number; const { data: pullRequest } = await github.rest.pulls.get({ owner: context.repo.owner, @@ -96,829 +108,16 @@ 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: Checkout repository - uses: actions/checkout@v4 - with: - 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 + uses: alibaba/open-code-review@main 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)); - } + 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 }} diff --git a/extensions/vscode/src/webview/ConfigPanelApp.tsx b/extensions/vscode/src/webview/ConfigPanelApp.tsx index 1439a12..24e9836 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 { useEffect, useReducer } from 'preact/hooks'; +import { useCallback, useEffect, useReducer } from 'preact/hooks'; import { bridge } from './bridge'; import { ConfigView } from './views/ConfigView'; import { configPanelInitialState, configPanelReducer } from './configStore'; @@ -12,6 +12,7 @@ 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)); @@ -59,7 +60,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={() => dispatch({ type: 'clearConnTest' })} + onClearConnTest={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/views/ConfigView.tsx b/extensions/vscode/src/webview/views/ConfigView.tsx index 1e75ef1..587405b 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, onClearConnTest]); + }, [panelFocus, config]); 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/IdleView.tsx b/extensions/vscode/src/webview/views/IdleView.tsx index 7de4974..dea61f7 100644 --- a/extensions/vscode/src/webview/views/IdleView.tsx +++ b/extensions/vscode/src/webview/views/IdleView.tsx @@ -113,8 +113,6 @@ export function IdleView({ gitState, modeFiles, filesLoading, configured, onMode {configured && (
- - ·
)} diff --git a/go.mod b/go.mod index 88d5e03..42a346c 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,9 @@ require ( github.com/pkoukk/tiktoken-go v0.1.8 go.opentelemetry.io/otel v1.44.0 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0 go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0 go.opentelemetry.io/otel/metric v1.44.0 diff --git a/go.sum b/go.sum index cb21c84..071f834 100644 --- a/go.sum +++ b/go.sum @@ -109,10 +109,14 @@ go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 h1:SUplec5dp06reu1zaXmOXdvqH398taqrDXqUl99jxSc= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0/go.mod h1:ho2g4N+ane+swq5I/VBkKWnRDY4kUINH3FuqyZqX/Ug= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0 h1:RuynHbfU8JUEw7DyONgkVYg2SVtsoF28y0LGIr69jgA= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0/go.mod h1:qZF+/lBs71APw8mlnEZcqZHMzqrYrsFiJOv83lX1OGo= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 h1:qazEJlUOQzhCpzQpFETGby7EdqjI1wsd0W+6Gg1SCTU= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0/go.mod h1:fOD2Yefuxixkx3ahVNf0O/PERb6r4OlbxfATVnYvzCo= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 h1:lgh3PiVrRUWMLOVSkQicxzZll5NjF1r+AtsX1XRIHw0= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0/go.mod h1:5Cnhth3m/AgOeTgE3ex12pPmiu/gGtZit03kSzx9X7s= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0 h1:hqxVTu/GtBF+vJ8d1fzW7fRxZFvgoDjWcxwwCaFDYpU= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0/go.mod h1:z5fVEF4X5v0ESvlJqBrrFlBVoj5EQuefZpzsu7R+x5Q= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0 h1:bl2S7Ubua0Nms+D/gAmznQTd4dxxMA93aKbcpKqiTCs= diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 4be6504..a50dead 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -2,6 +2,7 @@ package agent import ( "context" + "crypto/sha256" "encoding/json" "fmt" "runtime/debug" @@ -22,6 +23,8 @@ import ( "github.com/open-code-review/open-code-review/internal/stdout" "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" ) // AgentWarning is re-exported from llmloop for backwards compatibility with @@ -111,6 +114,9 @@ type Args struct { // Session is an optional session history instance for collecting conversation records. // When nil, a default one is created automatically with git branch auto-detected from repoDir. Session *session.SessionHistory + + // Resume is an optional read-only checkpoint index from a previous review session. + Resume *session.ResumeState } // Agent orchestrates the AI-powered code review. LLM tool-use loop / memory @@ -125,6 +131,16 @@ type Agent struct { session *session.SessionHistory subtaskFailed int64 // count of failed subtasks, accessed atomically runner *llmloop.Runner + resumeInfo *ResumeInfo +} + +// ResumeInfo summarizes file-level reuse for a resumed review. +type ResumeInfo struct { + ResumedFrom string `json:"resumed_from"` + ReusedFiles int64 `json:"reused_files"` + RerunFiles int64 `json:"rerun_files"` + PreviousModel string `json:"previous_model,omitempty"` + CurrentModel string `json:"current_model,omitempty"` } // New creates a new Agent from the given arguments. @@ -142,10 +158,11 @@ func New(args Args) *Agent { mode = reviewModeString(args.From, args.To, args.Commit) } args.Session = session.New(args.RepoDir, gitBranch, args.Model, session.SessionOptions{ - ReviewMode: mode, - DiffFrom: args.From, - DiffTo: args.To, - DiffCommit: args.Commit, + ReviewMode: mode, + DiffFrom: args.From, + DiffTo: args.To, + DiffCommit: args.Commit, + ResumedFrom: resumedFromSession(args.Resume), }) } a := &Agent{ @@ -223,6 +240,23 @@ func (a *Agent) Session() *session.SessionHistory { return a.session } +// SessionID returns the current review's session id, or "" when no session has been created. +func (a *Agent) SessionID() string { + if a == nil || a.session == nil { + return "" + } + return a.session.SessionID +} + +// ResumeInfo returns resume metadata for output. Nil means this was not a resume run. +func (a *Agent) ResumeInfo() *ResumeInfo { + if a.resumeInfo == nil { + return nil + } + info := *a.resumeInfo + return &info +} + // FilesReviewed returns the number of changed files included in this review. func (a *Agent) FilesReviewed() int64 { return int64(len(a.diffs)) @@ -325,6 +359,7 @@ func (a *Agent) dispatchSubtasks(ctx context.Context) ([]model.LlmComment, error if len(a.diffs) == 0 { return nil, fmt.Errorf("all diffs filtered out by token size") } + toDispatch := a.applyResume(a.diffs) var wg sync.WaitGroup @@ -337,8 +372,8 @@ func (a *Agent) dispatchSubtasks(ctx context.Context) ([]model.LlmComment, error timeout := time.Duration(a.args.ConcurrentTaskTimeout) * time.Minute var dispatched int64 - for i := range a.diffs { - if a.diffs[i].IsDeleted { + for i := range toDispatch { + if toDispatch[i].IsDeleted { continue } dispatched++ @@ -346,6 +381,7 @@ func (a *Agent) dispatchSubtasks(ctx context.Context) ([]model.LlmComment, error sem <- struct{}{} // acquire semaphore go func(d model.Diff) { + fingerprint := reviewItemFingerprint(a.reviewMode(), d) defer wg.Done() defer func() { <-sem }() // release // A panic while reviewing one file must be isolated exactly like an @@ -357,6 +393,7 @@ func (a *Agent) dispatchSubtasks(ctx context.Context) ([]model.LlmComment, error defer func() { if r := recover(); r != nil { atomic.AddInt64(&a.subtaskFailed, 1) + a.session.RecordReviewItemFailed(d.NewPath, d.OldPath, d.NewPath, fingerprint, fmt.Sprintf("panic: %v", r)) fmt.Fprintf(stdout.Writer(), "[ocr] Subtask panic for %s: %v\n%s\n", d.NewPath, r, debug.Stack()) telemetry.ErrorEvent(ctx, "subtask.panic", fmt.Errorf("panic: %v", r), telemetry.AnyToAttr("file.path", d.NewPath)) @@ -373,20 +410,31 @@ func (a *Agent) dispatchSubtasks(ctx context.Context) ([]model.LlmComment, error fileCtx = ctx } - if err := a.executeSubtask(fileCtx, d); err != nil { + completed, skipReason, err := a.executeSubtask(fileCtx, d) + if err != nil { atomic.AddInt64(&a.subtaskFailed, 1) + a.session.RecordReviewItemFailed(d.NewPath, d.OldPath, d.NewPath, fingerprint, err.Error()) fmt.Fprintf(stdout.Writer(), "[ocr] Subtask error for %s: %v\n", d.NewPath, err) telemetry.ErrorEvent(fileCtx, "subtask.error", err, telemetry.AnyToAttr("file.path", d.NewPath)) a.recordWarning("subtask_error", d.NewPath, err.Error()) + return } - }(a.diffs[i]) + if !completed { + if skipReason != "" { + a.session.RecordReviewItemFailed(d.NewPath, d.OldPath, d.NewPath, fingerprint, skipReason) + } + return + } + comments := a.args.CommentCollector.CommentsForPath(d.NewPath) + a.session.RecordReviewItemDone(d.NewPath, d.OldPath, d.NewPath, fingerprint, comments) + }(toDispatch[i]) } wg.Wait() if dispatched == 0 { - return []model.LlmComment{}, nil + return a.args.CommentCollector.Comments(), nil } // All subtasks finished — collect comments from the global collector once. @@ -402,8 +450,76 @@ func (a *Agent) dispatchSubtasks(ctx context.Context) ([]model.LlmComment, error return a.args.CommentCollector.Comments(), nil } +func (a *Agent) applyResume(diffs []model.Diff) []model.Diff { + resume := a.args.Resume + if resume == nil { + return diffs + } + + mode := a.reviewMode() + toDispatch := make([]model.Diff, 0, len(diffs)) + var reused int64 + for _, d := range diffs { + if d.IsDeleted { + toDispatch = append(toDispatch, d) + continue + } + fingerprint := reviewItemFingerprint(mode, d) + item, ok := resume.Item(fingerprint) + if !ok { + toDispatch = append(toDispatch, d) + continue + } + for _, cm := range item.Comments { + a.args.CommentCollector.Add(cm) + } + a.session.RecordReviewItemReused(effectivePath(d), d.OldPath, d.NewPath, fingerprint, resume.SessionID, item.Comments) + reused++ + } + + rerun := countDispatchable(toDispatch) + a.resumeInfo = &ResumeInfo{ + ResumedFrom: resume.SessionID, + ReusedFiles: reused, + RerunFiles: rerun, + PreviousModel: resume.Model, + CurrentModel: a.args.Model, + } + fmt.Fprintf(stdout.Writer(), "[ocr] Resume %s: reusing %d file(s), reviewing %d file(s)\n", resume.SessionID, reused, rerun) + return toDispatch +} + +func countDispatchable(diffs []model.Diff) int64 { + var n int64 + for _, d := range diffs { + if !d.IsDeleted { + n++ + } + } + return n +} + +func (a *Agent) reviewMode() string { + if a.args.ReviewMode != "" { + return a.args.ReviewMode + } + return reviewModeString(a.args.From, a.args.To, a.args.Commit) +} + +func reviewItemFingerprint(mode string, d model.Diff) string { + sum := sha256.Sum256([]byte(mode + "\x00" + d.OldPath + "\x00" + d.NewPath + "\x00" + d.Diff)) + return fmt.Sprintf("%x", sum) +} + +func resumedFromSession(resume *session.ResumeState) string { + if resume == nil { + return "" + } + return resume.SessionID +} + // executeSubtask performs the Plan Phase + Main Loop for a single file. -func (a *Agent) executeSubtask(ctx context.Context, d model.Diff) error { +func (a *Agent) executeSubtask(ctx context.Context, d model.Diff) (bool, string, error) { ctx, span := telemetry.StartSpan(ctx, "subtask.execute."+d.NewPath) defer span.End() telemetry.SetAttr(span, "file.path", d.NewPath) @@ -412,7 +528,7 @@ func (a *Agent) executeSubtask(ctx context.Context, d model.Diff) error { telemetry.SetAttr(span, "lines.deleted", d.Deletions) if ctx.Err() != nil { - return ctx.Err() + return false, "", ctx.Err() } newPath := d.NewPath @@ -446,7 +562,7 @@ func (a *Agent) executeSubtask(ctx context.Context, d model.Diff) error { // Phase 2: Main task loop if len(a.args.Template.MainTask.Messages) == 0 { - return fmt.Errorf("main_task.messages is empty in template") + return false, "", fmt.Errorf("main_task.messages is empty in template") } rawMsgs := a.args.Template.MainTask.Messages @@ -484,10 +600,21 @@ func (a *Agent) executeSubtask(ctx context.Context, d model.Diff) error { telemetry.AnyToAttr("file.path", newPath), telemetry.AnyToAttr("tokens", tokenCount), telemetry.AnyToAttr("max_tokens", maxAllowed)) - return nil + return false, msg, nil } - err := a.runner.RunPerFile(ctx, messages, newPath) + mainCompleted, err := func() (bool, error) { + ctx, mainSpan := telemetry.StartSpan(ctx, "main.loop") + defer mainSpan.End() + telemetry.SetAttr(mainSpan, "file.path", newPath) + completed, err := a.runner.RunPerFile(ctx, messages, newPath) + if err != nil { + mainSpan.SetStatus(codes.Error, err.Error()) + mainSpan.RecordError(err) + return false, err + } + return completed, nil + }() if err == nil { // REVIEW_FILTER_TASK runs after the main loop and decides which of the // just-collected comments to drop. It needs to see comments produced by @@ -497,12 +624,22 @@ func (a *Agent) executeSubtask(ctx context.Context, d model.Diff) error { } a.executeReviewFilter(ctx, d, newPath) } - return err + if err != nil { + return false, "", err + } + if !mainCompleted { + return false, "main_task did not complete before stopping", nil + } + return true, "", nil } // executeReviewFilter runs the REVIEW_FILTER_TASK to remove comments that are // provably incorrect based solely on the diff. Errors are logged and silently ignored. func (a *Agent) executeReviewFilter(ctx context.Context, d model.Diff, newPath string) { + ctx, span := telemetry.StartSpan(ctx, "review_filter.execute") + defer span.End() + telemetry.SetAttr(span, "file.path", newPath) + ft := a.args.Template.ReviewFilterTask if ft == nil || len(ft.Messages) == 0 { return @@ -512,6 +649,7 @@ func (a *Agent) executeReviewFilter(ctx context.Context, d model.Diff, newPath s if len(comments) == 0 { return } + telemetry.SetAttr(span, "comments.before", len(comments)) commentsJSON := buildFilterCommentsJSON(comments) @@ -534,20 +672,33 @@ func (a *Agent) executeReviewFilter(ctx context.Context, d model.Diff, newPath s rec := fs.AppendTaskRecord(session.ReviewFilterTask, messages) startTime := time.Now() + _, llmSpan := telemetry.StartLLMSpan(ctx, a.args.Model) resp, err := a.args.LLMClient.CompletionsWithCtx(ctx, llm.ChatRequest{ Model: a.args.Model, Messages: messages, MaxTokens: a.args.Template.MaxTokens, }) + duration := time.Since(startTime) if err != nil { - rec.SetError(err, time.Since(startTime)) + telemetry.RecordLLMResult(llmSpan, duration, 0, err) + llmSpan.End() + rec.SetError(err, duration) fmt.Fprintf(stdout.Writer(), "[ocr] Review filter failed for %s: %v\n", newPath, err) + span.SetStatus(codes.Error, err.Error()) + span.RecordError(err) return } - rec.SetResponse(resp, time.Since(startTime)) + var totalTokens int64 + if resp.Usage != nil { + totalTokens = resp.Usage.TotalTokens + } + telemetry.RecordLLMResult(llmSpan, duration, totalTokens, nil) + llmSpan.End() + rec.SetResponse(resp, duration) a.runner.RecordUsage(resp.Usage) indices := parseFilterResponse(resp.Content(), len(comments)) + telemetry.SetAttr(span, "comments.filtered", len(indices)) if len(indices) == 0 { return } @@ -722,6 +873,10 @@ func (a *Agent) extFromPath(path string) string { // executePlanPhase runs the plan task for a single file, sending template messages // with resolved placeholders and collecting the LLM response as plan guidance. func (a *Agent) executePlanPhase(ctx context.Context, newPath, rawDiff, changeFiles, rule string) (string, error) { + ctx, span := telemetry.StartSpan(ctx, "plan.execute") + defer span.End() + telemetry.SetAttr(span, "file.path", newPath) + pt := a.args.Template.PlanTask messages := make([]llm.Message, 0, len(pt.Messages)) for _, m := range pt.Messages { @@ -740,16 +895,28 @@ func (a *Agent) executePlanPhase(ctx context.Context, newPath, rawDiff, changeFi rec := fs.AppendTaskRecord(session.PlanTask, messages) startTime := time.Now() + _, llmSpan := telemetry.StartLLMSpan(ctx, a.args.Model) resp, err := a.args.LLMClient.CompletionsWithCtx(ctx, llm.ChatRequest{ Model: a.args.Model, Messages: messages, MaxTokens: a.args.Template.MaxTokens, }) + duration := time.Since(startTime) if err != nil { - rec.SetError(err, time.Since(startTime)) + telemetry.RecordLLMResult(llmSpan, duration, 0, err) + llmSpan.End() + rec.SetError(err, duration) + span.SetStatus(codes.Error, err.Error()) + span.RecordError(err) return "", fmt.Errorf("plan request: %w", err) } - rec.SetResponse(resp, time.Since(startTime)) + var totalTokens int64 + if resp.Usage != nil { + totalTokens = resp.Usage.TotalTokens + } + telemetry.RecordLLMResult(llmSpan, duration, totalTokens, nil) + llmSpan.End() + rec.SetResponse(resp, duration) a.runner.RecordUsage(resp.Usage) fmt.Fprintf(stdout.Writer(), "[ocr] Plan completed for %s\n", newPath) return resp.Content(), nil diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index 99f8280..24095dd 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -10,6 +10,7 @@ import ( "github.com/open-code-review/open-code-review/internal/config/toolsconfig" "github.com/open-code-review/open-code-review/internal/llm" "github.com/open-code-review/open-code-review/internal/model" + "github.com/open-code-review/open-code-review/internal/session" "github.com/open-code-review/open-code-review/internal/tool" ) @@ -407,6 +408,62 @@ func TestFilterLargeDiffs_ZeroMaxTokens(t *testing.T) { } } +func TestApplyResumeReusesCompletedItemsAcrossModels(t *testing.T) { + diffs := []model.Diff{ + {OldPath: "a.go", NewPath: "a.go", Diff: "+a", Insertions: 1}, + {OldPath: "b.go", NewPath: "b.go", Diff: "+b", Insertions: 1}, + } + fp := reviewItemFingerprint(session.ReviewModeRange, diffs[0]) + resume := &session.ResumeState{ + SessionID: "old-session", + Model: "anthropic-model", + ReviewMode: session.ReviewModeRange, + DiffFrom: "main", + DiffTo: "feature", + Items: map[string]session.ResumeItem{ + fp: { + FilePath: "a.go", + OldPath: "a.go", + NewPath: "a.go", + Fingerprint: fp, + Comments: []model.LlmComment{{ + Path: "a.go", + Content: "cached comment", + }}, + }, + }, + } + collector := tool.NewCommentCollector() + sess := session.New(t.TempDir(), "feature", "openai-model", session.SessionOptions{ + ReviewMode: session.ReviewModeRange, + DiffFrom: "main", + DiffTo: "feature", + ResumedFrom: "old-session", + }) + defer sess.Finalize() + a := New(Args{ + From: "main", + To: "feature", + Model: "openai-model", + CommentCollector: collector, + Resume: resume, + Session: sess, + }) + + toDispatch := a.applyResume(diffs) + if len(toDispatch) != 1 || toDispatch[0].NewPath != "b.go" { + t.Fatalf("toDispatch = %+v, want only b.go", toDispatch) + } + comments := collector.Comments() + if len(comments) != 1 || comments[0].Content != "cached comment" { + t.Fatalf("comments = %+v", comments) + } + info := a.ResumeInfo() + if info == nil || info.ReusedFiles != 1 || info.RerunFiles != 1 || info.PreviousModel != "anthropic-model" || info.CurrentModel != "openai-model" { + t.Fatalf("ResumeInfo = %+v", info) + } +} + func TestCountReviewable(t *testing.T) { a := New(Args{}) diffs := []model.Diff{ @@ -500,6 +557,140 @@ func TestDispatchSubtasks_WithFakeLLM(t *testing.T) { } } +func TestDispatchSubtasks_TokenThresholdSkipIsNotReusableCheckpoint(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + repoDir := t.TempDir() + sess := session.New(repoDir, "feature", "fake", session.SessionOptions{ + ReviewMode: session.ReviewModeRange, + DiffFrom: "main", + DiffTo: "feature", + }) + + client := &fakeAgentClient{responses: []*llm.ChatResponse{ + agentTaskDoneResponse(), + }} + a := New(Args{ + From: "main", + To: "feature", + LLMClient: client, + Model: "fake", + Session: sess, + Template: template.Template{ + MaxTokens: 100, + MaxToolRequestTimes: 5, + MainTask: template.LlmConversation{ + Messages: []template.ChatMessage{ + {Role: "user", Content: strings.Repeat("context ", 200) + "{{diff}}"}, + }, + }, + }, + }) + diff := model.Diff{NewPath: "large-prompt.go", OldPath: "large-prompt.go", Diff: "+x", Insertions: 1} + a.diffs = []model.Diff{diff} + a.currentDate = "2025-06-26 10:00" + + comments, err := a.dispatchSubtasks(context.Background()) + if err != nil { + t.Fatalf("dispatchSubtasks: %v", err) + } + if len(comments) != 0 { + t.Fatalf("expected no comments, got %d", len(comments)) + } + if client.calls != 0 { + t.Fatalf("threshold skip should not call LLM, got %d calls", client.calls) + } + sess.Finalize() + + state, err := session.LoadResumeState(repoDir, sess.SessionID) + if err != nil { + t.Fatalf("LoadResumeState: %v", err) + } + if state.CompletedCount() != 0 { + t.Fatalf("CompletedCount = %d, want 0", state.CompletedCount()) + } + fp := reviewItemFingerprint(session.ReviewModeRange, diff) + if _, ok := state.Item(fp); ok { + t.Fatal("token-threshold skip was recorded as a reusable checkpoint") + } + summary, items, err := session.LoadDetail(repoDir, sess.SessionID) + if err != nil { + t.Fatalf("LoadDetail: %v", err) + } + if summary.CompletedFiles != 0 || summary.FailedFiles != 1 { + t.Fatalf("summary counts = completed %d failed %d, want completed 0 failed 1", summary.CompletedFiles, summary.FailedFiles) + } + if len(items) != 1 || items[0].Type != "failed" || !strings.Contains(items[0].Error, "prompt tokens") { + t.Fatalf("items = %+v, want one token-threshold failed item", items) + } +} + +func TestDispatchSubtasks_MainTaskWithoutTaskDoneIsNotReusableCheckpoint(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + repoDir := t.TempDir() + sess := session.New(repoDir, "feature", "fake", session.SessionOptions{ + ReviewMode: session.ReviewModeRange, + DiffFrom: "main", + DiffTo: "feature", + }) + + emptyContent := "" + client := &fakeAgentClient{responses: []*llm.ChatResponse{{ + Choices: []llm.Choice{{Message: llm.ResponseMessage{Content: &emptyContent}}}, + Model: "fake", + Usage: &llm.UsageInfo{PromptTokens: 10, CompletionTokens: 1}, + }}} + a := New(Args{ + From: "main", + To: "feature", + LLMClient: client, + Model: "fake", + Session: sess, + Template: template.Template{ + MaxTokens: 100000, + MaxToolRequestTimes: 1, + MainTask: template.LlmConversation{ + Messages: []template.ChatMessage{{Role: "user", Content: "Review {{diff}}"}}, + }, + }, + }) + diff := model.Diff{NewPath: "needs-review.go", OldPath: "needs-review.go", Diff: "+x", Insertions: 1} + a.diffs = []model.Diff{diff} + a.currentDate = "2025-06-26 10:00" + + _, err := a.dispatchSubtasks(context.Background()) + if err != nil { + t.Fatalf("dispatchSubtasks: %v", err) + } + if client.calls != 1 { + t.Fatalf("LLM calls = %d, want 1", client.calls) + } + sess.Finalize() + + state, err := session.LoadResumeState(repoDir, sess.SessionID) + if err != nil { + t.Fatalf("LoadResumeState: %v", err) + } + if state.CompletedCount() != 0 { + t.Fatalf("CompletedCount = %d, want 0", state.CompletedCount()) + } + fp := reviewItemFingerprint(session.ReviewModeRange, diff) + if _, ok := state.Item(fp); ok { + t.Fatal("incomplete main task was recorded as a reusable checkpoint") + } + summary, items, err := session.LoadDetail(repoDir, sess.SessionID) + if err != nil { + t.Fatalf("LoadDetail: %v", err) + } + if summary.CompletedFiles != 0 || summary.FailedFiles != 1 { + t.Fatalf("summary counts = completed %d failed %d, want completed 0 failed 1", summary.CompletedFiles, summary.FailedFiles) + } + if len(items) != 1 || items[0].Type != "failed" || !strings.Contains(items[0].Error, "main_task did not complete") { + t.Fatalf("items = %+v, want one incomplete-main failed item", items) + } +} + func TestDispatchSubtasks_AllDeleted(t *testing.T) { client := &fakeAgentClient{} a := New(Args{ diff --git a/internal/agent/coverage_test.go b/internal/agent/coverage_test.go index f580942..018c86c 100644 --- a/internal/agent/coverage_test.go +++ b/internal/agent/coverage_test.go @@ -412,10 +412,16 @@ func TestExecuteSubtask_EmptyMainTask(t *testing.T) { }) a.currentDate = "2025-06-26 10:00" - err := a.executeSubtask(context.Background(), model.Diff{NewPath: "a.go", Diff: "+x", Insertions: 1}) + completed, skipReason, err := a.executeSubtask(context.Background(), model.Diff{NewPath: "a.go", Diff: "+x", Insertions: 1}) if err == nil { t.Fatal("expected error for empty main_task messages") } + if completed { + t.Fatal("empty main_task should not complete review") + } + if skipReason != "" { + t.Fatalf("skipReason = %q, want empty on error", skipReason) + } if !strings.Contains(err.Error(), "main_task.messages is empty") { t.Errorf("unexpected error: %v", err) } @@ -442,10 +448,16 @@ func TestExecuteSubtask_TokenThresholdExceeded(t *testing.T) { a.currentDate = "2025-06-26 10:00" a.diffs = []model.Diff{{NewPath: "a.go", Diff: strings.Repeat("code ", 200), Insertions: 100}} - err := a.executeSubtask(context.Background(), a.diffs[0]) + completed, skipReason, err := a.executeSubtask(context.Background(), a.diffs[0]) if err != nil { t.Fatalf("unexpected error: %v", err) } + if completed { + t.Fatal("token-threshold skip should not complete review") + } + if skipReason == "" { + t.Fatal("expected skip reason for token-threshold skip") + } warnings := a.Warnings() found := false @@ -514,10 +526,16 @@ func TestExecuteSubtask_WithPlanPhase(t *testing.T) { a.currentDate = "2025-06-26 10:00" a.diffs = []model.Diff{{NewPath: "main.go", OldPath: "main.go", Diff: "+new code", Insertions: 5}} - err := a.executeSubtask(context.Background(), a.diffs[0]) + completed, skipReason, err := a.executeSubtask(context.Background(), a.diffs[0]) if err != nil { t.Fatalf("executeSubtask: %v", err) } + if !completed { + t.Fatal("expected completed review") + } + if skipReason != "" { + t.Fatalf("skipReason = %q, want empty on completed review", skipReason) + } } func TestExecuteSubtask_ContextCancelled(t *testing.T) { @@ -538,10 +556,16 @@ func TestExecuteSubtask_ContextCancelled(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() - err := a.executeSubtask(ctx, model.Diff{NewPath: "a.go", Diff: "+x", Insertions: 1}) + completed, skipReason, err := a.executeSubtask(ctx, model.Diff{NewPath: "a.go", Diff: "+x", Insertions: 1}) if err == nil { t.Fatal("expected error for cancelled context") } + if completed { + t.Fatal("cancelled context should not complete review") + } + if skipReason != "" { + t.Fatalf("skipReason = %q, want empty on error", skipReason) + } } func TestExecuteReviewFilter_WithTimeout(t *testing.T) { diff --git a/internal/agent/util_test.go b/internal/agent/util_test.go index af3b8ff..caf030c 100644 --- a/internal/agent/util_test.go +++ b/internal/agent/util_test.go @@ -157,6 +157,9 @@ func TestCopyMessages(t *testing.T) { } cp = append(cp, llm.NewTextMessage("user", "c")) + if len(cp) != len(orig)+1 { + t.Errorf("appended copy length = %d, want %d", len(cp), len(orig)+1) + } if len(orig) != 2 { t.Error("copyMessages: appending to copy modified original slice") } diff --git a/internal/config/rules/rule_docs/python.md b/internal/config/rules/rule_docs/python.md new file mode 100644 index 0000000..644d2a0 --- /dev/null +++ b/internal/config/rules/rule_docs/python.md @@ -0,0 +1,76 @@ +> Favor precision over recall: only raise an issue when you are confident it is a real defect, and stay silent when the surrounding context is unclear — a false alarm costs more reviewer trust than a missed minor issue. Treat security and correctness findings as blocking, and style or idiom suggestions as non-blocking. + +#### Obvious Typos or Spelling Errors +- Spelling errors in variable, function, class, or module names at their declaration sites; do not report spelling errors at reference sites, as these are determined by the declaration +- Strings in log messages or exception messages containing spelling errors that affect readability + +#### Dead Code +- Code blocks that can never be reached (e.g., branches where the condition is always false, code after a `return`, `raise`, `break`, or `continue`) +- Variables, imports, or function parameters that are declared but never read or referenced +- Large blocks of commented-out code with no apparent intent to preserve + +#### Mutable Default Arguments and Shared State +- Mutable default arguments such as `def f(x=[])` or `def f(x={})`; the default is created once and shared across every call. Default to `None` and build the value inside the body +- Class-level mutable attributes shared unintentionally across instances when a per-instance value was intended +- Module-level mutable globals (lists, dicts, caches) mutated across requests or threads, retaining state in ways that surprise the caller +- Closures that capture a loop variable by reference and all end up seeing its final value +- Do not report when the function never mutates the argument, or when the shared default is a deliberate, documented cache or sentinel + +#### Boundary and Edge-Case Handling +- Empty inputs assumed to be non-empty: indexing `xs[0]`, `max()`/`min()`, or slicing without first handling the empty `list`, `str`, `dict`, or iterator +- Off-by-one and out-of-range access on indices, ranges, or slices, especially at the first/last element +- `None` reaching code that assumes a value, when an upstream call or default can legitimately return `None` (confirm the data source with `file_read` before flagging) +- Comparing floats for exact equality with `==`; use `math.isclose` or an explicit tolerance, since floating-point results are not exact +- Integer/float and division assumptions: unintended truncation with `//`, or `ZeroDivisionError` when a divisor can be zero +- Heterogeneous or unexpected element types in a collection that the code assumes are uniform (e.g., mixing `None`, numbers, and strings) +- Dictionary access by key without handling the missing-key case (`d[k]` vs `d.get(k)`), or set/dict operations that assume a key is present +- Do not report edge cases that a caller or type contract has already ruled out, or inputs that cannot occur given validated boundaries upstream + +#### Error Handling and Exceptions +- Bare `except:` swallows everything, including `KeyboardInterrupt` and `SystemExit`; catch `except Exception` at minimum, and prefer the specific exception types you expect +- `except Exception` that is still broader than the failure being handled; narrow it to the exceptions actually raised by the guarded call +- Exceptions caught and silently discarded (`pass`) without logging or re-raising +- Original traceback lost when re-raising; prefer `raise NewError(...) from err` to preserve the cause +- Broad `try` blocks that wrap far more than the line that can actually fail, hiding where the error originates +- `assert` used for runtime validation of external input — assertions are stripped under `python -O` + +#### Identity and Equality Comparisons +- Using `is`/`is not` to compare against literals such as strings, numbers, or tuples; this relies on implementation-specific interning rather than value equality — use `==` (a real correctness risk) +- Comparing against `True`/`False` with `==`, where a truthy-but-not-`True` value (e.g. `1`, a non-empty container) would compare unequal; prefer a plain truthiness check +- Reserve `is` for identity checks against singletons and sentinels +- Comparing against `None` with `==`/`!=` rather than `is`/`is not` is a style preference; report as minor, not blocking + +#### Resource Management +- Files, sockets, locks, or database connections opened without a `with` statement, risking leaks on early return or exception +- Context managers available but bypassed in favor of manual `open()`/`close()` pairs +- Resources acquired in a `try` whose `finally` cleanup is missing or incomplete on the error path +- Iterators or generators holding resources open longer than necessary +- Do not report short-lived scripts, or handles already managed by an enclosing `with` or framework-managed lifecycle (confirm the surrounding scope with `file_read` before flagging) + +#### Performance +Confirm data scale and that the code is on a hot path before flagging: +- Building strings with `+=` in a loop instead of accumulating in a list and `"".join(...)`, or using an f-string +- Repeated membership tests against a `list` where a `set` or `dict` would turn O(n) lookups into O(1) +- Building a full list when a generator would avoid holding everything in memory +- Recomputing inside a loop a value that is invariant across iterations (e.g., compiling a regex, attribute lookups in hot paths) +- Passing an eagerly formatted f-string to `logging` (e.g., `logging.info(f"...")`) instead of `logging.info("%s", value)`, which defeats lazy formatting when the level is disabled + +#### Concurrency and Async +Only flag concurrency issues when there is evidence of multi-threaded, multi-process, or async invocation (confirm the call context before reporting): +- CPU-bound work parallelized with `threading` under the GIL where `multiprocessing` or a process pool is the right tool (traditional CPython; free-threaded builds excepted); I/O-bound work is the case threads actually help +- Check-then-act races on shared state without a `Lock`, or non-atomic compound updates assumed to be atomic +- Blocking calls (synchronous I/O, `time.sleep`, `requests`, CPU-heavy work) inside `async def`, stalling the event loop; use the async equivalent or run them in an executor +- `asyncio` tasks created and never awaited, so exceptions are swallowed and the work may be garbage-collected before it finishes +- Shared mutable state across threads or tasks without synchronization or a thread-safe structure + +Do not report local variables (each thread has its own), read-only access to shared data, or code with no evidence of concurrent use. + +#### Security-Sensitive Code +Validate the data source before flagging; confirm the input is actually attacker-controlled rather than a trusted constant: +- `eval`, `exec`, or `compile` on untrusted input; this is arbitrary code execution +- `subprocess` with `shell=True` built from unsanitized input; pass an argument list and avoid the shell +- `pickle`, `marshal`, or `yaml.load` (without `SafeLoader`) on untrusted data; deserialization can execute arbitrary code +- SQL built by string concatenation or f-strings instead of parameterized queries +- Secrets, tokens, passwords, or PII written to logs or committed in source +- Weak or misused cryptography (`hashlib.md5`/`sha1` for passwords, `random` for security tokens); use `secrets` and vetted libraries +- Untrusted file paths joined without validation, allowing path traversal diff --git a/internal/config/rules/system_rules.json b/internal/config/rules/system_rules.json index 1f6bc4b..b16518f 100644 --- a/internal/config/rules/system_rules.json +++ b/internal/config/rules/system_rules.json @@ -18,6 +18,7 @@ "**/*.{kt}": "kotlin.md", "**/*.rs": "rust.md", "**/*.{cpp,cc,hpp}": "cpp.md", - "**/*.c": "c.md" + "**/*.c": "c.md", + "**/*.py": "python.md" } } diff --git a/internal/config/rules/system_rules_test.go b/internal/config/rules/system_rules_test.go index 5412382..cf54f3a 100644 --- a/internal/config/rules/system_rules_test.go +++ b/internal/config/rules/system_rules_test.go @@ -80,6 +80,8 @@ func TestResolve_DefaultRules(t *testing.T) { {"src/lib.rs", "Ownership and Lifetime Correctness"}, {"crates/service/src/main.rs", "Unsafe Code Boundaries"}, {"crates/service/Cargo.toml", "Cargo Manifest Hygiene"}, + {"scripts/deploy.py", "Mutable Default Arguments"}, + {"src/app/main.py", "Mutable Default Arguments"}, } for _, tt := range tests { @@ -104,7 +106,6 @@ func TestResolve_FallbackToDefault(t *testing.T) { "docs/architecture.txt", "Makefile", "internal/agent/agent.go", - "scripts/deploy.py", "ios/ViewController.m", } diff --git a/internal/config/toolsconfig/toolsconfig_test.go b/internal/config/toolsconfig/toolsconfig_test.go index bd25bb1..ce6bf30 100644 --- a/internal/config/toolsconfig/toolsconfig_test.go +++ b/internal/config/toolsconfig/toolsconfig_test.go @@ -31,7 +31,9 @@ func TestLoad_CustomFile(t *testing.T) { data := `[ {"name": "test_tool", "plan_task": true, "main_task": false, "definition": {"name": "test_tool"}} ]` - os.WriteFile(path, []byte(data), 0644) + if err := os.WriteFile(path, []byte(data), 0644); err != nil { + t.Fatalf("write tools.json: %v", err) + } tools, err := Load(path) if err != nil { @@ -61,7 +63,9 @@ func TestLoad_FileNotFound(t *testing.T) { func TestLoad_InvalidJSON(t *testing.T) { tmp := t.TempDir() path := filepath.Join(tmp, "tools.json") - os.WriteFile(path, []byte("not json"), 0644) + if err := os.WriteFile(path, []byte("not json"), 0644); err != nil { + t.Fatalf("write tools.json: %v", err) + } _, err := Load(path) if err == nil { diff --git a/internal/diff/relocation.go b/internal/diff/relocation.go index 1f296e3..d1afff5 100644 --- a/internal/diff/relocation.go +++ b/internal/diff/relocation.go @@ -10,6 +10,7 @@ import ( "github.com/open-code-review/open-code-review/internal/llm" "github.com/open-code-review/open-code-review/internal/model" "github.com/open-code-review/open-code-review/internal/stdout" + "github.com/open-code-review/open-code-review/internal/telemetry" ) // ReLocateComment calls the LLM to regenerate a precise existing_code snippet @@ -44,15 +45,26 @@ func ReLocateComment( messages = append(messages, llm.NewTextMessage(m.Role, content)) } + startTime := time.Now() + _, llmSpan := telemetry.StartLLMSpan(ctx, modelName) resp, err := client.CompletionsWithCtx(ctx, llm.ChatRequest{ Model: modelName, Messages: messages, MaxTokens: maxTokens, }) + duration := time.Since(startTime) if err != nil { + telemetry.RecordLLMResult(llmSpan, duration, 0, err) + llmSpan.End() fmt.Fprintf(stdout.Writer(), "[ocr] Re-location LLM call failed for %s: %v\n", cm.Path, err) return false, nil, messages } + var totalTokens int64 + if resp.Usage != nil { + totalTokens = resp.Usage.TotalTokens + } + telemetry.RecordLLMResult(llmSpan, duration, totalTokens, nil) + llmSpan.End() code := extractCodeBlock(resp.Content()) if code == "" { diff --git a/internal/llm/message_test.go b/internal/llm/message_test.go index 53ee185..4a03499 100644 --- a/internal/llm/message_test.go +++ b/internal/llm/message_test.go @@ -243,7 +243,9 @@ func TestParseShellRC_ModelOverride(t *testing.T) { export ANTHROPIC_AUTH_TOKEN="token" export ANTHROPIC_MODEL=claude-3-opus ` - os.WriteFile(rcPath, []byte(content), 0644) + if err := os.WriteFile(rcPath, []byte(content), 0644); err != nil { + t.Fatalf("write rc: %v", err) + } ep, ok, err := parseShellRC(rcPath, "override-model") if err != nil { @@ -265,7 +267,9 @@ func TestParseShellRC_Incomplete(t *testing.T) { export ANTHROPIC_AUTH_TOKEN="token" # missing ANTHROPIC_MODEL ` - os.WriteFile(rcPath, []byte(content), 0644) + if err := os.WriteFile(rcPath, []byte(content), 0644); err != nil { + t.Fatalf("write rc: %v", err) + } _, ok, err := parseShellRC(rcPath, "") if err != nil { diff --git a/internal/llm/resolver_test.go b/internal/llm/resolver_test.go index 5b2cace..6c933aa 100644 --- a/internal/llm/resolver_test.go +++ b/internal/llm/resolver_test.go @@ -104,7 +104,9 @@ func TestResolveEndpoint_ConfigFileStripsModelSuffix(t *testing.T) { } data, _ := json.Marshal(cfg) cfgPath := filepath.Join(t.TempDir(), "config.json") - os.WriteFile(cfgPath, data, 0644) + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } ep, err := ResolveEndpoint(cfgPath) if err != nil { @@ -137,7 +139,9 @@ func TestResolveEndpoint_ConfigAnthropicDefaultsToAuthorization(t *testing.T) { } data, _ := json.Marshal(cfg) cfgPath := filepath.Join(t.TempDir(), "config.json") - os.WriteFile(cfgPath, data, 0644) + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } ep, err := ResolveEndpoint(cfgPath) if err != nil { @@ -168,7 +172,9 @@ func TestResolveEndpoint_ConfigAuthHeaderOverrideToXAPIKey(t *testing.T) { } data, _ := json.Marshal(cfg) cfgPath := filepath.Join(t.TempDir(), "config.json") - os.WriteFile(cfgPath, data, 0644) + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } ep, err := ResolveEndpoint(cfgPath) if err != nil { @@ -199,7 +205,9 @@ func TestResolveEndpoint_ConfigOpenAIIgnoresAuthHeader(t *testing.T) { } data, _ := json.Marshal(cfg) cfgPath := filepath.Join(t.TempDir(), "config.json") - os.WriteFile(cfgPath, data, 0644) + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } ep, err := ResolveEndpoint(cfgPath) if err != nil { @@ -271,7 +279,9 @@ func TestResolveEndpoint_ProviderAnthropic(t *testing.T) { } data, _ := json.Marshal(cfg) cfgPath := filepath.Join(t.TempDir(), "config.json") - os.WriteFile(cfgPath, data, 0644) + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } ep, err := ResolveEndpoint(cfgPath) if err != nil { @@ -305,7 +315,9 @@ func TestResolveEndpoint_ProviderOpenAI(t *testing.T) { } data, _ := json.Marshal(cfg) cfgPath := filepath.Join(t.TempDir(), "config.json") - os.WriteFile(cfgPath, data, 0644) + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } ep, err := ResolveEndpoint(cfgPath) if err != nil { @@ -334,7 +346,9 @@ func TestResolveEndpoint_ProviderModelOverride(t *testing.T) { } data, _ := json.Marshal(cfg) cfgPath := filepath.Join(t.TempDir(), "config.json") - os.WriteFile(cfgPath, data, 0644) + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } ep, err := ResolveEndpoint(cfgPath) if err != nil { @@ -356,7 +370,9 @@ func TestResolveEndpoint_ProviderEntryModelOverridesDefault(t *testing.T) { } data, _ := json.Marshal(cfg) cfgPath := filepath.Join(t.TempDir(), "config.json") - os.WriteFile(cfgPath, data, 0644) + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } ep, err := ResolveEndpoint(cfgPath) if err != nil { @@ -383,7 +399,9 @@ func TestResolveEndpointWithModelOverride_CustomProviderWithoutConfiguredModel(t } data, _ := json.Marshal(cfg) cfgPath := filepath.Join(t.TempDir(), "config.json") - os.WriteFile(cfgPath, data, 0644) + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } ep, err := ResolveEndpointWithModelOverride(cfgPath, "llama-3-8b") if err != nil { @@ -409,7 +427,9 @@ func TestResolveEndpoint_ProviderAPIKeyEnvFallback(t *testing.T) { } data, _ := json.Marshal(cfg) cfgPath := filepath.Join(t.TempDir(), "config.json") - os.WriteFile(cfgPath, data, 0644) + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } ep, err := ResolveEndpoint(cfgPath) if err != nil { @@ -431,7 +451,9 @@ func TestResolveEndpoint_ProviderMissingAPIKey(t *testing.T) { } data, _ := json.Marshal(cfg) cfgPath := filepath.Join(t.TempDir(), "config.json") - os.WriteFile(cfgPath, data, 0644) + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } _, err := ResolveEndpoint(cfgPath) if err == nil { @@ -448,7 +470,9 @@ func TestResolveEndpoint_ProviderNotConfigured(t *testing.T) { } data, _ := json.Marshal(cfg) cfgPath := filepath.Join(t.TempDir(), "config.json") - os.WriteFile(cfgPath, data, 0644) + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } _, err := ResolveEndpoint(cfgPath) if err == nil { @@ -472,7 +496,9 @@ func TestResolveEndpoint_CustomProvider(t *testing.T) { } data, _ := json.Marshal(cfg) cfgPath := filepath.Join(t.TempDir(), "config.json") - os.WriteFile(cfgPath, data, 0644) + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } ep, err := ResolveEndpoint(cfgPath) if err != nil { @@ -508,7 +534,9 @@ func TestResolveEndpoint_CustomProviderInvalidProtocol(t *testing.T) { } data, _ := json.Marshal(cfg) cfgPath := filepath.Join(t.TempDir(), "config.json") - os.WriteFile(cfgPath, data, 0644) + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } _, err := ResolveEndpoint(cfgPath) if err == nil { @@ -531,7 +559,9 @@ func TestResolveEndpoint_CustomProviderMissingFields(t *testing.T) { } data, _ := json.Marshal(cfg) cfgPath := filepath.Join(t.TempDir(), "config.json") - os.WriteFile(cfgPath, data, 0644) + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } _, err := ResolveEndpoint(cfgPath) if err == nil { @@ -555,7 +585,9 @@ func TestResolveEndpoint_CustomProviderModelFromTopLevel(t *testing.T) { } data, _ := json.Marshal(cfg) cfgPath := filepath.Join(t.TempDir(), "config.json") - os.WriteFile(cfgPath, data, 0644) + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } ep, err := ResolveEndpoint(cfgPath) if err != nil { @@ -578,7 +610,9 @@ func TestResolveEndpoint_LegacyLlmStillWorks(t *testing.T) { } data, _ := json.Marshal(cfg) cfgPath := filepath.Join(t.TempDir(), "config.json") - os.WriteFile(cfgPath, data, 0644) + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } ep, err := ResolveEndpoint(cfgPath) if err != nil { @@ -603,7 +637,9 @@ func TestResolveEndpoint_ProviderAnthropicURLHasMessagesSuffix(t *testing.T) { } data, _ := json.Marshal(cfg) cfgPath := filepath.Join(t.TempDir(), "config.json") - os.WriteFile(cfgPath, data, 0644) + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } ep, err := ResolveEndpoint(cfgPath) if err != nil { @@ -629,7 +665,9 @@ func TestResolveEndpoint_ProviderExtraBody(t *testing.T) { } data, _ := json.Marshal(cfg) cfgPath := filepath.Join(t.TempDir(), "config.json") - os.WriteFile(cfgPath, data, 0644) + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } ep, err := ResolveEndpoint(cfgPath) if err != nil { @@ -654,7 +692,9 @@ func TestResolveEndpointWithModelOverride_ValidModelInPresetList(t *testing.T) { } data, _ := json.Marshal(cfg) cfgPath := filepath.Join(t.TempDir(), "config.json") - os.WriteFile(cfgPath, data, 0644) + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } ep, err := ResolveEndpointWithModelOverride(cfgPath, "claude-opus-4-8") if err != nil { @@ -676,7 +716,9 @@ func TestResolveEndpointWithModelOverride_InvalidModelInPresetList(t *testing.T) } data, _ := json.Marshal(cfg) cfgPath := filepath.Join(t.TempDir(), "config.json") - os.WriteFile(cfgPath, data, 0644) + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } _, err := ResolveEndpointWithModelOverride(cfgPath, "claude-opsu-4-6") if err == nil { @@ -706,7 +748,9 @@ func TestResolveEndpointWithModelOverride_ValidModelInCustomProviderList(t *test } data, _ := json.Marshal(cfg) cfgPath := filepath.Join(t.TempDir(), "config.json") - os.WriteFile(cfgPath, data, 0644) + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } ep, err := ResolveEndpointWithModelOverride(cfgPath, "llama-3-8b") if err != nil { @@ -733,7 +777,9 @@ func TestResolveEndpointWithModelOverride_InvalidModelInCustomProviderList(t *te } data, _ := json.Marshal(cfg) cfgPath := filepath.Join(t.TempDir(), "config.json") - os.WriteFile(cfgPath, data, 0644) + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } _, err := ResolveEndpointWithModelOverride(cfgPath, "gpt-4") if err == nil { @@ -760,7 +806,9 @@ func TestResolveEndpointWithModelOverride_NoValidationWhenNoModelList(t *testing } data, _ := json.Marshal(cfg) cfgPath := filepath.Join(t.TempDir(), "config.json") - os.WriteFile(cfgPath, data, 0644) + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } ep, err := ResolveEndpointWithModelOverride(cfgPath, "any-model-name") if err != nil { @@ -785,7 +833,9 @@ func TestResolveEndpointWithModelOverride_MergesPresetAndEntryModels(t *testing. } data, _ := json.Marshal(cfg) cfgPath := filepath.Join(t.TempDir(), "config.json") - os.WriteFile(cfgPath, data, 0644) + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } // Should accept both preset models and entry models. ep1, err := ResolveEndpointWithModelOverride(cfgPath, "claude-opus-4-8") @@ -823,7 +873,9 @@ func TestResolveEndpointWithModelOverride_LegacyConfigNoValidation(t *testing.T) } data, _ := json.Marshal(cfg) cfgPath := filepath.Join(t.TempDir(), "config.json") - os.WriteFile(cfgPath, data, 0644) + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } // Legacy config has no model list, so any override should be accepted. ep, err := ResolveEndpointWithModelOverride(cfgPath, "any-override-model") @@ -1087,7 +1139,9 @@ func TestResolveEndpoint_ProviderExtraHeaders(t *testing.T) { } data, _ := json.Marshal(cfg) cfgPath := filepath.Join(t.TempDir(), "config.json") - os.WriteFile(cfgPath, data, 0644) + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } ep, err := ResolveEndpoint(cfgPath) if err != nil { @@ -1114,7 +1168,9 @@ func TestResolveEndpoint_LegacyLlmExtraHeaders(t *testing.T) { } data, _ := json.Marshal(cfg) cfgPath := filepath.Join(t.TempDir(), "config.json") - os.WriteFile(cfgPath, data, 0644) + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } ep, err := ResolveEndpoint(cfgPath) if err != nil { @@ -1218,7 +1274,9 @@ func TestResolveEndpoint_ConfigTimeoutSec(t *testing.T) { } data, _ := json.Marshal(cfg) cfgPath := filepath.Join(t.TempDir(), "config.json") - os.WriteFile(cfgPath, data, 0644) + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } ep, err := ResolveEndpoint(cfgPath) if err != nil { @@ -1242,7 +1300,9 @@ func TestResolveEndpoint_NegativeConfigTimeoutSec(t *testing.T) { } data, _ := json.Marshal(cfg) cfgPath := filepath.Join(t.TempDir(), "config.json") - os.WriteFile(cfgPath, data, 0644) + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } _, err := ResolveEndpoint(cfgPath) if err == nil { @@ -1308,7 +1368,9 @@ func TestResolveEndpoint_ProviderConfigTimeoutSec(t *testing.T) { } data, _ := json.Marshal(cfg) cfgPath := filepath.Join(t.TempDir(), "config.json") - os.WriteFile(cfgPath, data, 0644) + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } ep, err := ResolveEndpoint(cfgPath) if err != nil { @@ -1334,7 +1396,9 @@ func TestResolveEndpoint_ProviderConfigNegativeTimeoutSec(t *testing.T) { } data, _ := json.Marshal(cfg) cfgPath := filepath.Join(t.TempDir(), "config.json") - os.WriteFile(cfgPath, data, 0644) + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } _, err := ResolveEndpoint(cfgPath) if err == nil { @@ -1359,7 +1423,9 @@ func TestResolveEndpoint_EnvTimeoutOverridesConfigTimeout(t *testing.T) { } data, _ := json.Marshal(cfg) cfgPath := filepath.Join(t.TempDir(), "config.json") - os.WriteFile(cfgPath, data, 0644) + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } ep, err := ResolveEndpoint(cfgPath) if err != nil { @@ -1387,7 +1453,9 @@ func TestResolveEndpoint_EnvTimeoutOverridesProviderTimeout(t *testing.T) { } data, _ := json.Marshal(cfg) cfgPath := filepath.Join(t.TempDir(), "config.json") - os.WriteFile(cfgPath, data, 0644) + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } ep, err := ResolveEndpoint(cfgPath) if err != nil { @@ -1412,7 +1480,9 @@ func TestResolveEndpoint_InvalidEnvTimeoutWithConfig(t *testing.T) { } data, _ := json.Marshal(cfg) cfgPath := filepath.Join(t.TempDir(), "config.json") - os.WriteFile(cfgPath, data, 0644) + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } _, err := ResolveEndpoint(cfgPath) if err == nil { @@ -1436,7 +1506,9 @@ func TestResolveEndpoint_NegativeEnvTimeoutWithConfig(t *testing.T) { } data, _ := json.Marshal(cfg) cfgPath := filepath.Join(t.TempDir(), "config.json") - os.WriteFile(cfgPath, data, 0644) + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } _, err := ResolveEndpoint(cfgPath) if err == nil { diff --git a/internal/llm/usage_resolver.go b/internal/llm/usage_resolver.go index 1345420..f6411e1 100644 --- a/internal/llm/usage_resolver.go +++ b/internal/llm/usage_resolver.go @@ -27,24 +27,37 @@ var completionTokensPaths = []string{ } var cacheReadTokensPaths = []string{ - "usage.cache_read_input_tokens", // Anthropic - "cache_read_input_tokens", // flat at root - "usage.prompt_tokens_details.cache_tokens_hit", // some providers - "usage.prompt_tokens_details.cache_tokens", // some providers + "usage.cache_read_input_tokens", // Anthropic + "cache_read_input_tokens", // flat at root + "data.usage.cache_read_input_tokens", // wrapped Anthropic-compatible proxy + "usage.prompt_tokens_details.cached_tokens", // OpenAI-compatible providers + "data.usage.prompt_tokens_details.cached_tokens", // wrapped OpenAI-compatible providers } var cacheWriteTokensPaths = []string{ - "usage.cache_creation_input_tokens", // Anthropic / proxy - "cache_creation_input_tokens", // flat at root + "usage.cache_creation_input_tokens", // Anthropic / proxy + "cache_creation_input_tokens", // flat at root + "data.usage.cache_creation_input_tokens", // wrapped Anthropic-compatible proxy + "usage.prompt_tokens_details.cache_creation_tokens", // ApexRoute / LLM Gateway — proxy normalization of Anthropic cache_creation_input_tokens + "data.usage.prompt_tokens_details.cache_creation_tokens", // wrapped proxy normalization } +// anthropicCacheReadPathCount is the number of Anthropic-style cache read paths +// at the start of cacheReadTokensPaths. OpenAI-style paths follow; under OpenAI +// semantics cached tokens are already included in prompt_tokens. +const anthropicCacheReadPathCount = 3 + +// anthropicCacheWritePathCount is the number of Anthropic-style cache write paths +// at the start of cacheWriteTokensPaths. +const anthropicCacheWritePathCount = 3 + // totalTokensPaths is an ordered list of JSON paths to try when extracting // total token count from a response body. Paths are dot-separated keys that // navigate through nested map[string]any objects. The first match wins. var totalTokensPaths = []string{ "usage.total_tokens", // OpenAI standard "total_tokens", // flat at root - "data.usage.total_tokens", // wrapped in data layer (some proxy APIs) + "data.usage.total_tokens", // wrapped in data layer } // resolveUsage parses raw JSON bytes into a map and extracts token usage @@ -58,8 +71,8 @@ func resolveUsage(raw []byte) *UsageInfo { total, hasAny := probePath(rawBody, totalTokensPaths) prompt, _ := probePath(rawBody, promptTokensPaths) completion, _ := probePath(rawBody, completionTokensPaths) - cacheRead, _ := probePath(rawBody, cacheReadTokensPaths) - cacheWrite, _ := probePath(rawBody, cacheWriteTokensPaths) + cacheRead, cacheReadIdx, _ := probePathIndex(rawBody, cacheReadTokensPaths) + cacheWrite, cacheWriteIdx, _ := probePathIndex(rawBody, cacheWriteTokensPaths) if !hasAny && prompt == 0 && completion == 0 { return nil @@ -74,8 +87,17 @@ func resolveUsage(raw []byte) *UsageInfo { } // If TotalTokens wasn't explicitly available but we have prompt+completion, compute it. + // Anthropic reports cache tokens separately from input_tokens, so include them in the + // fallback total. OpenAI prompt_tokens already includes cached_tokens, so only add cache + // counts when they came from Anthropic-style top-level fields. if total == 0 && (prompt > 0 || completion > 0) { - ui.TotalTokens = prompt + completion + cacheRead + cacheWrite + ui.TotalTokens = prompt + completion + if cacheReadIdx >= 0 && cacheReadIdx < anthropicCacheReadPathCount { + ui.TotalTokens += cacheRead + } + if cacheWriteIdx >= 0 && cacheWriteIdx < anthropicCacheWritePathCount { + ui.TotalTokens += cacheWrite + } } return ui @@ -84,7 +106,13 @@ func resolveUsage(raw []byte) *UsageInfo { // probePath walks through each candidate path in order, returning the first // int64 value found along with true. Returns (0, false) if none match. func probePath(root map[string]any, paths []string) (int64, bool) { - for _, p := range paths { + v, _, ok := probePathIndex(root, paths) + return v, ok +} + +// probePathIndex is like probePath but also returns the index of the matched path. +func probePathIndex(root map[string]any, paths []string) (int64, int, bool) { + for i, p := range paths { parts := strings.Split(p, ".") var current any = root @@ -101,13 +129,13 @@ func probePath(root map[string]any, paths []string) (int64, bool) { switch v := current.(type) { case float64: - return int64(v), true + return int64(v), i, true case int64: - return v, true + return v, i, true case int: - return int64(v), true + return int64(v), i, true } next: } - return 0, false + return 0, -1, false } diff --git a/internal/llm/usage_resolver_test.go b/internal/llm/usage_resolver_test.go new file mode 100644 index 0000000..0c9397a --- /dev/null +++ b/internal/llm/usage_resolver_test.go @@ -0,0 +1,123 @@ +package llm + +import "testing" + +func TestResolveUsageOpenAICompatibleCachedTokens(t *testing.T) { + usage := resolveUsage([]byte(`{ + "usage": { + "prompt_tokens": 100, + "completion_tokens": 20, + "total_tokens": 120, + "prompt_tokens_details": { + "cached_tokens": 75 + } + } + }`)) + + if usage == nil { + t.Fatal("resolveUsage returned nil") + } + if usage.CacheReadTokens != 75 { + t.Errorf("CacheReadTokens = %d, want 75", usage.CacheReadTokens) + } + if usage.PromptTokens != 100 { + t.Errorf("PromptTokens = %d, want 100", usage.PromptTokens) + } + if usage.CompletionTokens != 20 { + t.Errorf("CompletionTokens = %d, want 20", usage.CompletionTokens) + } +} + +func TestResolveUsageWrappedCachedTokens(t *testing.T) { + usage := resolveUsage([]byte(`{ + "data": { + "usage": { + "prompt_tokens": 100, + "completion_tokens": 20, + "prompt_tokens_details": { + "cached_tokens": 75, + "cache_creation_tokens": 10 + } + } + } + }`)) + + if usage == nil { + t.Fatal("resolveUsage returned nil") + } + if usage.CacheReadTokens != 75 { + t.Errorf("CacheReadTokens = %d, want 75", usage.CacheReadTokens) + } + if usage.CacheWriteTokens != 10 { + t.Errorf("CacheWriteTokens = %d, want 10", usage.CacheWriteTokens) + } + if usage.TotalTokens != 120 { + t.Errorf("TotalTokens = %d, want 120 (OpenAI cached tokens are included in prompt_tokens)", usage.TotalTokens) + } +} + +func TestResolveUsageWrappedAnthropicCompatibleCacheTokens(t *testing.T) { + usage := resolveUsage([]byte(`{ + "data": { + "usage": { + "prompt_tokens": 100, + "completion_tokens": 20, + "cache_read_input_tokens": 40, + "cache_creation_input_tokens": 15 + } + } + }`)) + + if usage == nil { + t.Fatal("resolveUsage returned nil") + } + if usage.CacheReadTokens != 40 { + t.Errorf("CacheReadTokens = %d, want 40", usage.CacheReadTokens) + } + if usage.CacheWriteTokens != 15 { + t.Errorf("CacheWriteTokens = %d, want 15", usage.CacheWriteTokens) + } + if usage.TotalTokens != 175 { + t.Errorf("TotalTokens = %d, want 175", usage.TotalTokens) + } +} + +func TestResolveUsageCacheReadPathPriority(t *testing.T) { + usage := resolveUsage([]byte(`{ + "usage": { + "prompt_tokens": 100, + "completion_tokens": 20, + "cache_read_input_tokens": 40, + "prompt_tokens_details": { + "cached_tokens": 75 + } + } + }`)) + + if usage == nil { + t.Fatal("resolveUsage returned nil") + } + if usage.CacheReadTokens != 40 { + t.Errorf("CacheReadTokens = %d, want 40 (Anthropic path should win)", usage.CacheReadTokens) + } +} + +func TestResolveUsageCacheCreationTokensPriority(t *testing.T) { + usage := resolveUsage([]byte(`{ + "usage": { + "prompt_tokens": 100, + "completion_tokens": 20, + "cache_creation_input_tokens": 30, + "prompt_tokens_details": { + "cache_creation_tokens": 15 + } + } + }`)) + + if usage == nil { + t.Fatal("resolveUsage returned nil") + } + if usage.CacheWriteTokens != 30 { + t.Errorf("CacheWriteTokens = %d, want 30 (Anthropic top-level path should win over prompt_tokens_details)", usage.CacheWriteTokens) + } +} diff --git a/internal/llmloop/loop.go b/internal/llmloop/loop.go index d206a78..ee0fe6a 100644 --- a/internal/llmloop/loop.go +++ b/internal/llmloop/loop.go @@ -144,8 +144,9 @@ func (r *Runner) CollectPendingComments() []model.LlmComment { // It sends messages with the configured tool definitions, executes any // tool calls returned by the model, and collects review comments until // task_done is called or limits are reached. Token usage and warnings -// are aggregated on the Runner across all files. -func (r *Runner) RunPerFile(ctx context.Context, messages []llm.Message, newPath string) error { +// are aggregated on the Runner across all files. The returned bool is true +// only when the model explicitly calls task_done. +func (r *Runner) RunPerFile(ctx context.Context, messages []llm.Message, newPath string) (bool, error) { toolReqCount := r.deps.Template.MaxToolRequestTimes const maxConsecutiveEmptyRounds = 3 consecutiveEmptyRounds := 0 @@ -153,7 +154,7 @@ func (r *Runner) RunPerFile(ctx context.Context, messages []llm.Message, newPath for toolReqCount > 0 { select { case <-ctx.Done(): - return ctx.Err() + return false, ctx.Err() default: } @@ -163,6 +164,7 @@ func (r *Runner) RunPerFile(ctx context.Context, messages []llm.Message, newPath rec := fs.AppendTaskRecord(session.MainTask, append([]llm.Message(nil), messages...)) startTime := time.Now() + _, llmSpan := telemetry.StartLLMSpan(ctx, r.deps.Model) resp, err := r.deps.LLMClient.CompletionsWithCtx(ctx, llm.ChatRequest{ Model: r.deps.Model, Messages: messages, @@ -172,8 +174,10 @@ func (r *Runner) RunPerFile(ctx context.Context, messages []llm.Message, newPath duration := time.Since(startTime) if err != nil { rec.SetError(err, duration) + telemetry.RecordLLMResult(llmSpan, duration, 0, err) + llmSpan.End() telemetry.RecordLLMRequest(ctx, r.deps.Model, duration, 0, "error") - return fmt.Errorf("LLM completion error: %w", err) + return false, fmt.Errorf("LLM completion error: %w", err) } rec.SetResponse(resp, duration) totalTokens := int64(0) @@ -184,6 +188,8 @@ func (r *Runner) RunPerFile(ctx context.Context, messages []llm.Message, newPath atomic.AddInt64(&r.totalCacheReadTokens, resp.Usage.CacheReadTokens) atomic.AddInt64(&r.totalCacheWriteTokens, resp.Usage.CacheWriteTokens) } + telemetry.RecordLLMResult(llmSpan, duration, totalTokens, nil) + llmSpan.End() telemetry.RecordLLMRequest(ctx, r.deps.Model, duration, totalTokens, "ok") content := resp.Content() @@ -228,7 +234,7 @@ func (r *Runner) RunPerFile(ctx context.Context, messages []llm.Message, newPath } if taskCompleted { - break + return true, nil } if !hasValidResult { consecutiveEmptyRounds++ @@ -251,7 +257,7 @@ func (r *Runner) RunPerFile(ctx context.Context, messages []llm.Message, newPath if toolReqCount <= 0 { fmt.Fprintf(stdout.Writer(), "[ocr] Max tool requests reached for %s.\n", newPath) } - return nil + return false, nil } // executeToolCall dispatches a single tool call from the LLM response and @@ -272,14 +278,19 @@ func (r *Runner) executeToolCall(ctx context.Context, newPath string, call llm.T return tool.Of(fmt.Sprintf("Error parsing tool arguments for %s: %v", call.Function.Name, err)) } telemetry.PrintToolCallStarted(call.Function.Name, dynArgs) + _, toolSpan := telemetry.StartToolSpan(ctx, call.Function.Name) startTime := time.Now() result, err := p.Execute(ctx, dynArgs) dur := time.Since(startTime) if err != nil { + telemetry.RecordToolResult(toolSpan, call.Function.Name, dur.Milliseconds(), err) + toolSpan.End() telemetry.RecordToolCall(ctx, call.Function.Name, dur, false) telemetry.PrintToolCallError(call.Function.Name, err) return tool.Of(fmt.Sprintf("Error executing tool %s: %v", call.Function.Name, err)) } + telemetry.RecordToolResult(toolSpan, call.Function.Name, dur.Milliseconds(), nil) + toolSpan.End() telemetry.RecordToolCall(ctx, call.Function.Name, dur, true) telemetry.PrintToolCallFinished(call.Function.Name, dur) if rec != nil { @@ -314,10 +325,14 @@ func (r *Runner) executeToolCall(ctx context.Context, newPath string, call llm.T if t == tool.CodeComment { telemetry.PrintToolCallStarted(t.Name(), args) + _, toolSpan := telemetry.StartToolSpan(ctx, t.Name()) comments, errMsg := tool.ParseComments(args) if errMsg != "" { - telemetry.RecordToolCall(ctx, t.Name(), time.Since(startTime), false) + dur := time.Since(startTime) + telemetry.RecordToolResult(toolSpan, t.Name(), dur.Milliseconds(), fmt.Errorf("%s", errMsg)) + toolSpan.End() + telemetry.RecordToolCall(ctx, t.Name(), dur, false) return tool.Of(errMsg) } @@ -361,8 +376,13 @@ func (r *Runner) executeToolCall(ctx context.Context, newPath string, call llm.T asyncCtx := context.WithoutCancel(ctx) toolName := t.Name() pool.Submit(func() ([]model.LlmComment, error) { + defer func() { + dur := time.Since(startTime) + telemetry.RecordToolResult(toolSpan, toolName, dur.Milliseconds(), nil) + toolSpan.End() + telemetry.PrintToolCallFinished(toolName, dur) + }() resolveAndCollect(asyncCtx) - telemetry.PrintToolCallFinished(toolName, time.Since(startTime)) return []model.LlmComment{}, nil }) telemetry.RecordToolCall(asyncCtx, toolName, time.Since(startTime), true) @@ -371,6 +391,8 @@ func (r *Runner) executeToolCall(ctx context.Context, newPath string, call llm.T resolveAndCollect(ctx) dur := time.Since(startTime) + telemetry.RecordToolResult(toolSpan, t.Name(), dur.Milliseconds(), nil) + toolSpan.End() telemetry.RecordToolCall(ctx, t.Name(), dur, true) telemetry.PrintToolCallFinished(t.Name(), dur) if rec != nil { @@ -381,9 +403,12 @@ func (r *Runner) executeToolCall(ctx context.Context, newPath string, call llm.T // Synchronous path for all other tools telemetry.PrintToolCallStarted(t.Name(), args) + _, toolSpan := telemetry.StartToolSpan(ctx, t.Name()) result, err := p.Execute(ctx, args) dur := time.Since(startTime) ok := err == nil + telemetry.RecordToolResult(toolSpan, t.Name(), dur.Milliseconds(), err) + toolSpan.End() telemetry.RecordToolCall(ctx, t.Name(), dur, ok) if err != nil { diff --git a/internal/llmloop/loop_test.go b/internal/llmloop/loop_test.go index 5b6d449..352fc20 100644 --- a/internal/llmloop/loop_test.go +++ b/internal/llmloop/loop_test.go @@ -99,10 +99,13 @@ func TestRunPerFile_TaskDoneImmediately(t *testing.T) { runner := NewRunner(deps) msgs := []llm.Message{llm.NewTextMessage("user", "review this file")} - err := runner.RunPerFile(context.Background(), msgs, "main.go") + completed, err := runner.RunPerFile(context.Background(), msgs, "main.go") if err != nil { t.Fatalf("RunPerFile: %v", err) } + if !completed { + t.Fatal("expected task_done to complete RunPerFile") + } if client.calls != 1 { t.Errorf("expected 1 LLM call, got %d", client.calls) } @@ -123,10 +126,13 @@ func TestRunPerFile_ToolCallThenDone(t *testing.T) { runner := NewRunner(deps) msgs := []llm.Message{llm.NewTextMessage("user", "review")} - err := runner.RunPerFile(context.Background(), msgs, "main.go") + completed, err := runner.RunPerFile(context.Background(), msgs, "main.go") if err != nil { t.Fatalf("RunPerFile: %v", err) } + if !completed { + t.Fatal("expected task_done to complete RunPerFile") + } if client.calls != 2 { t.Errorf("expected 2 LLM calls, got %d", client.calls) } @@ -149,10 +155,13 @@ func TestRunPerFile_ContextCancelled(t *testing.T) { cancel() msgs := []llm.Message{llm.NewTextMessage("user", "review")} - err := runner.RunPerFile(ctx, msgs, "main.go") + completed, err := runner.RunPerFile(ctx, msgs, "main.go") if err == nil { t.Error("expected error for cancelled context") } + if completed { + t.Fatal("cancelled context should not complete RunPerFile") + } } func TestRunPerFile_UnknownTool(t *testing.T) { @@ -179,15 +188,39 @@ func TestRunPerFile_UnknownTool(t *testing.T) { runner := NewRunner(deps) msgs := []llm.Message{llm.NewTextMessage("user", "review")} - err := runner.RunPerFile(context.Background(), msgs, "main.go") + completed, err := runner.RunPerFile(context.Background(), msgs, "main.go") if err != nil { t.Fatalf("RunPerFile: %v", err) } + if !completed { + t.Fatal("expected task_done to complete RunPerFile") + } if client.calls != 2 { t.Errorf("expected 2 calls, got %d", client.calls) } } +func TestRunPerFile_MaxToolRequestsWithoutTaskDoneDoesNotComplete(t *testing.T) { + content := "" + client := &fakeClient{responses: []*llm.ChatResponse{{ + Choices: []llm.Choice{{Message: llm.ResponseMessage{Content: &content}}}, + Model: "fake", + Usage: &llm.UsageInfo{PromptTokens: 5, CompletionTokens: 5}, + }}} + deps := newTestDeps(client) + deps.Template.MaxToolRequestTimes = 1 + runner := NewRunner(deps) + + msgs := []llm.Message{llm.NewTextMessage("user", "review")} + completed, err := runner.RunPerFile(context.Background(), msgs, "main.go") + if err != nil { + t.Fatalf("RunPerFile: %v", err) + } + if completed { + t.Fatal("RunPerFile completed without task_done") + } +} + func TestRunner_RecordWarning(t *testing.T) { deps := newTestDeps(&fakeClient{}) runner := NewRunner(deps) diff --git a/internal/mcp/client_test.go b/internal/mcp/client_test.go index 07aee15..c5f04da 100644 --- a/internal/mcp/client_test.go +++ b/internal/mcp/client_test.go @@ -113,7 +113,7 @@ func TestProvider_Execute_Integration(t *testing.T) { } c := &Client{name: "test-srv", session: session, tools: toolsResult.Tools} - defer c.Close() + defer func() { _ = c.Close() }() p := &Provider{toolName: "greet", client: c} result, err := p.Execute(ctx, map[string]any{"name": "world"}) @@ -193,7 +193,7 @@ func TestNewClient_Integration(t *testing.T) { session: session, tools: toolsResult.Tools, } - defer c.Close() + defer func() { _ = c.Close() }() if c.Name() != "test-srv" { t.Errorf("Name() = %q", c.Name()) diff --git a/internal/mcp/stdio_test.go b/internal/mcp/stdio_test.go index ea9f3d0..b851cad 100644 --- a/internal/mcp/stdio_test.go +++ b/internal/mcp/stdio_test.go @@ -68,7 +68,7 @@ func TestNewClient_Stdio(t *testing.T) { if err != nil { t.Fatalf("NewClient: %v", err) } - defer c.Close() + defer func() { _ = c.Close() }() if c.Name() != "test-srv" { t.Errorf("Name() = %q, want %q", c.Name(), "test-srv") diff --git a/internal/model/model_test.go b/internal/model/model_test.go index 8da62b8..5ac6273 100644 --- a/internal/model/model_test.go +++ b/internal/model/model_test.go @@ -65,14 +65,18 @@ func TestPreviewEntry_ExcludeReasonOmitEmpty(t *testing.T) { } data, _ := json.Marshal(e) var m map[string]any - json.Unmarshal(data, &m) + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("unmarshal: %v", err) + } if _, ok := m["exclude_reason"]; ok { t.Error("expected exclude_reason to be omitted when empty") } e.ExcludeReason = ExcludeUserRule data, _ = json.Marshal(e) - json.Unmarshal(data, &m) + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("unmarshal: %v", err) + } if m["exclude_reason"] != "user_exclude" { t.Errorf("expected exclude_reason=user_exclude, got %v", m["exclude_reason"]) } diff --git a/internal/pathutil/path_test.go b/internal/pathutil/path_test.go index 3664e75..ecc4985 100644 --- a/internal/pathutil/path_test.go +++ b/internal/pathutil/path_test.go @@ -72,7 +72,7 @@ func TestCanonicalPath_RelativePath(t *testing.T) { if err != nil { t.Fatalf("Getwd: %v", err) } - t.Cleanup(func() { os.Chdir(oldWd) }) + t.Cleanup(func() { _ = os.Chdir(oldWd) }) if err := os.Chdir(dir); err != nil { t.Fatalf("Chdir: %v", err) } diff --git a/internal/scan/agent.go b/internal/scan/agent.go index d9095e9..aa0b834 100644 --- a/internal/scan/agent.go +++ b/internal/scan/agent.go @@ -154,6 +154,14 @@ func toLoopTemplate(s template.ScanTemplate) template.Template { // Session returns the session history associated with this Agent. func (a *Agent) Session() *session.SessionHistory { return a.session } +// SessionID returns the current scan's session id, or "" when no session has been created. +func (a *Agent) SessionID() string { + if a == nil || a.session == nil { + return "" + } + return a.session.SessionID +} + // FilesReviewed returns the number of items included in this scan. func (a *Agent) FilesReviewed() int64 { return int64(len(a.items)) } @@ -553,7 +561,8 @@ func (a *Agent) executeSubtask(ctx context.Context, it model.ScanItem) error { return nil } - return a.runner.RunPerFile(ctx, messages, it.Path) + _, err := a.runner.RunPerFile(ctx, messages, it.Path) + return err } // maybeRunPlan invokes PLAN_TASK on the file and returns a human-readable diff --git a/internal/session/history.go b/internal/session/history.go index 6ece4da..c64ac88 100644 --- a/internal/session/history.go +++ b/internal/session/history.go @@ -10,6 +10,7 @@ import ( "time" "github.com/open-code-review/open-code-review/internal/llm" + "github.com/open-code-review/open-code-review/internal/model" ) // TaskType identifies the kind of LLM request within a file subtask. @@ -42,6 +43,7 @@ type SessionHistory struct { DiffFrom string DiffTo string DiffCommit string + ResumedFrom string StartTime time.Time EndTime time.Time persist *jsonlWriter @@ -96,10 +98,11 @@ type ToolResultRecord struct { // SessionOptions holds optional metadata for a new session. type SessionOptions struct { - ReviewMode string - DiffFrom string - DiffTo string - DiffCommit string + ReviewMode string + DiffFrom string + DiffTo string + DiffCommit string + ResumedFrom string } // New creates a new SessionHistory with the given repo directory. @@ -114,6 +117,7 @@ func New(repoDir, gitBranch, model string, opts SessionOptions) *SessionHistory DiffFrom: opts.DiffFrom, DiffTo: opts.DiffTo, DiffCommit: opts.DiffCommit, + ResumedFrom: opts.ResumedFrom, StartTime: time.Now(), FileSessions: make(map[string]*FileSession), } @@ -147,6 +151,54 @@ func (sh *SessionHistory) GetOrCreateFileSession(filePath string) *FileSession { return fs } +// RecordReviewItemDone persists the file-level checkpoint used by resume. +func (sh *SessionHistory) RecordReviewItemDone(filePath, oldPath, newPath, fingerprint string, comments []model.LlmComment) { + if sh == nil { + return + } + if filePath == "" { + filePath = newPath + } + if filePath != "" { + sh.GetOrCreateFileSession(filePath) + } + if p := sh.persist; p != nil { + p.WriteReviewItemDone(filePath, oldPath, newPath, fingerprint, comments) + } +} + +// RecordReviewItemReused records that this run reused a checkpoint from another session. +func (sh *SessionHistory) RecordReviewItemReused(filePath, oldPath, newPath, fingerprint, sourceSessionID string, comments []model.LlmComment) { + if sh == nil { + return + } + if filePath == "" { + filePath = newPath + } + if filePath != "" { + sh.GetOrCreateFileSession(filePath) + } + if p := sh.persist; p != nil { + p.WriteReviewItemReused(filePath, oldPath, newPath, fingerprint, sourceSessionID, comments) + } +} + +// RecordReviewItemFailed persists an incomplete file-level checkpoint. +func (sh *SessionHistory) RecordReviewItemFailed(filePath, oldPath, newPath, fingerprint, errorMsg string) { + if sh == nil { + return + } + if filePath == "" { + filePath = newPath + } + if filePath != "" { + sh.GetOrCreateFileSession(filePath) + } + if p := sh.persist; p != nil { + p.WriteReviewItemFailed(filePath, oldPath, newPath, fingerprint, errorMsg) + } +} + // Finalize marks the session as complete, sets the end time, and persists // the final summary record. func (sh *SessionHistory) Finalize() { diff --git a/internal/session/list.go b/internal/session/list.go new file mode 100644 index 0000000..3e91494 --- /dev/null +++ b/internal/session/list.go @@ -0,0 +1,293 @@ +package session + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +// Summary is a compact digest of one persisted session, suitable for +// listing recent runs. +type Summary struct { + SessionID string `json:"session_id"` + FilePath string `json:"file_path"` + RepoDir string `json:"repo_dir"` + GitBranch string `json:"git_branch,omitempty"` + Model string `json:"model,omitempty"` + ReviewMode string `json:"review_mode,omitempty"` + DiffFrom string `json:"diff_from,omitempty"` + DiffTo string `json:"diff_to,omitempty"` + DiffCommit string `json:"diff_commit,omitempty"` + ResumedFrom string `json:"resumed_from,omitempty"` + StartTime time.Time `json:"start_time"` + EndTime time.Time `json:"end_time,omitempty"` + Duration time.Duration `json:"duration_ns,omitempty"` + CompletedFiles int `json:"completed_files"` + FailedFiles int `json:"failed_files"` + ReusedFiles int `json:"reused_files"` + TotalComments int `json:"total_comments"` + LLMFailures int64 `json:"llm_failures"` + Aborted bool `json:"aborted"` +} + +// ItemDetail describes one file-level record within a session, used by `ocr session show`. +type ItemDetail struct { + Type string `json:"type"` + Timestamp time.Time `json:"timestamp"` + FilePath string `json:"file_path"` + OldPath string `json:"old_path,omitempty"` + NewPath string `json:"new_path,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` + Comments int `json:"comments"` + SourceSessionID string `json:"source_session_id,omitempty"` + Error string `json:"error,omitempty"` +} + +// summaryRecord is a superset of resumeRecord that also carries session_end fields. +type summaryRecord struct { + Type string `json:"type"` + SessionID string `json:"sessionId"` + Timestamp string `json:"timestamp"` + Cwd string `json:"cwd"` + GitBranch string `json:"gitBranch"` + Model string `json:"model"` + ReviewMode string `json:"reviewMode"` + DiffFrom string `json:"diffFrom"` + DiffTo string `json:"diffTo"` + DiffCommit string `json:"diffCommit"` + ResumedFrom string `json:"resumedFrom"` + FilePath string `json:"filePath"` + OldPath string `json:"oldPath"` + NewPath string `json:"newPath"` + Fingerprint string `json:"fingerprint"` + SourceSessionID string `json:"sourceSessionId"` + Error string `json:"error"` + Comments json.RawMessage `json:"comments"` + FilesReviewed []string `json:"files_reviewed"` + DurationSeconds float64 `json:"duration_seconds"` + LLMFailures int64 `json:"llm_failures"` +} + +// SessionsDir returns the on-disk directory that holds JSONL session files +// for a given repository. It does not create the directory. +func SessionsDir(repoDir string) (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve home dir: %w", err) + } + return filepath.Join(home, ".opencodereview", sessionSubDir, encodeRepoPath(repoDir)), nil +} + +// ListSessions enumerates all persisted sessions for the given repository +// directory, sorted by StartTime descending (most recent first). Missing +// directories return an empty slice with no error. +func ListSessions(repoDir string) ([]Summary, error) { + dir, err := SessionsDir(repoDir) + if err != nil { + return nil, err + } + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("read sessions dir %q: %w", dir, err) + } + summaries := make([]Summary, 0, len(entries)) + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || !strings.HasSuffix(name, ".jsonl") { + continue + } + sessionID := strings.TrimSuffix(name, ".jsonl") + summary, err := loadSummaryFromFile(filepath.Join(dir, name), sessionID, repoDir) + if err != nil { + continue + } + summaries = append(summaries, *summary) + } + sort.Slice(summaries, func(i, j int) bool { + return summaries[i].StartTime.After(summaries[j].StartTime) + }) + return summaries, nil +} + +// LoadSummary loads a single session's Summary. Errors when the session +// file is missing or unreadable. +func LoadSummary(repoDir, sessionID string) (*Summary, error) { + path, err := SessionFilePath(repoDir, sessionID) + if err != nil { + return nil, err + } + return loadSummaryFromFile(path, sessionID, repoDir) +} + +// LoadDetail returns the summary plus per-file item records for one session. +func LoadDetail(repoDir, sessionID string) (*Summary, []ItemDetail, error) { + path, err := SessionFilePath(repoDir, sessionID) + if err != nil { + return nil, nil, err + } + summary := &Summary{ + SessionID: sessionID, + FilePath: path, + RepoDir: repoDir, + Aborted: true, + } + var items []ItemDetail + err = walkSessionFile(path, func(rec summaryRecord) { + applyRecordToSummary(summary, rec) + if item, ok := recordToItem(rec); ok { + items = append(items, item) + } + }) + if err != nil { + return nil, nil, err + } + if summary.SessionID == "" { + summary.SessionID = sessionID + } + return summary, items, nil +} + +func loadSummaryFromFile(path, sessionID, repoDir string) (*Summary, error) { + summary := &Summary{ + SessionID: sessionID, + FilePath: path, + RepoDir: repoDir, + Aborted: true, + } + if err := walkSessionFile(path, func(rec summaryRecord) { + applyRecordToSummary(summary, rec) + }); err != nil { + return nil, err + } + if summary.SessionID == "" { + summary.SessionID = sessionID + } + return summary, nil +} + +func walkSessionFile(path string, apply func(summaryRecord)) error { + f, err := os.Open(path) + if err != nil { + return fmt.Errorf("open session %q: %w", path, err) + } + defer f.Close() + + reader := bufio.NewReader(f) + for { + line, readErr := reader.ReadBytes('\n') + if len(line) > 0 { + var rec summaryRecord + if err := json.Unmarshal(line, &rec); err == nil { + apply(rec) + } + } + if readErr == io.EOF { + return nil + } + if readErr != nil { + return fmt.Errorf("read session %q: %w", path, readErr) + } + } +} + +func applyRecordToSummary(s *Summary, rec summaryRecord) { + ts := parseRecordTime(rec.Timestamp) + switch rec.Type { + case "session_start": + if rec.SessionID != "" { + s.SessionID = rec.SessionID + } + if rec.Cwd != "" { + s.RepoDir = rec.Cwd + } + s.GitBranch = rec.GitBranch + s.Model = rec.Model + s.ReviewMode = rec.ReviewMode + s.DiffFrom = rec.DiffFrom + s.DiffTo = rec.DiffTo + s.DiffCommit = rec.DiffCommit + s.ResumedFrom = rec.ResumedFrom + if !ts.IsZero() { + s.StartTime = ts + } + case "review_item_done": + s.CompletedFiles++ + s.TotalComments += countCommentsRaw(rec.Comments) + case "review_item_reused": + s.ReusedFiles++ + s.TotalComments += countCommentsRaw(rec.Comments) + case "review_item_failed": + s.FailedFiles++ + case "session_end": + s.Aborted = false + if s.CompletedFiles == 0 && s.ReusedFiles == 0 && s.FailedFiles == 0 && len(rec.FilesReviewed) > 0 { + s.CompletedFiles = len(rec.FilesReviewed) + } + if !ts.IsZero() { + s.EndTime = ts + } + if rec.DurationSeconds > 0 { + s.Duration = time.Duration(rec.DurationSeconds * float64(time.Second)) + } else if !s.EndTime.IsZero() && !s.StartTime.IsZero() { + s.Duration = s.EndTime.Sub(s.StartTime) + } + s.LLMFailures = rec.LLMFailures + } +} + +func recordToItem(rec summaryRecord) (ItemDetail, bool) { + switch rec.Type { + case "review_item_done", "review_item_reused", "review_item_failed": + default: + return ItemDetail{}, false + } + kind := strings.TrimPrefix(rec.Type, "review_item_") + filePath := rec.FilePath + if filePath == "" { + filePath = rec.NewPath + } + return ItemDetail{ + Type: kind, + Timestamp: parseRecordTime(rec.Timestamp), + FilePath: filePath, + OldPath: rec.OldPath, + NewPath: rec.NewPath, + Fingerprint: rec.Fingerprint, + Comments: countCommentsRaw(rec.Comments), + SourceSessionID: rec.SourceSessionID, + Error: rec.Error, + }, true +} + +func countCommentsRaw(raw json.RawMessage) int { + if len(raw) == 0 { + return 0 + } + var arr []json.RawMessage + if err := json.Unmarshal(raw, &arr); err != nil { + return 0 + } + return len(arr) +} + +func parseRecordTime(s string) time.Time { + if s == "" { + return time.Time{} + } + if t, err := time.Parse(time.RFC3339, s); err == nil { + return t + } + if t, err := time.Parse(time.RFC3339Nano, s); err == nil { + return t + } + return time.Time{} +} diff --git a/internal/session/list_test.go b/internal/session/list_test.go new file mode 100644 index 0000000..2c5c394 --- /dev/null +++ b/internal/session/list_test.go @@ -0,0 +1,189 @@ +package session + +import ( + "path/filepath" + "testing" + "time" + + "github.com/open-code-review/open-code-review/internal/model" +) + +func TestListSessions_EmptyRepoReturnsNil(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + + got, err := ListSessions(t.TempDir()) + if err != nil { + t.Fatalf("ListSessions: %v", err) + } + if len(got) != 0 { + t.Errorf("expected empty result, got %d entries", len(got)) + } +} + +func TestListSessions_SortsAndAggregates(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + repoDir := t.TempDir() + + older := writeTestSession(t, repoDir, "feature-a", "commit-x", []model.LlmComment{ + {Path: "a.go", Content: "one"}, + }, 1, 0, true) + time.Sleep(1100 * time.Millisecond) + newer := writeTestSession(t, repoDir, "feature-a", "commit-y", []model.LlmComment{ + {Path: "b.go", Content: "one"}, + {Path: "b.go", Content: "two"}, + }, 2, 1, false) + + got, err := ListSessions(repoDir) + if err != nil { + t.Fatalf("ListSessions: %v", err) + } + if len(got) != 2 { + t.Fatalf("expected 2 sessions, got %d", len(got)) + } + if got[0].SessionID != newer { + t.Errorf("expected newest first, got %q vs %q", got[0].SessionID, newer) + } + if got[1].SessionID != older { + t.Errorf("expected older second, got %q", got[1].SessionID) + } + + if !got[0].Aborted { + t.Errorf("newest session was interrupted; expected Aborted=true") + } + if got[1].Aborted { + t.Errorf("older session was finalized; expected Aborted=false") + } + if got[0].TotalComments != 2 { + t.Errorf("newest TotalComments = %d, want 2", got[0].TotalComments) + } + if got[0].FailedFiles != 1 { + t.Errorf("newest FailedFiles = %d, want 1", got[0].FailedFiles) + } + if got[0].CompletedFiles != 2 { + t.Errorf("newest CompletedFiles = %d, want 2", got[0].CompletedFiles) + } +} + +func TestLoadDetail_ReturnsItems(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + repoDir := t.TempDir() + + sh := New(repoDir, "main", "test-model", SessionOptions{ + ReviewMode: ReviewModeCommit, + DiffCommit: "abc123", + }) + sh.RecordReviewItemDone("a.go", "a.go", "a.go", "fp-a", []model.LlmComment{{Path: "a.go", Content: "note"}}) + sh.RecordReviewItemReused("b.go", "b.go", "b.go", "fp-b", "prior-session", []model.LlmComment{{Path: "b.go", Content: "cached"}}) + sh.RecordReviewItemFailed("c.go", "c.go", "c.go", "fp-c", "boom") + sh.Finalize() + + summary, items, err := LoadDetail(repoDir, sh.SessionID) + if err != nil { + t.Fatalf("LoadDetail: %v", err) + } + if summary.CompletedFiles != 1 || summary.ReusedFiles != 1 || summary.FailedFiles != 1 { + t.Fatalf("summary = %+v", summary) + } + if summary.TotalComments != 2 { + t.Errorf("TotalComments = %d, want 2", summary.TotalComments) + } + if summary.Aborted { + t.Errorf("summary should not be aborted after Finalize") + } + if len(items) != 3 { + t.Fatalf("expected 3 items, got %d", len(items)) + } + byType := map[string]ItemDetail{} + for _, it := range items { + byType[it.Type] = it + } + if reused := byType["reused"]; reused.SourceSessionID != "prior-session" { + t.Errorf("reused source = %q, want prior-session", reused.SourceSessionID) + } + if failed := byType["failed"]; failed.Error != "boom" { + t.Errorf("failed error = %q, want boom", failed.Error) + } + if done := byType["done"]; done.Comments != 1 { + t.Errorf("done comments = %d, want 1", done.Comments) + } +} + +func TestLoadSummary_FallsBackToSessionEndFilesReviewed(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + repoDir := t.TempDir() + + sh := New(repoDir, "main", "test-model", SessionOptions{ + ReviewMode: ReviewModeWorkspace, + }) + sh.GetOrCreateFileSession("legacy-a.go") + sh.GetOrCreateFileSession("legacy-b.go") + sh.Finalize() + + summary, items, err := LoadDetail(repoDir, sh.SessionID) + if err != nil { + t.Fatalf("LoadDetail: %v", err) + } + if summary.CompletedFiles != 2 { + t.Fatalf("CompletedFiles = %d, want 2", summary.CompletedFiles) + } + if summary.ReusedFiles != 0 || summary.FailedFiles != 0 { + t.Fatalf("unexpected checkpoint counts: %+v", summary) + } + if len(items) != 0 { + t.Fatalf("legacy session should not synthesize item details, got %d", len(items)) + } +} + +func TestLoadSummary_MissingFile(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + if _, err := LoadSummary(t.TempDir(), "nonexistent"); err == nil { + t.Fatal("expected error for missing session") + } +} + +// writeTestSession creates a real JSONL session using the persistence layer +// so tests exercise the same on-disk format that ListSessions consumes. +// It returns the session id. +func writeTestSession(t *testing.T, repoDir, from, to string, comments []model.LlmComment, doneCount, failedCount int, finalize bool) string { + t.Helper() + sh := New(repoDir, "main", "test-model", SessionOptions{ + ReviewMode: ReviewModeRange, + DiffFrom: from, + DiffTo: to, + }) + for i := 0; i < doneCount; i++ { + filePath := filepath.Base(t.TempDir()) + ".go" + var perFile []model.LlmComment + if i < len(comments) { + perFile = []model.LlmComment{comments[i]} + } + sh.RecordReviewItemDone(filePath, filePath, filePath, "fp-"+filePath, perFile) + } + for i := 0; i < failedCount; i++ { + filePath := "failed-" + filepath.Base(t.TempDir()) + ".go" + sh.RecordReviewItemFailed(filePath, filePath, filePath, "fp-fail-"+filePath, "test error") + } + if finalize { + sh.Finalize() + } else { + // Simulate an aborted run: flush the writer without emitting session_end. + if sh.persist != nil { + sh.persist.mu.Lock() + if sh.persist.writer != nil { + sh.persist.writer.Flush() + } + if sh.persist.file != nil { + _ = sh.persist.file.Close() + } + sh.persist.writer = nil + sh.persist.file = nil + sh.persist.mu.Unlock() + } + } + return sh.SessionID +} diff --git a/internal/session/persist.go b/internal/session/persist.go index 143d299..464da1b 100644 --- a/internal/session/persist.go +++ b/internal/session/persist.go @@ -11,6 +11,8 @@ import ( "strings" "sync" "time" + + "github.com/open-code-review/open-code-review/internal/model" ) var sessionSubDir = "sessions" @@ -19,31 +21,33 @@ var sessionSubDir = "sessions" // $HOME/.opencodereview/sessions//.jsonl. // It is safe for concurrent use by multiple goroutines. type jsonlWriter struct { - mu sync.Mutex - sessionID string - repoDir string - gitBranch string - model string - reviewMode string - diffFrom string - diffTo string - diffCommit string - file *os.File - writer *bufio.Writer - lastUUID string // tracks chain of records via parentUuid + mu sync.Mutex + sessionID string + repoDir string + gitBranch string + model string + reviewMode string + diffFrom string + diffTo string + diffCommit string + resumedFrom string + file *os.File + writer *bufio.Writer + lastUUID string // tracks chain of records via parentUuid } // newJSONLWriter creates and opens a new JSONL writer for the given session. func newJSONLWriter(sessionID, repoDir, gitBranch, model string, opts SessionOptions) (*jsonlWriter, error) { jw := &jsonlWriter{ - sessionID: sessionID, - repoDir: repoDir, - gitBranch: gitBranch, - model: model, - reviewMode: opts.ReviewMode, - diffFrom: opts.DiffFrom, - diffTo: opts.DiffTo, - diffCommit: opts.DiffCommit, + sessionID: sessionID, + repoDir: repoDir, + gitBranch: gitBranch, + model: model, + reviewMode: opts.ReviewMode, + diffFrom: opts.DiffFrom, + diffTo: opts.DiffTo, + diffCommit: opts.DiffCommit, + resumedFrom: opts.ResumedFrom, } if err := jw.open(); err != nil { return nil, err @@ -148,6 +152,9 @@ func (jw *jsonlWriter) WriteSessionStart(startTime time.Time) string { if jw.diffCommit != "" { rec["diffCommit"] = jw.diffCommit } + if jw.resumedFrom != "" { + rec["resumedFrom"] = jw.resumedFrom + } jw.mu.Lock() defer jw.mu.Unlock() @@ -156,6 +163,55 @@ func (jw *jsonlWriter) WriteSessionStart(startTime time.Time) string { return uuid } +// WriteReviewItemDone writes a file-level resume checkpoint for a completed diff. +func (jw *jsonlWriter) WriteReviewItemDone(filePath, oldPath, newPath, fingerprint string, comments []model.LlmComment) string { + return jw.writeReviewItemRecord("review_item_done", filePath, oldPath, newPath, fingerprint, "", "", comments) +} + +// WriteReviewItemReused writes a checkpoint reused from a previous session. +func (jw *jsonlWriter) WriteReviewItemReused(filePath, oldPath, newPath, fingerprint, sourceSessionID string, comments []model.LlmComment) string { + return jw.writeReviewItemRecord("review_item_reused", filePath, oldPath, newPath, fingerprint, sourceSessionID, "", comments) +} + +// WriteReviewItemFailed writes a file-level checkpoint for a failed diff. +func (jw *jsonlWriter) WriteReviewItemFailed(filePath, oldPath, newPath, fingerprint, errorMsg string) string { + return jw.writeReviewItemRecord("review_item_failed", filePath, oldPath, newPath, fingerprint, "", errorMsg, nil) +} + +func (jw *jsonlWriter) writeReviewItemRecord(recordType, filePath, oldPath, newPath, fingerprint, sourceSessionID, errorMsg string, comments []model.LlmComment) string { + uuid := generateUUID() + + jw.mu.Lock() + defer jw.mu.Unlock() + rec := map[string]any{ + "uuid": uuid, + "parentUuid": jw.lastUUID, + "type": recordType, + "sessionId": jw.sessionID, + "timestamp": time.Now().UTC().Format(time.RFC3339), + "filePath": filePath, + "oldPath": oldPath, + "newPath": newPath, + "fingerprint": fingerprint, + "model": jw.model, + } + if len(comments) > 0 { + rec["comments"] = comments + } + if sourceSessionID != "" { + rec["sourceSessionId"] = sourceSessionID + } + if errorMsg != "" { + rec["error"] = errorMsg + } + jw.writeRecordLocked(rec) + if jw.writer != nil { + jw.writer.Flush() + } + jw.lastUUID = uuid + return uuid +} + // WriteLLMRequest writes a request entry with the resolved messages. func (jw *jsonlWriter) WriteLLMRequest(filePath string, taskType TaskType, requestNo int, messages any) string { uuid := generateUUID() diff --git a/internal/session/persist_test.go b/internal/session/persist_test.go index ddf99c2..bb9119d 100644 --- a/internal/session/persist_test.go +++ b/internal/session/persist_test.go @@ -9,6 +9,8 @@ import ( "runtime" "testing" "time" + + "github.com/open-code-review/open-code-review/internal/model" ) func init() { UseTestSessions() } @@ -109,7 +111,7 @@ func readJSONLRecords(t *testing.T, path string) []map[string]any { if err != nil { t.Fatalf("open jsonl: %v", err) } - defer f.Close() + defer func() { _ = f.Close() }() var records []map[string]any scanner := bufio.NewScanner(f) @@ -165,8 +167,11 @@ func TestSetErrorWritesJSONL(t *testing.T) { if sh.persist != nil { sh.persist.mu.Lock() - sh.persist.writer.Flush() + err := sh.persist.writer.Flush() sh.persist.mu.Unlock() + if err != nil { + t.Fatalf("flush: %v", err) + } } path := sessionJSONLPath(t, repoDir, sh.SessionID) @@ -265,3 +270,89 @@ func TestSessionEndIncludesFailures(t *testing.T) { t.Errorf("llm_failures = %v, want 3", failures) } } + +func TestReviewItemResumeRoundTrip(t *testing.T) { + repoDir := t.TempDir() + sh := New(repoDir, "feature", "new-model", SessionOptions{ + ReviewMode: ReviewModeRange, + DiffFrom: "main", + DiffTo: "feature", + ResumedFrom: "old-session", + }) + comments := []model.LlmComment{{ + Path: "a.go", + Content: "fix this", + ExistingCode: "old()", + }} + sh.RecordReviewItemDone("a.go", "a.go", "a.go", "fp-a", comments) + sh.RecordReviewItemFailed("b.go", "b.go", "b.go", "fp-b", "rate limit") + sh.Finalize() + + state, err := LoadResumeState(repoDir, sh.SessionID) + if err != nil { + t.Fatalf("LoadResumeState: %v", err) + } + if state.SessionID != sh.SessionID { + t.Errorf("SessionID = %q, want %q", state.SessionID, sh.SessionID) + } + if state.ReviewMode != ReviewModeRange || state.DiffFrom != "main" || state.DiffTo != "feature" { + t.Errorf("metadata mismatch: %+v", state) + } + if state.CompletedCount() != 1 { + t.Fatalf("CompletedCount = %d, want 1", state.CompletedCount()) + } + item, ok := state.Item("fp-a") + if !ok { + t.Fatal("missing fp-a") + } + if item.FilePath != "a.go" || len(item.Comments) != 1 || item.Comments[0].Content != "fix this" { + t.Errorf("item mismatch: %+v", item) + } + if _, ok := state.Item("fp-b"); ok { + t.Error("failed item should not be reusable") + } + if err := state.ValidateOptions(SessionOptions{ReviewMode: ReviewModeRange, DiffFrom: "main", DiffTo: "feature"}); err != nil { + t.Fatalf("ValidateOptions: %v", err) + } + + records := readJSONLRecords(t, sessionJSONLPath(t, repoDir, sh.SessionID)) + var foundStart bool + for _, r := range records { + if r["type"] == "session_start" { + foundStart = true + if r["resumedFrom"] != "old-session" { + t.Errorf("resumedFrom = %v, want old-session", r["resumedFrom"]) + } + } + } + if !foundStart { + t.Fatal("missing session_start") + } +} + +func TestResumeStateValidateOptionsRejectsMismatchedRange(t *testing.T) { + state := &ResumeState{ + SessionID: "s1", + ReviewMode: ReviewModeRange, + DiffFrom: "main", + DiffTo: "feature", + } + err := state.ValidateOptions(SessionOptions{ReviewMode: ReviewModeRange, DiffFrom: "main", DiffTo: "other"}) + if err == nil { + t.Fatal("expected mismatch error") + } +} + +func TestResumeStateSessionStartKeepsRepoDirWhenCwdEmpty(t *testing.T) { + state := &ResumeState{RepoDir: "/repo/from/caller"} + + state.applySessionStart(resumeRecord{ + SessionID: "s1", + ReviewMode: ReviewModeCommit, + DiffCommit: "abc123", + }) + + if state.RepoDir != "/repo/from/caller" { + t.Fatalf("RepoDir = %q, want caller-provided repo dir", state.RepoDir) + } +} diff --git a/internal/session/resume.go b/internal/session/resume.go new file mode 100644 index 0000000..756037c --- /dev/null +++ b/internal/session/resume.go @@ -0,0 +1,209 @@ +package session + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/open-code-review/open-code-review/internal/model" +) + +// ResumeState is the replayed, read-only checkpoint index for one prior session. +type ResumeState struct { + SessionID string + RepoDir string + GitBranch string + Model string + ReviewMode string + DiffFrom string + DiffTo string + DiffCommit string + Items map[string]ResumeItem +} + +// ResumeItem is a completed file-level checkpoint, keyed by diff fingerprint. +type ResumeItem struct { + FilePath string + OldPath string + NewPath string + Fingerprint string + Comments []model.LlmComment +} + +type resumeRecord struct { + Type string `json:"type"` + SessionID string `json:"sessionId"` + Cwd string `json:"cwd"` + GitBranch string `json:"gitBranch"` + Model string `json:"model"` + ReviewMode string `json:"reviewMode"` + DiffFrom string `json:"diffFrom"` + DiffTo string `json:"diffTo"` + DiffCommit string `json:"diffCommit"` + FilePath string `json:"filePath"` + OldPath string `json:"oldPath"` + NewPath string `json:"newPath"` + Fingerprint string `json:"fingerprint"` + SourceSessionID string `json:"sourceSessionId"` + Error string `json:"error"` + Comments []model.LlmComment `json:"comments"` +} + +// SessionFilePath returns the JSONL path for a persisted session. +func SessionFilePath(repoDir, sessionID string) (string, error) { + if sessionID == "" { + return "", fmt.Errorf("session id is required") + } + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve home dir: %w", err) + } + return filepath.Join(home, ".opencodereview", sessionSubDir, encodeRepoPath(repoDir), sessionID+".jsonl"), nil +} + +// LoadResumeState replays a previous session JSONL into a fingerprint index. +func LoadResumeState(repoDir, sessionID string) (*ResumeState, error) { + path, err := SessionFilePath(repoDir, sessionID) + if err != nil { + return nil, err + } + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("open resume session %q: %w", sessionID, err) + } + defer f.Close() + + state := &ResumeState{ + SessionID: sessionID, + RepoDir: repoDir, + Items: make(map[string]ResumeItem), + } + reader := bufio.NewReader(f) + for { + line, readErr := reader.ReadBytes('\n') + if len(line) > 0 { + if err := state.applyResumeLine(line); err != nil { + return nil, err + } + } + if readErr == io.EOF { + break + } + if readErr != nil { + return nil, fmt.Errorf("read resume session %q: %w", sessionID, readErr) + } + } + if state.SessionID == "" { + state.SessionID = sessionID + } + return state, nil +} + +func (s *ResumeState) applyResumeLine(line []byte) error { + var rec resumeRecord + if err := json.Unmarshal(line, &rec); err != nil { + return fmt.Errorf("parse resume session %q: %w", s.SessionID, err) + } + + switch rec.Type { + case "session_start": + s.applySessionStart(rec) + case "review_item_done", "review_item_reused": + if rec.Fingerprint == "" { + return nil + } + filePath := rec.FilePath + if filePath == "" { + filePath = rec.NewPath + } + s.Items[rec.Fingerprint] = ResumeItem{ + FilePath: filePath, + OldPath: rec.OldPath, + NewPath: rec.NewPath, + Fingerprint: rec.Fingerprint, + Comments: copyLlmComments(rec.Comments), + } + case "review_item_failed": + if rec.Fingerprint != "" { + delete(s.Items, rec.Fingerprint) + } + } + return nil +} + +func (s *ResumeState) applySessionStart(rec resumeRecord) { + if rec.SessionID != "" { + s.SessionID = rec.SessionID + } + if rec.Cwd != "" { + s.RepoDir = rec.Cwd + } + s.GitBranch = rec.GitBranch + s.Model = rec.Model + s.ReviewMode = rec.ReviewMode + s.DiffFrom = rec.DiffFrom + s.DiffTo = rec.DiffTo + s.DiffCommit = rec.DiffCommit +} + +// CompletedCount returns the number of reusable file-level checkpoints. +func (s *ResumeState) CompletedCount() int { + if s == nil { + return 0 + } + return len(s.Items) +} + +// Item returns a copy of the checkpoint for fingerprint. +func (s *ResumeState) Item(fingerprint string) (ResumeItem, bool) { + if s == nil { + return ResumeItem{}, false + } + item, ok := s.Items[fingerprint] + if !ok { + return ResumeItem{}, false + } + item.Comments = copyLlmComments(item.Comments) + return item, true +} + +// ValidateOptions verifies that the requested review range matches the prior session. +func (s *ResumeState) ValidateOptions(opts SessionOptions) error { + if s == nil { + return nil + } + if opts.ReviewMode == "" || opts.ReviewMode == ReviewModeWorkspace { + return fmt.Errorf("resume requires --from/--to or --commit; workspace resume is not supported") + } + if s.ReviewMode == "" { + return fmt.Errorf("resume session %q is missing review mode metadata", s.SessionID) + } + if s.ReviewMode != opts.ReviewMode { + return fmt.Errorf("resume session review mode %q does not match current mode %q", s.ReviewMode, opts.ReviewMode) + } + switch opts.ReviewMode { + case ReviewModeRange: + if s.DiffFrom != opts.DiffFrom || s.DiffTo != opts.DiffTo { + return fmt.Errorf("resume session range %q..%q does not match current range %q..%q", s.DiffFrom, s.DiffTo, opts.DiffFrom, opts.DiffTo) + } + case ReviewModeCommit: + if s.DiffCommit != opts.DiffCommit { + return fmt.Errorf("resume session commit %q does not match current commit %q", s.DiffCommit, opts.DiffCommit) + } + default: + return fmt.Errorf("resume mode %q is not supported", opts.ReviewMode) + } + return nil +} + +func copyLlmComments(in []model.LlmComment) []model.LlmComment { + if len(in) == 0 { + return nil + } + out := make([]model.LlmComment, len(in)) + copy(out, in) + return out +} diff --git a/internal/telemetry/config_test.go b/internal/telemetry/config_test.go index 4edb8ce..9a1c215 100644 --- a/internal/telemetry/config_test.go +++ b/internal/telemetry/config_test.go @@ -65,7 +65,7 @@ func TestResolveEnv(t *testing.T) { }, }, { - name: "otlp protocol override", + name: "otlp protocol passthrough", envs: map[string]string{"OTEL_EXPORTER_OTLP_PROTOCOL": "http/json"}, check: func(t *testing.T, cfg Config) { if cfg.OTLPProtocol != "http/json" { @@ -103,7 +103,7 @@ func TestResolveEnv(t *testing.T) { } for _, k := range envKeys { t.Setenv(k, "") - os.Unsetenv(k) + _ = os.Unsetenv(k) } for k, v := range tc.envs { t.Setenv(k, v) @@ -128,7 +128,9 @@ func TestLoadFromJSON(t *testing.T) { t.Run("malformed json returns nil error", func(t *testing.T) { tmp := t.TempDir() path := filepath.Join(tmp, "config.json") - os.WriteFile(path, []byte("{invalid json"), 0644) + if err := os.WriteFile(path, []byte("{invalid json"), 0644); err != nil { + t.Fatalf("write file: %v", err) + } cfg := DefaultConfig() err := LoadFromJSON(&cfg, path) @@ -140,7 +142,9 @@ func TestLoadFromJSON(t *testing.T) { t.Run("no telemetry section", func(t *testing.T) { tmp := t.TempDir() path := filepath.Join(tmp, "config.json") - os.WriteFile(path, []byte(`{"other": "value"}`), 0644) + if err := os.WriteFile(path, []byte(`{"other": "value"}`), 0644); err != nil { + t.Fatalf("write file: %v", err) + } cfg := DefaultConfig() err := LoadFromJSON(&cfg, path) @@ -163,7 +167,9 @@ func TestLoadFromJSON(t *testing.T) { "content_logging": true } }` - os.WriteFile(path, []byte(data), 0644) + if err := os.WriteFile(path, []byte(data), 0644); err != nil { + t.Fatalf("write file: %v", err) + } cfg := DefaultConfig() err := LoadFromJSON(&cfg, path) @@ -188,7 +194,9 @@ func TestLoadFromJSON(t *testing.T) { tmp := t.TempDir() path := filepath.Join(tmp, "config.json") data := `{"telemetry": {"otlp_endpoint": "localhost:4317"}}` - os.WriteFile(path, []byte(data), 0644) + if err := os.WriteFile(path, []byte(data), 0644); err != nil { + t.Fatalf("write file: %v", err) + } cfg := DefaultConfig() err := LoadFromJSON(&cfg, path) @@ -204,7 +212,9 @@ func TestLoadFromJSON(t *testing.T) { tmp := t.TempDir() path := filepath.Join(tmp, "config.json") data := `{"telemetry": {"exporter": "otlp"}}` - os.WriteFile(path, []byte(data), 0644) + if err := os.WriteFile(path, []byte(data), 0644); err != nil { + t.Fatalf("write file: %v", err) + } cfg := DefaultConfig() cfg.Exporter = "custom" @@ -227,7 +237,7 @@ func TestResolveConfig(t *testing.T) { } for _, k := range envKeys { t.Setenv(k, "") - os.Unsetenv(k) + _ = os.Unsetenv(k) } cfg := ResolveConfig("") @@ -240,7 +250,9 @@ func TestResolveConfig(t *testing.T) { tmp := t.TempDir() path := filepath.Join(tmp, "config.json") data := `{"telemetry": {"enabled": false}}` - os.WriteFile(path, []byte(data), 0644) + if err := os.WriteFile(path, []byte(data), 0644); err != nil { + t.Fatalf("write file: %v", err) + } t.Setenv("OCR_ENABLE_TELEMETRY", "1") diff --git a/internal/telemetry/events_test.go b/internal/telemetry/events_test.go index 1bcd170..d9d1efa 100644 --- a/internal/telemetry/events_test.go +++ b/internal/telemetry/events_test.go @@ -54,7 +54,7 @@ func captureStderr(t *testing.T, fn func()) string { defer func() { os.Stderr = oldStderr }() fn() - w.Close() + _ = w.Close() var buf bytes.Buffer _, _ = io.Copy(&buf, r) diff --git a/internal/telemetry/exporter.go b/internal/telemetry/exporter.go index 58638c8..bcb2b45 100644 --- a/internal/telemetry/exporter.go +++ b/internal/telemetry/exporter.go @@ -11,7 +11,9 @@ import ( sdktrace "go.opentelemetry.io/otel/sdk/trace" otlpmetricgrpc "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc" + otlpmetrichttp "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp" otlptracegrpc "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" + otlptracehttp "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" stdoutmetric "go.opentelemetry.io/otel/exporters/stdout/stdoutmetric" stdouttrace "go.opentelemetry.io/otel/exporters/stdout/stdouttrace" ) @@ -27,11 +29,11 @@ func newStdoutMetricExporter() (sdkmetric.Exporter, error) { } // parseOTLPEndpoint strips a http:// or https:// scheme from the endpoint and -// reports whether the connection should be insecure (plaintext gRPC). +// reports whether the connection should be plaintext (insecure). // Scheme matching is case-insensitive per RFC 3986. A bare host:port (no // scheme) is left unchanged and defaults to TLS. Any trailing slash left // over from a URL-style endpoint (e.g. "http://localhost:4317/") is -// trimmed, since otlptracegrpc/otlpmetricgrpc's WithEndpoint expects a bare +// trimmed, since both gRPC and HTTP exporters' WithEndpoint expects a bare // host:port with no path. func parseOTLPEndpoint(endpoint string) (addr string, insecure bool) { switch { @@ -44,8 +46,21 @@ func parseOTLPEndpoint(endpoint string) (addr string, insecure bool) { } } -// initOTLPProviders sets up OTLP gRPC exporters for traces and metrics. +// initOTLPProviders dispatches to the gRPC or HTTP exporter based on cfg.OTLPProtocol. func initOTLPProviders(ctx context.Context, res *resource.Resource, cfg Config) { + switch cfg.OTLPProtocol { + case "http/protobuf", "http/json": + initOTLPHTTPProviders(ctx, res, cfg) + case "", "grpc": + initOTLPGRPCProviders(ctx, res, cfg) + default: + fmt.Fprintf(os.Stderr, "[ocr] WARNING: unsupported OTLP protocol %q, falling back to gRPC\n", cfg.OTLPProtocol) + initOTLPGRPCProviders(ctx, res, cfg) + } +} + +// initOTLPGRPCProviders sets up OTLP gRPC exporters for traces and metrics. +func initOTLPGRPCProviders(ctx context.Context, res *resource.Resource, cfg Config) { addr, insecure := parseOTLPEndpoint(cfg.OTLPEndpoint) traceOpts := []otlptracegrpc.Option{otlptracegrpc.WithEndpoint(addr)} @@ -83,6 +98,45 @@ func initOTLPProviders(ctx context.Context, res *resource.Resource, cfg Config) shutdownFuncs = append(shutdownFuncs, func(ctx context.Context) error { return mp.Shutdown(ctx) }) } +// initOTLPHTTPProviders sets up OTLP HTTP exporters for traces and metrics. +func initOTLPHTTPProviders(ctx context.Context, res *resource.Resource, cfg Config) { + addr, insecure := parseOTLPEndpoint(cfg.OTLPEndpoint) + + traceOpts := []otlptracehttp.Option{otlptracehttp.WithEndpoint(addr)} + if insecure { + traceOpts = append(traceOpts, otlptracehttp.WithInsecure()) + } + traceExp, err := otlptracehttp.New(ctx, traceOpts...) + if err != nil { + fmt.Fprintf(os.Stderr, "[ocr] WARNING: failed to create OTLP HTTP trace exporter: %v\n", err) + return + } + + tp := sdktrace.NewTracerProvider( + sdktrace.WithBatcher(traceExp), + sdktrace.WithResource(res), + ) + tracerProvider = tp + shutdownFuncs = append(shutdownFuncs, func(ctx context.Context) error { return tp.Shutdown(ctx) }) + + metricOpts := []otlpmetrichttp.Option{otlpmetrichttp.WithEndpoint(addr)} + if insecure { + metricOpts = append(metricOpts, otlpmetrichttp.WithInsecure()) + } + metricExp, err := otlpmetrichttp.New(ctx, metricOpts...) + if err != nil { + fmt.Fprintf(os.Stderr, "[ocr] WARNING: failed to create OTLP HTTP metric exporter: %v\n", err) + return + } + + mp := sdkmetric.NewMeterProvider( + sdkmetric.WithReader(sdkmetric.NewPeriodicReader(metricExp)), + sdkmetric.WithResource(res), + ) + meterProvider = mp + shutdownFuncs = append(shutdownFuncs, func(ctx context.Context) error { return mp.Shutdown(ctx) }) +} + // initConsoleProviders sets up stdout exporters for traces and metrics. func initConsoleProviders(res *resource.Resource) { traceExp, err := newStdoutTraceExporter() diff --git a/internal/telemetry/exporter_test.go b/internal/telemetry/exporter_test.go index 95efd4b..97ebaa2 100644 --- a/internal/telemetry/exporter_test.go +++ b/internal/telemetry/exporter_test.go @@ -1,7 +1,11 @@ package telemetry import ( + "bytes" "context" + "io" + "os" + "strings" "testing" "go.opentelemetry.io/otel/sdk/resource" @@ -103,3 +107,98 @@ func TestInitOTLPProviders_InvalidEndpoint(t *testing.T) { t.Error("expected tracerProvider to be set (OTLP exporter creation is lazy)") } } + +func TestInitOTLPHTTPProviders_InvalidEndpoint(t *testing.T) { + tracerProvider = nil + meterProvider = nil + shutdownFuncs = nil + defer func() { + for _, fn := range shutdownFuncs { + _ = fn(context.Background()) + } + tracerProvider = nil + meterProvider = nil + shutdownFuncs = nil + }() + + cfg := Config{ + Exporter: "otlp", + OTLPEndpoint: "localhost:0", + OTLPProtocol: "http/protobuf", + } + initOTLPHTTPProviders(context.Background(), resource.Default(), cfg) + if tracerProvider == nil { + t.Error("expected tracerProvider to be set (OTLP HTTP exporter creation is lazy)") + } +} + +func TestInitOTLPProviders_ProtocolRouting(t *testing.T) { + cases := []struct { + name string + protocol string + wantWarning bool // default branch emits a gRPC fallback warning + }{ + {"grpc default", "grpc", false}, + {"empty defaults to grpc", "", false}, + {"http/protobuf routes to http", "http/protobuf", false}, + {"http/json routes to http", "http/json", false}, + {"unknown falls back to grpc", "foo", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + tracerProvider = nil + meterProvider = nil + shutdownFuncs = nil + + // Capture stderr to assert on the fallback warning. + oldStderr := os.Stderr + r, w, _ := os.Pipe() + os.Stderr = w + + cfg := Config{ + Exporter: "otlp", + OTLPEndpoint: "localhost:0", + OTLPProtocol: tc.protocol, + } + initOTLPProviders(context.Background(), resource.Default(), cfg) + if err := w.Close(); err != nil { + t.Fatalf("close stderr pipe: %v", err) + } + os.Stderr = oldStderr + + defer func() { + for _, fn := range shutdownFuncs { + _ = fn(context.Background()) + } + tracerProvider = nil + meterProvider = nil + shutdownFuncs = nil + }() + + if tracerProvider == nil { + t.Error("expected tracerProvider to be set") + } + if meterProvider == nil { + t.Error("expected meterProvider to be set") + } + if len(shutdownFuncs) != 2 { + t.Errorf("expected 2 shutdown funcs, got %d", len(shutdownFuncs)) + } + + var buf bytes.Buffer + if _, err := io.Copy(&buf, r); err != nil { + t.Fatalf("read stderr pipe: %v", err) + } + stderrOut := buf.String() + if tc.wantWarning { + if !strings.Contains(stderrOut, "falling back to gRPC") { + t.Errorf("expected gRPC fallback warning in stderr, got %q", stderrOut) + } + } else { + if strings.Contains(stderrOut, "falling back to gRPC") { + t.Errorf("expected no fallback warning, got %q", stderrOut) + } + } + }) + } +} diff --git a/internal/telemetry/provider_test.go b/internal/telemetry/provider_test.go index 3895636..bdbc380 100644 --- a/internal/telemetry/provider_test.go +++ b/internal/telemetry/provider_test.go @@ -63,7 +63,7 @@ func TestContentLogging_EnabledWithEnv(t *testing.T) { func TestContentLogging_EnabledWithoutEnv(t *testing.T) { setupEnabledTelemetry(t) t.Setenv("OCR_CONTENT_LOGGING", "") - os.Unsetenv("OCR_CONTENT_LOGGING") + _ = os.Unsetenv("OCR_CONTENT_LOGGING") if ContentLogging() { t.Error("expected ContentLogging()=false when enabled but env var not set") } @@ -112,7 +112,7 @@ func TestInit_DisabledByDefault(t *testing.T) { } for _, k := range envKeys { t.Setenv(k, "") - os.Unsetenv(k) + _ = os.Unsetenv(k) } defer func() { @@ -140,7 +140,7 @@ func TestInit_EnabledConsole(t *testing.T) { } for _, k := range envKeys { t.Setenv(k, "") - os.Unsetenv(k) + _ = os.Unsetenv(k) } defer func() { diff --git a/internal/telemetry/span.go b/internal/telemetry/span.go index 04c9d4d..0b412ed 100644 --- a/internal/telemetry/span.go +++ b/internal/telemetry/span.go @@ -3,6 +3,7 @@ package telemetry import ( "context" "fmt" + "time" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" @@ -23,6 +24,16 @@ func StartSpan(ctx context.Context, name string, opts ...trace.SpanStartOption) return getTracer().Start(ctx, name, opts...) } +// TraceIDFromContext returns the hex-encoded trace ID of the span carried by +// ctx, or "" if ctx carries no valid span (e.g. telemetry is disabled). +func TraceIDFromContext(ctx context.Context) string { + sc := trace.SpanContextFromContext(ctx) + if !sc.TraceID().IsValid() { + return "" + } + return sc.TraceID().String() +} + // EndSpan ends the span and records error status if present. func EndSpan(span trace.Span, err error) { if err != nil { @@ -69,11 +80,35 @@ func RecordToolResult(span trace.Span, toolName string, durationMs int64, err er SetAttr(span, "tool.status", "error") SetAttr(span, "tool.error", err.Error()) span.SetStatus(codes.Error, err.Error()) + span.RecordError(err) } else { SetAttr(span, "tool.status", "ok") } } +// StartLLMSpan creates a span for an LLM request with standard attributes. +func StartLLMSpan(ctx context.Context, model string) (context.Context, trace.Span) { + return StartSpan(ctx, "llm.request", + trace.WithAttributes(attribute.String("llm.model", model))) +} + +// RecordLLMResult sets the outcome of an LLM request on the span. +func RecordLLMResult(span trace.Span, duration time.Duration, totalTokens int64, err error) { + if span == nil { + return + } + SetAttr(span, "llm.duration_ms", duration.Milliseconds()) + SetAttr(span, "llm.total_tokens", totalTokens) + if err != nil { + SetAttr(span, "llm.status", "error") + SetAttr(span, "llm.error", err.Error()) + span.SetStatus(codes.Error, err.Error()) + span.RecordError(err) + } else { + SetAttr(span, "llm.status", "ok") + } +} + // AnyToAttr converts an arbitrary value to an OTel attribute.KeyValue. func AnyToAttr(k string, v interface{}) attribute.KeyValue { switch val := v.(type) { diff --git a/internal/telemetry/span_test.go b/internal/telemetry/span_test.go index a8ca9d1..089aac0 100644 --- a/internal/telemetry/span_test.go +++ b/internal/telemetry/span_test.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "testing" + "time" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" @@ -138,3 +139,34 @@ func TestRecordToolResult_NilSpan(t *testing.T) { RecordToolResult(nil, "tool", 100, nil) RecordToolResult(nil, "tool", 100, fmt.Errorf("err")) } + +func TestStartLLMSpan(t *testing.T) { + setupEnabledTelemetry(t) + ctx, span := StartLLMSpan(context.Background(), "qwen-max") + if !span.SpanContext().IsValid() { + t.Error("expected valid span context") + } + if ctx == nil { + t.Error("expected non-nil context") + } + span.End() +} + +func TestRecordLLMResult_Success(t *testing.T) { + setupEnabledTelemetry(t) + _, span := StartSpan(context.Background(), "test.llm") + RecordLLMResult(span, 500*time.Millisecond, 1200, nil) + span.End() +} + +func TestRecordLLMResult_Error(t *testing.T) { + setupEnabledTelemetry(t) + _, span := StartSpan(context.Background(), "test.llm") + RecordLLMResult(span, 100*time.Millisecond, 0, errors.New("timeout")) + span.End() +} + +func TestRecordLLMResult_NilSpan(t *testing.T) { + RecordLLMResult(nil, 100*time.Millisecond, 0, nil) + RecordLLMResult(nil, 100*time.Millisecond, 0, fmt.Errorf("err")) +} diff --git a/internal/tool/code_search.go b/internal/tool/code_search.go index 15844a6..4e8ee79 100644 --- a/internal/tool/code_search.go +++ b/internal/tool/code_search.go @@ -34,6 +34,9 @@ func (p *CodeSearchProvider) Execute(ctx context.Context, args map[string]any) ( var patterns []string for _, item := range filePatternsIface { if s, ok := item.(string); ok && s != "" { + if hasTraversalPathComponent(s) { + return "Error: file_patterns must not contain ..", nil + } patterns = append(patterns, s) } } @@ -85,6 +88,15 @@ func (p *CodeSearchProvider) buildGrepArgs(searchText string, caseSensitive bool return cmdArgs } +func hasTraversalPathComponent(pathspec string) bool { + for _, part := range strings.Split(pathspec, "/") { + if part == ".." { + return true + } + } + return false +} + func (p *CodeSearchProvider) runGitGrep(parentCtx context.Context, cmdArgs []string) (string, string, error) { ctx, cancel := context.WithTimeout(parentCtx, gitGrepTimeout) defer cancel() diff --git a/internal/tool/code_search_test.go b/internal/tool/code_search_test.go index c2d0fb9..9b4045c 100644 --- a/internal/tool/code_search_test.go +++ b/internal/tool/code_search_test.go @@ -433,6 +433,54 @@ func TestCodeSearchProvider_Execute_WithFilePatterns(t *testing.T) { } } +func TestCodeSearchProvider_Execute_RejectsTraversalPattern(t *testing.T) { + dir := setupTestRepo(t) + p := NewCodeSearch(&FileReader{RepoDir: dir, Mode: ModeWorkspace}) + tests := []struct { + name string + pattern string + want string + }{ + {name: "leading parent", pattern: "../pkg", want: "Error: file_patterns must not contain .."}, + {name: "middle parent", pattern: "pkg/../internal", want: "Error: file_patterns must not contain .."}, + {name: "trailing parent", pattern: "pkg/..", want: "Error: file_patterns must not contain .."}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := p.Execute(context.Background(), map[string]any{ + "search_text": "Hello", + "file_patterns": []any{test.pattern}, + }) + if err != nil { + t.Fatal(err) + } + if got != test.want { + t.Errorf("Execute() = %q, want %q", got, test.want) + } + }) + } +} + +func TestCodeSearchProvider_Execute_AllowsDoubleDotInFilename(t *testing.T) { + dir := setupTestRepo(t) + if err := os.WriteFile(filepath.Join(dir, "foo..bar.go"), []byte("package main\n\nfunc DoubleDotName() {}\n"), 0644); err != nil { + t.Fatal(err) + } + + p := NewCodeSearch(&FileReader{RepoDir: dir, Mode: ModeWorkspace}) + got, err := p.Execute(context.Background(), map[string]any{ + "search_text": "DoubleDotName", + "file_patterns": []any{"foo..bar.go"}, + }) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(got, "foo..bar.go") { + t.Errorf("expected foo..bar.go in result, got: %s", got) + } +} + func TestCodeSearchProvider_Execute_CaseSensitive(t *testing.T) { dir := setupTestRepo(t) p := NewCodeSearch(&FileReader{RepoDir: dir, Mode: ModeWorkspace}) diff --git a/internal/viewer/handler_test.go b/internal/viewer/handler_test.go index c7e9179..c24349c 100644 --- a/internal/viewer/handler_test.go +++ b/internal/viewer/handler_test.go @@ -82,7 +82,7 @@ func TestHandleRepos_PermissionDenied(t *testing.T) { if err := os.Chmod(root, 0000); err != nil { t.Fatal(err) } - t.Cleanup(func() { os.Chmod(root, 0755) }) + t.Cleanup(func() { _ = os.Chmod(root, 0755) }) req := httptest.NewRequest("GET", "/", nil) rr := httptest.NewRecorder() diff --git a/internal/viewer/server_extra_test.go b/internal/viewer/server_extra_test.go index c4e8b63..04be65d 100644 --- a/internal/viewer/server_extra_test.go +++ b/internal/viewer/server_extra_test.go @@ -167,7 +167,7 @@ func TestStaticFS(t *testing.T) { if err != nil { t.Fatalf("failed to open style.css from staticFS: %v", err) } - f.Close() + _ = f.Close() } func TestResolveAllowedHostsFromEnv(t *testing.T) { diff --git a/internal/viewer/static/style.css b/internal/viewer/static/style.css index e008eaf..f5c9b00 100644 --- a/internal/viewer/static/style.css +++ b/internal/viewer/static/style.css @@ -1,73 +1,175 @@ +:root { + /* Let the browser theme native scrollbars/form controls per the active scheme */ + color-scheme: light dark; + + /* Typography */ + --mono: "SF Mono", "Cascadia Code", Consolas, "Liberation Mono", "DejaVu Sans Mono", ui-monospace, SFMono-Regular, Menlo, "Courier New", monospace; + + /* Surfaces */ + --bg: #f5f7fa; + --surface: #fff; + --surface-alt: #f6f8fa; + --surface-alt2: #f8f9fb; + --response-bg: #fefefe; + + /* Text */ + --text: #333; + --text-strong: #24292e; + --text-muted: #586069; + --text-faint: #8b949e; + + /* Borders */ + --border: #e1e4e8; + --border-light: #eaecef; + + /* Accents */ + --link: #0366d6; + --accent: #0366d6; + --accent-soft: #f0f6ff; + + /* Code */ + --code-bg: #f0f0f0; + --inline-code-bg: #eff1f3; + + /* Task accents (dot + card border) */ + --task-main: #0366d6; + --task-plan: #8250df; + --task-memory: #8b949e; + --task-relocation: #e36209; + --task-default: #57606a; + --tool-name: #8250df; + --danger: #cf222e; + + /* Badge tints (bg + fg) */ + --badge-model-bg: #ddf4ff; --badge-model-fg: #0550ae; + --badge-tokens-bg: #dafbe1; --badge-tokens-fg: #116329; + --badge-duration-bg: #fff8c5; --badge-duration-fg: #6a5700; + --badge-error-bg: #ffebe9; --badge-error-fg: #cf222e; + + /* Misc */ + --badge-neutral-bg: #e8ecf1; + --badge-neutral-fg: #586069; + --shadow: 0 1px 3px rgba(0,0,0,0.08); + --shadow-hover: 0 2px 8px rgba(0,0,0,0.08); +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #0d1117; + --surface: #161b22; + --surface-alt: #21262d; + --surface-alt2: #1c2128; + --response-bg: #161b22; + + --text: #c9d1d9; + --text-strong: #e6edf3; + --text-muted: #8b949e; + --text-faint: #7d8590; + + --border: #30363d; + --border-light: #21262d; + + --link: #58a6ff; + --accent: #58a6ff; + --accent-soft: rgba(56,139,253,0.1); + + --code-bg: #21262d; + --inline-code-bg: #21262d; + + /* Task accents — lift dim brand colors for the dark surface */ + --task-main: #58a6ff; + --task-plan: #d2a8ff; + --task-default: #7d8590; + --tool-name: #d2a8ff; + --danger: #f85149; + + /* Badge tints — translucent bg + brighter fg on dark */ + --badge-model-bg: rgba(56,139,253,0.15); --badge-model-fg: #79c0ff; + --badge-tokens-bg: rgba(46,160,67,0.15); --badge-tokens-fg: #7ee787; + --badge-duration-bg: rgba(187,128,9,0.15); --badge-duration-fg: #e3b341; + --badge-error-bg: rgba(248,81,73,0.15); --badge-error-fg: #ff7b72; + + --badge-neutral-bg: #30363d; + --badge-neutral-fg: #adbac7; + --shadow: 0 1px 3px rgba(0,0,0,0.4); + --shadow-hover: 0 2px 8px rgba(0,0,0,0.5); + } +} + * { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; - background: #f5f7fa; - color: #333; + background: var(--bg); + color: var(--text); line-height: 1.6; padding-bottom: 2rem; } +code, pre { + font-family: var(--mono); +} + nav.breadcrumb { - background: #fff; - border-bottom: 1px solid #e1e4e8; + background: var(--surface); + border-bottom: 1px solid var(--border); padding: 0.75rem 1.5rem; font-size: 0.9rem; } nav.breadcrumb a { - color: #0366d6; + color: var(--link); text-decoration: none; } nav.breadcrumb a:hover { text-decoration: underline; } main { max-width: 1200px; margin: 1.5rem auto; padding: 0 1rem; } -h2 { margin-bottom: 1rem; color: #24292e; } -h3 { margin: 1.5rem 0 0.75rem; color: #24292e; } +h2 { margin-bottom: 1rem; color: var(--text-strong); } +h3 { margin: 1.5rem 0 0.75rem; color: var(--text-strong); } .table { width: 100%; border-collapse: collapse; - background: #fff; + background: var(--surface); border-radius: 6px; overflow: hidden; - box-shadow: 0 1px 3px rgba(0,0,0,0.08); + box-shadow: var(--shadow); } .table th { - background: #f6f8fa; + background: var(--surface-alt); text-align: left; padding: 0.6rem 1rem; font-size: 0.85rem; - color: #586069; - border-bottom: 1px solid #e1e4e8; + color: var(--text-muted); + border-bottom: 1px solid var(--border); } .table td { padding: 0.6rem 1rem; - border-bottom: 1px solid #eaecef; + border-bottom: 1px solid var(--border-light); font-size: 0.9rem; } .table tr:last-child td { border-bottom: none; } -.table tr:hover { background: #f6f8fa; } -.table a { color: #0366d6; text-decoration: none; } +.table tr:hover { background: var(--surface-alt); } +.table a { color: var(--link); text-decoration: none; } .table a:hover { text-decoration: underline; } .table code { - background: #f0f0f0; + background: var(--code-bg); padding: 0.15em 0.4em; border-radius: 3px; font-size: 0.85em; } .session-header { - background: #fff; + background: var(--surface); border-radius: 6px; padding: 1.25rem; margin-bottom: 1rem; - box-shadow: 0 1px 3px rgba(0,0,0,0.08); + box-shadow: var(--shadow); } .session-header h2 { margin-bottom: 0.5rem; word-break: break-all; } -.meta { display: flex; flex-wrap: wrap; gap: 1rem; font-size: 0.9rem; color: #586069; } +.meta { display: flex; flex-wrap: wrap; gap: 1rem; font-size: 0.9rem; color: var(--text-muted); } .meta code { - background: #f0f0f0; + background: var(--code-bg); padding: 0.15em 0.4em; border-radius: 3px; font-size: 0.85em; @@ -75,11 +177,11 @@ h3 { margin: 1.5rem 0 0.75rem; color: #24292e; } /* Token usage summary */ .token-summary { - background: #fff; + background: var(--surface); border-radius: 6px; padding: 1.25rem; margin-bottom: 1rem; - box-shadow: 0 1px 3px rgba(0,0,0,0.08); + box-shadow: var(--shadow); } .token-stats { display: grid; @@ -91,12 +193,12 @@ h3 { margin: 1.5rem 0 0.75rem; color: #24292e; } .token-value { font-size: 1.8rem; font-weight: 700; - color: #0366d6; + color: var(--accent); line-height: 1.2; } .token-label { font-size: 0.75rem; - color: #586069; + color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; } @@ -106,24 +208,24 @@ h3 { margin: 1.5rem 0 0.75rem; color: #24292e; } font-size: 0.85rem; } .token-table th { - background: #f6f8fa; + background: var(--surface-alt); text-align: left; padding: 0.4rem 0.75rem; - color: #586069; - border-bottom: 1px solid #e1e4e8; + color: var(--text-muted); + border-bottom: 1px solid var(--border); } .token-table td { padding: 0.35rem 0.75rem; - border-bottom: 1px solid #eaecef; + border-bottom: 1px solid var(--border-light); } .token-table tr:last-child td { border-bottom: none; } -.token-table tbody tr:hover { background: #f6f8fa; } +.token-table tbody tr:hover { background: var(--surface-alt); } .file-list { list-style: none; padding-left: 1rem; } .file-list li { padding: 0.25rem 0; font-size: 0.9rem; - color: #586069; + color: var(--text-muted); } .file-list li::before { content: "\1F4C4 "; margin-right: 0.25rem; } @@ -132,11 +234,11 @@ h3 { margin: 1.5rem 0 0.75rem; color: #24292e; } /* File Accordion */ .file-accordion { - background: #fff; + background: var(--surface); border-radius: 8px; margin-bottom: 0.75rem; - box-shadow: 0 1px 3px rgba(0,0,0,0.08); - border: 1px solid #e1e4e8; + box-shadow: var(--shadow); + border: 1px solid var(--border); overflow: hidden; } @@ -144,7 +246,7 @@ h3 { margin: 1.5rem 0 0.75rem; color: #24292e; } cursor: pointer; padding: 0.85rem 1rem; font-size: 0.95rem; - color: #24292e; + color: var(--text-strong); user-select: none; display: flex; align-items: center; @@ -154,15 +256,15 @@ h3 { margin: 1.5rem 0 0.75rem; color: #24292e; } } .file-accordion-header::-webkit-details-marker { display: none; } .file-accordion-header::marker { display: none; content: ""; } -.file-accordion-header:hover { background: #f0f6ff; } +.file-accordion-header:hover { background: var(--accent-soft); } /* Chevron icon */ .chevron { display: inline-block; width: 6px; height: 6px; - border-right: 2px solid #586069; - border-bottom: 2px solid #586069; + border-right: 2px solid var(--text-muted); + border-bottom: 2px solid var(--text-muted); transform: rotate(-45deg); transition: transform 0.2s ease; flex-shrink: 0; @@ -174,7 +276,7 @@ h3 { margin: 1.5rem 0 0.75rem; color: #24292e; } .file-path { font-weight: 600; - font-family: SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace; + font-family: var(--mono); font-size: 0.88rem; flex: 1; min-width: 0; @@ -184,8 +286,8 @@ h3 { margin: 1.5rem 0 0.75rem; color: #24292e; } } .file-count-badge { - background: #e8ecf1; - color: #586069; + background: var(--badge-neutral-bg); + color: var(--badge-neutral-fg); padding: 0.15em 0.6em; border-radius: 10px; font-size: 0.72rem; @@ -195,7 +297,7 @@ h3 { margin: 1.5rem 0 0.75rem; color: #24292e; } } .file-accordion[open] > .file-accordion-header { - border-bottom: 1px solid #e1e4e8; + border-bottom: 1px solid var(--border); } .file-accordion-body { @@ -209,7 +311,7 @@ h3 { margin: 1.5rem 0 0.75rem; color: #24292e; } .task-type-label { font-size: 0.72rem; text-transform: uppercase; - color: #586069; + color: var(--text-muted); margin-bottom: 0.6rem; letter-spacing: 0.06em; display: flex; @@ -225,42 +327,42 @@ h3 { margin: 1.5rem 0 0.75rem; color: #24292e; } border-radius: 50%; flex-shrink: 0; } -.task-main .task-type-dot { background: #0366d6; } -.task-plan .task-type-dot { background: #8250df; } -.task-memory .task-type-dot { background: #8b949e; } -.task-relocation .task-type-dot { background: #e36209; } -.task-default .task-type-dot { background: #57606a; } +.task-main .task-type-dot { background: var(--task-main); } +.task-plan .task-type-dot { background: var(--task-plan); } +.task-memory .task-type-dot { background: var(--task-memory); } +.task-relocation .task-type-dot { background: var(--task-relocation); } +.task-default .task-type-dot { background: var(--task-default); } /* Card */ .card { - border: 1px solid #e1e4e8; + border: 1px solid var(--border); border-radius: 8px; margin-bottom: 0.75rem; overflow: hidden; transition: box-shadow 0.15s; } .card:hover { - box-shadow: 0 2px 8px rgba(0,0,0,0.08); + box-shadow: var(--shadow-hover); } -.task-main .card { border-left: 3px solid #0366d6; } -.task-plan .card { border-left: 3px solid #8250df; } -.task-memory .card { border-left: 3px solid #8b949e; } -.task-relocation .card { border-left: 3px solid #e36209; } +.task-main .card { border-left: 3px solid var(--task-main); } +.task-plan .card { border-left: 3px solid var(--task-plan); } +.task-memory .card { border-left: 3px solid var(--task-memory); } +.task-relocation .card { border-left: 3px solid var(--task-relocation); } .card-header { - background: #f6f8fa; + background: var(--surface-alt); padding: 0.55rem 0.85rem; font-size: 0.85rem; display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; - border-bottom: 1px solid #eaecef; + border-bottom: 1px solid var(--border-light); } .request-label { font-weight: 600; - color: #24292e; + color: var(--text-strong); } /* Badges */ @@ -271,24 +373,24 @@ h3 { margin: 1.5rem 0 0.75rem; color: #24292e; } font-weight: 500; white-space: nowrap; } -.badge-model { background: #ddf4ff; color: #0550ae; } -.badge-tokens { background: #dafbe1; color: #116329; } -.badge-duration { background: #fff8c5; color: #6a5700; } -.badge-error { background: #ffebe9; color: #cf222e; } +.badge-model { background: var(--badge-model-bg); color: var(--badge-model-fg); } +.badge-tokens { background: var(--badge-tokens-bg); color: var(--badge-tokens-fg); } +.badge-duration { background: var(--badge-duration-bg); color: var(--badge-duration-fg); } +.badge-error { background: var(--badge-error-bg); color: var(--badge-error-fg); } /* Response Content */ .card-body { padding: 0; } .response-content { - background: #fefefe; + background: var(--response-bg); border-top: none; } .response-text { padding: 0.85rem 1rem; font-size: 0.85rem; line-height: 1.65; - color: #24292e; + color: var(--text-strong); max-height: 400px; overflow-y: auto; white-space: pre-wrap; @@ -297,8 +399,8 @@ h3 { margin: 1.5rem 0 0.75rem; color: #24292e; } /* Markdown-rendered elements */ .response-text .code-block { - background: #f6f8fa; - border: 1px solid #e1e4e8; + background: var(--surface-alt); + border: 1px solid var(--border); border-radius: 6px; padding: 0.65rem 0.85rem; margin: 0.5rem 0; @@ -312,21 +414,21 @@ h3 { margin: 1.5rem 0 0.75rem; color: #24292e; } font-size: inherit; } .response-text .inline-code { - background: #eff1f3; + background: var(--inline-code-bg); padding: 0.15em 0.35em; border-radius: 4px; font-size: 0.88em; - font-family: SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace; + font-family: var(--mono); } -.response-text .md-h1 { font-size: 1.15em; font-weight: 700; margin: 0.6em 0 0.3em; color: #24292e; } -.response-text .md-h2 { font-size: 1.05em; font-weight: 600; margin: 0.5em 0 0.25em; color: #24292e; } -.response-text .md-h3 { font-size: 0.95em; font-weight: 600; margin: 0.4em 0 0.2em; color: #24292e; } +.response-text .md-h1 { font-size: 1.15em; font-weight: 700; margin: 0.6em 0 0.3em; color: var(--text-strong); } +.response-text .md-h2 { font-size: 1.05em; font-weight: 600; margin: 0.5em 0 0.25em; color: var(--text-strong); } +.response-text .md-h3 { font-size: 0.95em; font-weight: 600; margin: 0.4em 0 0.2em; color: var(--text-strong); } .response-text .md-li { padding-left: 1em; text-indent: -0.8em; margin: 0.15em 0; } /* Tool Calls Section */ .tool-calls-section { - background: #f8f9fb; - border-top: 1px solid #e1e4e8; + background: var(--surface-alt2); + border-top: 1px solid var(--border); padding: 0.65rem 0.85rem; } @@ -334,14 +436,14 @@ h3 { margin: 1.5rem 0 0.75rem; color: #24292e; } font-size: 0.72rem; font-weight: 600; text-transform: uppercase; - color: #586069; + color: var(--text-muted); letter-spacing: 0.04em; margin-bottom: 0.5rem; } .tool-call-item { - background: #fff; - border: 1px solid #e1e4e8; + background: var(--surface); + border: 1px solid var(--border); border-radius: 6px; margin-bottom: 0.5rem; overflow: hidden; @@ -350,7 +452,7 @@ h3 { margin: 1.5rem 0 0.75rem; color: #24292e; } .tool-call-item:last-child { margin-bottom: 0; } .tool-call-error { - border-left: 3px solid #cf222e; + border-left: 3px solid var(--danger); } .tool-call-header { @@ -363,19 +465,19 @@ h3 { margin: 1.5rem 0 0.75rem; color: #24292e; } } .tool-icon { - color: #8b949e; + color: var(--text-faint); font-size: 0.9em; } .tool-name { - color: #8250df; - font-family: SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace; + color: var(--tool-name); + font-family: var(--mono); font-size: 0.85em; } /* Tool Detail Toggle */ .tool-detail { - border-top: 1px solid #eaecef; + border-top: 1px solid var(--border-light); } .tool-detail summary { list-style: none; @@ -387,22 +489,22 @@ h3 { margin: 1.5rem 0 0.75rem; color: #24292e; } cursor: pointer; padding: 0.35rem 0.7rem; font-size: 0.78rem; - color: #0366d6; + color: var(--link); display: flex; align-items: center; gap: 0.35rem; user-select: none; transition: background 0.12s; } -.tool-detail-toggle:hover { background: #f0f6ff; } +.tool-detail-toggle:hover { background: var(--accent-soft); } /* Small chevron for tool details */ .chevron-sm { display: inline-block; width: 5px; height: 5px; - border-right: 1.5px solid #0366d6; - border-bottom: 1.5px solid #0366d6; + border-right: 1.5px solid var(--link); + border-bottom: 1.5px solid var(--link); transform: rotate(-45deg); transition: transform 0.2s ease; flex-shrink: 0; @@ -423,14 +525,14 @@ h3 { margin: 1.5rem 0 0.75rem; color: #24292e; } font-size: 0.7rem; font-weight: 600; text-transform: uppercase; - color: #8b949e; + color: var(--text-faint); letter-spacing: 0.04em; margin-bottom: 0.2rem; } .tool-section pre { - background: #f6f8fa; - border: 1px solid #e1e4e8; + background: var(--surface-alt); + border: 1px solid var(--border); border-radius: 4px; padding: 0.5rem 0.65rem; font-size: 0.78rem; @@ -441,4 +543,4 @@ h3 { margin: 1.5rem 0 0.75rem; color: #24292e; } word-break: break-word; } -p { color: #586069; padding: 1rem 0; } +p { color: var(--text-muted); padding: 1rem 0; } diff --git a/internal/viewer/store_load_test.go b/internal/viewer/store_load_test.go index 3ed4a92..a4da1db 100644 --- a/internal/viewer/store_load_test.go +++ b/internal/viewer/store_load_test.go @@ -405,7 +405,7 @@ func TestDiscoverRepos_SkipsUnreadableSubdir(t *testing.T) { if err := os.Chmod(badRepo, 0000); err != nil { t.Fatal(err) } - t.Cleanup(func() { os.Chmod(badRepo, 0755) }) + t.Cleanup(func() { _ = os.Chmod(badRepo, 0755) }) repos, err := DiscoverRepos(root) if err != nil { @@ -439,7 +439,7 @@ func TestListSessions_SkipsUnreadableFiles(t *testing.T) { if err := os.Chmod(badPath, 0000); err != nil { t.Fatal(err) } - t.Cleanup(func() { os.Chmod(badPath, 0644) }) + t.Cleanup(func() { _ = os.Chmod(badPath, 0644) }) sessions, err := ListSessions(root, "repo") if err != nil { diff --git a/pages/src/content/docs/en/cli-reference.md b/pages/src/content/docs/en/cli-reference.md index c80f8cb..905b0c4 100644 --- a/pages/src/content/docs/en/cli-reference.md +++ b/pages/src/content/docs/en/cli-reference.md @@ -21,6 +21,7 @@ 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: @@ -31,12 +32,14 @@ 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. 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 ``` @@ -53,6 +56,8 @@ GitHub: https://github.com/alibaba/open-code-review | `ocr config model` | — | Interactive model-selection TUI. | | `ocr llm test` | — | Send a small chat request to verify the configured endpoint. | | `ocr llm providers` | — | List all 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` | — | Launch the local web UI for past review sessions (`localhost:5483`). | | `ocr version` | — | Print version, commit, platform, build date, and GitHub URL. | @@ -83,6 +88,7 @@ staged + unstaged + untracked changes in the current directory's repo. | `--to ` | — | — | Target ref to end the diff at (e.g., `feature-branch`). When set, OCR computes `merge-base(from, to)..to`. | | `--commit ` | `-c` | — | Single commit to review (vs its parent). | | `--preview` | `-p` | `false` | Run the filter pipeline but skip the LLM. Prints the file list and exclusion reasons. | +| `--resume ` | — | — | Resume from a previous compatible range or commit review session. | | `--format ` | `-f` | `text` | `text` (human-readable) or `json` (machine-readable comment array). | | `--audience ` | — | `human` | `human` streams progress lines; `agent` quiets stdout and prints only the final summary / JSON. | | `--background ` | `-b` | — | Optional requirement / business context injected into the plan + main prompts. | @@ -96,6 +102,8 @@ staged + unstaged + untracked changes in the current directory's repo. > Mode flags are mutually exclusive: pass either `--from`/`--to`, or > `--commit`, or neither (workspace mode). Mixing them is a hard error. +> `--resume` supports only range or commit reviews and cannot be combined +> with `--preview`. ### Modes @@ -135,6 +143,29 @@ ocr review -c abc123 Reviews the diff produced by `git show abc123` (i.e., the changes that single commit introduced). +### Resuming interrupted reviews + +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 saved sessions and +resume from 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 strict by design: + +- workspace reviews cannot be resumed +- range reviews must use the same `--from` and `--to` +- commit reviews must use the same `--commit` +- `--preview` and `--resume` cannot be used together + ### Output #### Text (default, `--audience human`) @@ -208,6 +239,8 @@ Top-level fields: | `summary` | Optional. Run aggregates: `files_reviewed`, `comments`, `total_tokens`, `input_tokens`, `output_tokens`, `cache_read_tokens` (omitempty), `cache_write_tokens` (omitempty), `elapsed`. Omitted for `skipped` runs. | | `comments` | Always present, possibly empty. Per-comment fields are the ones in the example above. | | `warnings` | Optional. Present when one or more sub-agents failed; each entry describes the affected file and the error. | +| `session_id` | Optional. Present on persisted review runs; pass this to `ocr review --resume ` when retrying compatible range or commit reviews. | +| `resume` | Optional. Present on resumed runs with `resumed_from`, `reused_files`, `rerun_files`, `previous_model`, and `current_model`. | When no files were eligible for review, JSON mode emits a `skipped` envelope instead so callers can distinguish "no changes" from "no findings": @@ -231,6 +264,48 @@ Non-fatal warnings (a single sub-agent failed, a file exceeded the token threshold, etc.) are printed inline; in JSON mode they're added to the `warnings` array. +## `ocr session` + +Lists and inspects local review session logs saved under +`~/.opencodereview/sessions/`. Use it to find a session ID, inspect +per-file checkpoint status, and resume interrupted range or commit reviews. + +```text +ocr session +ocr sessions (alias) + +Sub-commands: + list, ls List recent review sessions for the current repo + show Show one session's metadata and per-file items +``` + +### `ocr session list` + +```bash +ocr session list +ocr session list --limit 50 +ocr session list --json +``` + +| Flag | Default | Description | +|---|---|---| +| `--repo ` | current dir | Repository whose sessions should be listed. | +| `--json` | `false` | Emit session summaries as JSON. | +| `--limit ` | `20` | Cap the number of listed sessions. Use `0` for unlimited. | + +### `ocr session show` + +```bash +ocr session show +ocr session show --json +ocr session show --repo /path/to/repo +``` + +| Flag | Default | Description | +|---|---|---| +| `--repo ` | current dir | Repository whose session should be inspected. | +| `--json` | `false` | Emit session metadata and per-file items as JSON. | + ## `ocr rules` Rule introspection. There is exactly one subcommand: diff --git a/pages/src/content/docs/en/quickstart.md b/pages/src/content/docs/en/quickstart.md index 7ab60e3..d2a97d0 100644 --- a/pages/src/content/docs/en/quickstart.md +++ b/pages/src/content/docs/en/quickstart.md @@ -16,6 +16,9 @@ Get your first code review running in a few minutes. ```bash npm install -g @alibaba-group/open-code-review +``` + +```bash ocr version ``` diff --git a/pages/src/content/docs/ja/cli-reference.md b/pages/src/content/docs/ja/cli-reference.md index 22691c7..c62da6e 100644 --- a/pages/src/content/docs/ja/cli-reference.md +++ b/pages/src/content/docs/ja/cli-reference.md @@ -20,6 +20,7 @@ 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: @@ -30,12 +31,14 @@ 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. 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 ``` @@ -52,6 +55,8 @@ GitHub: https://github.com/alibaba/open-code-review | `ocr config model` | — | 対話的な model 選択 TUI。 | | `ocr llm test` | — | 短い chat リクエストを送信し、設定されたエンドポイントを検証します。 | | `ocr llm providers` | — | 組み込みの LLM プロバイダーをすべて一覧表示します。 | +| `ocr session list` | `ocr sessions list`, `ocr session ls` | 保存されたレビューセッションを一覧表示します。 | +| `ocr session show ` | `ocr sessions show ` | 1つのセッションとファイル単位のチェックポイントを表示します。 | | `ocr viewer` | — | 過去のレビューセッション用のローカル Web UI を起動します(`localhost:5483`)。 | | `ocr version` | — | バージョン、commit、プラットフォーム、ビルド日、GitHub URL を出力します。 | @@ -79,6 +84,7 @@ ocr r [flags] (alias) | `--to ` | — | — | diff の終了 ref(例: `feature-branch`)。設定すると OCR は `merge-base(from, to)..to` を計算します。 | | `--commit ` | `-c` | — | 単一の commit をレビューします(その親との差分)。 | | `--preview` | `-p` | `false` | フィルタリングのパイプラインを実行しますが LLM はスキップします。ファイル一覧と除外理由を出力します。 | +| `--resume ` | — | — | 以前の互換性のある範囲または単一 commit レビューセッションから再開します。 | | `--format ` | `-f` | `text` | `text`(人間が読みやすい形式)または `json`(機械可読なコメント配列)。 | | `--audience ` | — | `human` | `human` は進捗行をストリーム出力します。`agent` は stdout を静音化し、最終サマリー / JSON のみを出力します。 | | `--background ` | `-b` | — | plan + main prompt に注入する、任意の要件 / 業務コンテキスト。 | @@ -92,6 +98,7 @@ ocr r [flags] (alias) > モード引数は排他です: `--from`/`--to` を渡すか、`--commit` を渡すか、いずれも渡さない(ワークスペースモード)かのいずれかです。 > 混在させるとそのままエラーになります。 +> `--resume` は範囲または単一 commit レビューのみ対応し、`--preview` とは併用できません。 ### モード @@ -125,6 +132,28 @@ ocr review -c abc123 `git show abc123` が生成する diff(すなわちその commit が導入した変更)をレビューします。 +### 中断したレビューの再開 + +すべての `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 +``` + +再開は意図的に厳密です: + +- ワークスペースレビューは再開できません +- 範囲レビューは同じ `--from` と `--to` が必要です +- 単一 commit レビューは同じ `--commit` が必要です +- `--preview` と `--resume` は併用できません + ### 出力 #### Text(デフォルト、`--audience human`) @@ -193,6 +222,8 @@ ocr review --format json --audience agent | `summary` | 任意。実行の集計: `files_reviewed`、`comments`、`total_tokens`、`input_tokens`、`output_tokens`、`cache_read_tokens`(omitempty)、`cache_write_tokens`(omitempty)、`elapsed`。`skipped` の実行時は省略されます。 | | `comments` | 常に存在しますが、空の場合があります。各コメントのフィールドは上記の例のとおりです。 | | `warnings` | 任意。1 つ以上のサブエージェントが失敗した場合に存在します。各項目は影響を受けたファイルとエラーを記述します。 | +| `session_id` | 任意。永続化されたレビュー実行に含まれます。互換性のある範囲または単一 commit レビューを再試行する際に `ocr review --resume ` へ渡せます。 | +| `resume` | 任意。再開した実行で存在し、`resumed_from`、`reused_files`、`rerun_files`、`previous_model`、`current_model` を含みます。 | レビュー対象のファイルがない場合、JSON モードは `skipped` の外殻を発行し、呼び出し側が「変更なし」と「発見なし」を区別できるようにします: @@ -213,6 +244,48 @@ ocr review --format json --audience agent 致命的でない警告(個々のサブエージェントの失敗、あるファイルが token しきい値を超過、など)はインラインで出力されます。JSON モードでは `warnings` 配列に追加されます。 +## `ocr session` + +`~/.opencodereview/sessions/` 配下に保存されたローカルレビューセッションログを一覧表示・確認します。 +session ID の確認、ファイル単位のチェックポイント状態の確認、中断した範囲または単一 commit +レビューの再開に使用します。 + +```text +ocr session +ocr sessions (alias) + +Sub-commands: + list, ls List recent review sessions for the current repo + show Show one session's metadata and per-file items +``` + +### `ocr session list` + +```bash +ocr session list +ocr session list --limit 50 +ocr session list --json +``` + +| 引数 | デフォルト | 説明 | +|---|---|---| +| `--repo ` | カレントディレクトリ | セッションを一覧表示するリポジトリ。 | +| `--json` | `false` | セッションサマリーを JSON として出力します。 | +| `--limit ` | `20` | 一覧表示するセッション数を制限します。`0` は無制限です。 | + +### `ocr session show` + +```bash +ocr session show +ocr session show --json +ocr session show --repo /path/to/repo +``` + +| 引数 | デフォルト | 説明 | +|---|---|---| +| `--repo ` | カレントディレクトリ | セッションを確認するリポジトリ。 | +| `--json` | `false` | セッションのメタデータとファイル単位の項目を JSON として出力します。 | + ## `ocr rules` ルールの自己確認です。サブコマンドは 1 つだけです: diff --git a/pages/src/content/docs/ja/quickstart.md b/pages/src/content/docs/ja/quickstart.md index a29e594..00c9778 100644 --- a/pages/src/content/docs/ja/quickstart.md +++ b/pages/src/content/docs/ja/quickstart.md @@ -16,6 +16,9 @@ sidebar: ```bash npm install -g @alibaba-group/open-code-review +``` + +```bash ocr version ``` diff --git a/pages/src/content/docs/zh/cli-reference.md b/pages/src/content/docs/zh/cli-reference.md index 34387db..39ca0fa 100644 --- a/pages/src/content/docs/zh/cli-reference.md +++ b/pages/src/content/docs/zh/cli-reference.md @@ -20,6 +20,7 @@ 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: @@ -30,12 +31,14 @@ 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. 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 ``` @@ -52,6 +55,8 @@ GitHub: https://github.com/alibaba/open-code-review | `ocr config model` | — | 交互式 model 选择 TUI。 | | `ocr llm test` | — | 发送一条简短 chat 请求以验证配置的端点。 | | `ocr llm providers` | — | 列出所有内置 LLM provider。 | +| `ocr session list` | `ocr sessions list`, `ocr session ls` | 列出已保存的评审会话。 | +| `ocr session show ` | `ocr sessions show ` | 查看单个会话及其逐文件检查点。 | | `ocr viewer` | — | 启动用于历史评审会话的本地 Web UI(`localhost:5483`)。 | | `ocr version` | — | 打印版本、commit、平台、构建日期与 GitHub URL。 | @@ -80,6 +85,7 @@ unstaged + untracked 变更。 | `--to ` | — | — | diff 结束 ref(如 `feature-branch`)。设置后 OCR 计算 `merge-base(from, to)..to`。 | | `--commit ` | `-c` | — | 评审单个 commit(相对其父)。 | | `--preview` | `-p` | `false` | 运行过滤流水线但跳过 LLM。打印文件列表与排除原因。 | +| `--resume ` | — | — | 从之前兼容的区间或单 commit 评审会话恢复。 | | `--format ` | `-f` | `text` | `text`(人类可读)或 `json`(机器可读的评论数组)。 | | `--audience ` | — | `human` | `human` 流式输出进度行;`agent` 静默 stdout,只打印最终摘要 / JSON。 | | `--background ` | `-b` | — | 注入 plan + main prompt 的可选需求 / 业务上下文。 | @@ -93,6 +99,7 @@ unstaged + untracked 变更。 > 模式参数互斥:传 `--from`/`--to`,或 `--commit`,或都不传(工作区模式)。 > 混用会直接报错。 +> `--resume` 仅支持区间或单 commit 评审,不能与 `--preview` 同时使用。 ### 模式 @@ -129,6 +136,27 @@ ocr review -c abc123 评审 `git show abc123` 产生的 diff(即该 commit 引入的变更)。 +### 恢复中断的评审 + +每次 `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 +``` + +恢复逻辑是严格的: + +- 工作区评审不能恢复 +- 区间评审必须使用相同的 `--from` 和 `--to` +- 单 commit 评审必须使用相同的 `--commit` +- `--preview` 和 `--resume` 不能同时使用 + ### 输出 #### Text(默认,`--audience human`) @@ -201,6 +229,8 @@ ocr review --format json --audience agent | `summary` | 可选。运行聚合:`files_reviewed`、`comments`、`total_tokens`、`input_tokens`、`output_tokens`、`cache_read_tokens`(omitempty)、`cache_write_tokens`(omitempty)、`elapsed`。`skipped` 运行时省略。 | | `comments` | 总是存在,可能为空。每条评论的字段如上例。 | | `warnings` | 可选。当一个或多个子 agent 失败时存在;每条描述受影响文件与错误。 | +| `session_id` | 可选。持久化的评审运行会包含该字段;重试兼容的区间或单 commit 评审时可传给 `ocr review --resume `。 | +| `resume` | 可选。恢复运行时存在,包含 `resumed_from`、`reused_files`、`rerun_files`、`previous_model` 和 `current_model`。 | 当没有文件可评审时,JSON 模式会发一个 `skipped` 外壳,以便调用方区分“无变更” 与“无发现”: @@ -223,6 +253,47 @@ ocr review --format json --audience agent 非致命警告(单个子 agent 失败、某文件超过 token 阈值等)内联打印;JSON 模式下 会加入 `warnings` 数组。 +## `ocr session` + +列出和查看保存在 `~/.opencodereview/sessions/` 下的本地评审会话日志。 +可用它查找 session ID、查看逐文件检查点状态,并恢复中断的区间或单 commit 评审。 + +```text +ocr session +ocr sessions (alias) + +Sub-commands: + list, ls List recent review sessions for the current repo + show Show one session's metadata and per-file items +``` + +### `ocr session list` + +```bash +ocr session list +ocr session list --limit 50 +ocr session list --json +``` + +| 参数 | 默认 | 说明 | +|---|---|---| +| `--repo ` | 当前目录 | 要列出会话的仓库。 | +| `--json` | `false` | 以 JSON 输出会话摘要。 | +| `--limit ` | `20` | 限制列出的会话数量。使用 `0` 表示不限制。 | + +### `ocr session show` + +```bash +ocr session show +ocr session show --json +ocr session show --repo /path/to/repo +``` + +| 参数 | 默认 | 说明 | +|---|---|---| +| `--repo ` | 当前目录 | 要查看会话的仓库。 | +| `--json` | `false` | 以 JSON 输出会话元数据和逐文件条目。 | + ## `ocr rules` 规则自查。只有一个子命令: diff --git a/pages/src/content/docs/zh/quickstart.md b/pages/src/content/docs/zh/quickstart.md index 10f8753..a7954b3 100644 --- a/pages/src/content/docs/zh/quickstart.md +++ b/pages/src/content/docs/zh/quickstart.md @@ -16,6 +16,9 @@ sidebar: ```bash npm install -g @alibaba-group/open-code-review +``` + +```bash ocr version ``` diff --git a/plugins/open-code-review/commands/review.md b/plugins/open-code-review/commands/review.md index 82a1e99..1e2e19a 100644 --- a/plugins/open-code-review/commands/review.md +++ b/plugins/open-code-review/commands/review.md @@ -17,6 +17,7 @@ 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`. @@ -32,4 +33,4 @@ Silently discard low-confidence comments. Display the remaining comments. ### Step 3: Fix -Automatically fix issues and suggestions that are worth adopting. \ No newline at end of file +Automatically fix issues and suggestions that are worth adopting. diff --git a/scripts/github-actions/post-review-comments.js b/scripts/github-actions/post-review-comments.js new file mode 100644 index 0000000..fba461e --- /dev/null +++ b/scripts/github-actions/post-review-comments.js @@ -0,0 +1,1203 @@ +"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 9fc1dcd..78679e9 100644 --- a/scripts/github-actions/post-review-comments.test.js +++ b/scripts/github-actions/post-review-comments.test.js @@ -1,47 +1,39 @@ #!/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 vm = require("vm"); +const { runPostReviewComments, safeFence, fencedBlock, lineSpan, sameCommentSpan, overlapsHistory, resolveThreshold, DEFAULT_OVERLAP_THRESHOLD, newCommentId, getPostedCommentIds, computeRetryDelayMs, formatWarnings } = require(path.join(__dirname, "post-review-comments.js")); -const repoRoot = path.join(__dirname, "..", ".."); -const workflowFiles = [ - ".github/workflows/ocr-review.yml", - "examples/github_actions/ocr-review.yml", -]; +// 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"; -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}`); -} +const context = { + repo: { owner: "owner", repo: "repo" }, + issue: { number: 123 }, + eventName: "pull_request_target", + payload: { pull_request: { head: { sha: "head-sha" } } }, +}; function mockFs(resultText, stderrText) { return { @@ -53,64 +45,241 @@ function mockFs(resultText, stderrText) { }; } -function mockGithub(options) { +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 = {}) { 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); - if (createReviewCalls.length === 1 && options.bulkError) { - throw new Error(options.bulkError); + 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.individualError) { - throw new Error(options.individualError); + // 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(); } - return { data: {} }; + 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 || [] }; }, }, issues: { + listComments: async (params) => { + listCommentsCalls.push(params); + return { data: opts.existingSummary || [] }; + }, createComment: async (params) => { issueComments.push(params); - return { data: {} }; + 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}` } }; }, }, }, }; } -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" } } }, +function mockCore() { + const outputs = {}; + return { + outputs, + setOutput(name, value) { outputs[name] = value; }, + info() {}, }; - const sandbox = { - github, - context, - 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; } -async function testFailedInlineCommentsAreSummarized(workflowPath) { +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({ + github, + context, + core, + fs, + resultPath: "/tmp/ocr-result.json", + stderrPath: "/tmp/ocr-stderr.log", + ...options, + }); + return { github, core, outputs: core.outputs }; +} + +// ---- Test cases (mirror PLAN §7) ---- + +async function testFailedInlineCommentsAreSummarized() { const result = { comments: [ { @@ -134,37 +303,1034 @@ async function testFailedInlineCommentsAreSummarized(workflowPath) { warnings: [], }; - 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"', + 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 }, }); - assert.strictEqual(github.createReviewCalls.length, 2); - assert.strictEqual(github.issueComments.length, 1); - const body = github.issueComments[0].body; + 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.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 testErrorCommentUsesSafeFence(workflowPath) { - const github = await runPostReviewScript(workflowPath, { - fs: mockFs("not json", "stderr includes a fence\n```js\nbroken();\n```"), +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 }, }); 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 main() { - for (const workflowPath of workflowFiles) { - await testFailedInlineCommentsAreSummarized(workflowPath); - await testErrorCommentUsesSafeFence(workflowPath); +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."); +} + main().catch((err) => { console.error(err); process.exit(1);