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 505d335..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 # ディレクトリまたは特定のファイルをスキャン @@ -404,12 +419,36 @@ ocr review \ `--format json`フラグは、CIスクリプトでのパースに適した機械可読な結果を出力します。 +各指摘には2つの構造化フィールドが付与され、CI統合はコメント本文を再パースせずに並べ替え・グループ化・フィルタリング・ビルドのゲート判定を行えます: + +| フィールド | 許可される値 | 説明 | +|-----------|-------------|------| +| `category` | `bug`、`security`、`performance`、`maintainability`、`test`、`style`、`documentation`、`other` | 指摘が属するカテゴリ。 | +| `severity` | `critical`、`high`、`medium`、`low` | 指摘の重要度。 | + +JSON出力ではこの2つのフィールドは`content`や`start_line`などと同じ階層に並びます。ターミナルでは、コメントの前にインラインの`[category · severity]`バッジとして表示され、重要度に応じて色分けされます。 + 統合例は[`examples/`](./examples/)ディレクトリを参照してください: - [`github_actions/`](./examples/github_actions/) — GitHub Actions統合の例 - [`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/) ディレクトリを参照してください。 + ## コマンド | コマンド | エイリアス | 説明 | @@ -423,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 ` | 1つのセッションとファイル単位のチェックポイントを表示 | | `ocr viewer` | `ocr v` | `localhost:5483`でWebUIセッションビューアーを起動 | | `ocr version` | — | バージョン情報を表示 | @@ -436,17 +477,54 @@ 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`使用時に未指定の場合、コミットメッセージから自動取得 | +| `--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` を尊重するファイルシステムウォークにフォールバック)。 @@ -492,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 @@ -499,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 @@ -753,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 283576d..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 # 디렉터리 또는 특정 파일 스캔 @@ -404,12 +419,36 @@ ocr review \ `--format json` flag는 CI script에서 파싱하기 좋은 machine-readable 결과를 출력합니다. +각 finding에는 두 개의 구조화된 field가 포함되어, CI 통합에서 comment 텍스트를 다시 파싱하지 않고도 정렬·그룹화·필터링하거나 build를 gate할 수 있습니다: + +| Field | 허용 값 | 설명 | +|-------|--------|------| +| `category` | `bug`, `security`, `performance`, `maintainability`, `test`, `style`, `documentation`, `other` | 이슈가 속한 카테고리. | +| `severity` | `critical`, `high`, `medium`, `low` | 이슈의 중요도. | + +JSON 출력에서 두 field는 `content`, `start_line` 등과 같은 수준의 sibling으로 나타납니다. 터미널에서는 comment 앞에 인라인 `[category · severity]` badge로 표시되며 severity에 따라 색상이 지정됩니다. + 통합 예시는 [`examples/`](./examples/) 디렉터리를 참고하세요. - [`github_actions/`](./examples/github_actions/): GitHub Actions 통합 예시 - [`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 | @@ -423,6 +462,8 @@ ocr review \ | `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 정보 표시 | @@ -436,17 +477,54 @@ ocr review \ | `--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`를 따르는 파일 시스템 탐색으로 폴백). @@ -492,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 @@ -499,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 @@ -710,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 00cae07..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 @@ -406,12 +421,36 @@ The `--from` flag accepts a branch ref (e.g., `origin/main`) or commit SHA as th The `--format json` flag outputs machine-readable results suitable for parsing in CI scripts. +Each finding carries two structured fields so CI integrations can sort, group, filter, or gate builds without re-parsing comment text: + +| Field | Allowed values | Notes | +|-------|----------------|-------| +| `category` | `bug`, `security`, `performance`, `maintainability`, `test`, `style`, `documentation`, `other` | The category the issue belongs to. | +| `severity` | `critical`, `high`, `medium`, `low` | The importance of the issue. | + +In JSON output the two fields appear as siblings alongside `content`, `start_line`, etc. In the terminal, they render as an inline `[category · severity]` badge before the comment, colored by severity. + See the [`examples/`](./examples/) directory for integration examples: - [`github_actions/`](./examples/github_actions/) — GitHub Actions integration example - [`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 | @@ -425,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 | @@ -438,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 @@ -497,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 @@ -504,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 @@ -758,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 3df1068..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 # сканировать каталог или конкретные файлы @@ -406,12 +421,36 @@ ocr review \ Флаг `--format json` выводит машиночитаемый результат, удобный для разбора в CI-скриптах. +Каждое замечание содержит два структурированных поля, чтобы CI-интеграции могли сортировать, группировать, фильтровать замечания или блокировать сборку без повторного разбора текста комментария: + +| Поле | Допустимые значения | Примечание | +|------|---------------------|------------| +| `category` | `bug`, `security`, `performance`, `maintainability`, `test`, `style`, `documentation`, `other` | Категория, к которой относится замечание. | +| `severity` | `critical`, `high`, `medium`, `low` | Важность замечания. | + +В JSON-выводе эти два поля располагаются рядом с `content`, `start_line` и др. В терминале они отображаются перед комментарием как встроенный бейдж `[category · severity]`, цвет которого определяется важностью. + Примеры интеграции — в каталоге [`examples/`](./examples/): - [`github_actions/`](./examples/github_actions/) — пример интеграции с GitHub Actions - [`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/). + ## Команды | Команда | Алиас | Описание | @@ -425,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` | — | Показать информацию о версии | @@ -438,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`). @@ -494,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 @@ -501,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 @@ -755,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 40fa277..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 # 扫描指定目录或文件 @@ -404,12 +419,36 @@ ocr review \ `--format json` 参数输出适合 CI 脚本解析的机器可读结果。 +每条评审结果都带有两个结构化字段,便于 CI 集成在无需解析评论文本的情况下排序、分组、过滤或卡点构建: + +| 字段 | 允许的取值 | 说明 | +|------|-----------|------| +| `category` | `bug`、`security`、`performance`、`maintainability`、`test`、`style`、`documentation`、`other` | 问题所属的类别。 | +| `severity` | `critical`、`high`、`medium`、`low` | 问题的严重程度。 | + +在 JSON 输出中,这两个字段与 `content`、`start_line` 等平级;在终端中,它们会以内联的 `[category · severity]` 徽章形式显示在评论前,并按严重程度着色。 + 集成示例请参见 [`examples/`](./examples/) 目录: - [`github_actions/`](./examples/github_actions/) — GitHub Actions 集成示例 - [`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/) 目录。 + ## 命令 | 命令 | 别名 | 描述 | @@ -423,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` | — | 显示版本信息 | @@ -436,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` 的文件系统遍历)。 @@ -492,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 @@ -499,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 @@ -743,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/config_cmd.go b/cmd/opencodereview/config_cmd.go index 64b511b..a7c1341 100644 --- a/cmd/opencodereview/config_cmd.go +++ b/cmd/opencodereview/config_cmd.go @@ -480,23 +480,13 @@ func ensureModelInList(models []string, model string) []string { if model == "" { return models } - if modelListContains(models, model) { + if llm.ModelListContains(models, model) { return models } out := append([]string(nil), models...) return append(out, model) } -func modelListContains(models []string, target string) bool { - target = strings.TrimSpace(target) - for _, model := range models { - if model == target { - return true - } - } - return false -} - func setProviderValue(cfg *Config, key, value string) error { parts := strings.SplitN(key, ".", 3) if len(parts) != 3 || parts[1] == "" || parts[2] == "" { 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.go b/cmd/opencodereview/git.go index 0185b56..dba1d6f 100644 --- a/cmd/opencodereview/git.go +++ b/cmd/opencodereview/git.go @@ -12,6 +12,15 @@ func runGitCmd(repoDir string, args ...string) ([]byte, error) { return cmd.CombinedOutput() } +// runGitCmdStdout is like runGitCmd but returns stdout only. Use it when the +// output is consumed as data (e.g. a resolved path) so git's stderr warnings +// (permissions, deprecations, config notices) can't pollute the result. +func runGitCmdStdout(repoDir string, args ...string) ([]byte, error) { + fullArgs := append([]string{"-C", repoDir}, args...) + cmd := exec.Command("git", fullArgs...) + return cmd.Output() +} + func getCommitMessage(repoDir, commit string) (string, error) { out, err := runGitCmd(repoDir, "log", "-1", "--format=%B", "--end-of-options", commit) 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 3ec31c3..0006164 100644 --- a/cmd/opencodereview/output.go +++ b/cmd/opencodereview/output.go @@ -61,7 +61,19 @@ func renderComment(comment model.LlmComment) { fmt.Printf("\n\033[2m─── %s:%d-%d ───\033[0m\n", sanitizeTerminal(comment.Path), comment.StartLine, comment.EndLine) if comment.Content != "" { - for _, ln := range wrapByRunes(sanitizeTerminal(comment.Content), 100) { + badge := buildBadge(comment) + content := sanitizeTerminal(comment.Content) + if badge != "" { + // Prepend the plain badge text to the content so it wraps inline with + // the first line, then colorize just the badge prefix after wrapping. + content = badge + " " + content + } + lines := wrapByRunes(content, 100) + for i, ln := range lines { + if i == 0 && badge != "" && strings.HasPrefix(ln, badge) { + color := severityColor(comment.Severity) + ln = color + badge + "\033[0m" + ln[len(badge):] + } fmt.Printf("%s\n", ln) } fmt.Println() @@ -83,6 +95,41 @@ func renderComment(comment model.LlmComment) { fmt.Println() } +// buildBadge renders a compact "[category · severity]" tag for a finding. It returns +// an empty string when neither structured field is present, so text output for findings +// without metadata is unchanged. +func buildBadge(comment model.LlmComment) string { + category := sanitizeTerminal(comment.Category) + severity := sanitizeTerminal(comment.Severity) + switch { + case category != "" && severity != "": + return fmt.Sprintf("[%s · %s]", category, severity) + case category != "": + return fmt.Sprintf("[%s]", category) + case severity != "": + return fmt.Sprintf("[%s]", severity) + default: + return "" + } +} + +// severityColor maps a finding severity to an ANSI color used for its badge. +// Unknown or empty severities fall back to dim. +func severityColor(severity string) string { + switch severity { + case "critical": + return "\033[1;91m" // bold bright red + case "high": + return "\033[91m" // bright red + case "medium": + return "\033[93m" // bright yellow + case "low": + return "\033[94m" // bright blue + default: + return "\033[2m" // dim + } +} + // printDiffLine renders a single diff line with colored prefix and background on content. func printDiffLine(prefix, content, fgColor, bgColor string) { fmt.Printf("%s%s%s %s%s\033[0m\n", fgColor+bgColor, prefix, "\033[0m"+bgColor, content, "\033[0m") @@ -193,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 { @@ -216,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, @@ -231,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 { @@ -264,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 e0cccfb..038cd84 100644 --- a/cmd/opencodereview/output_test.go +++ b/cmd/opencodereview/output_test.go @@ -1,6 +1,78 @@ package main -import "testing" +import ( + "strings" + "testing" + + "github.com/open-code-review/open-code-review/internal/model" +) + +func TestBuildBadge(t *testing.T) { + tests := []struct { + name string + comment model.LlmComment + want string + }{ + {"both fields", model.LlmComment{Category: "security", Severity: "high"}, "[security · high]"}, + {"category only", model.LlmComment{Category: "bug"}, "[bug]"}, + {"severity only", model.LlmComment{Severity: "low"}, "[low]"}, + {"neither", model.LlmComment{}, ""}, + {"strips control chars", model.LlmComment{Category: "bug\x1b[0m", Severity: "high"}, "[bug[0m · high]"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := buildBadge(tt.comment); got != tt.want { + t.Errorf("buildBadge() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestSeverityColor(t *testing.T) { + // Each known severity must map to a distinct color; unknown falls back to dim. + seen := map[string]string{} + for _, sev := range []string{"critical", "high", "medium", "low"} { + c := severityColor(sev) + if c == "" { + t.Errorf("severityColor(%q) is empty", sev) + } + if prev, ok := seen[c]; ok { + t.Errorf("severityColor(%q) shares color with %q", sev, prev) + } + seen[c] = sev + } + if got := severityColor("bogus"); got != "\033[2m" { + t.Errorf("severityColor(unknown) = %q, want dim", got) + } + if got := severityColor(""); got != "\033[2m" { + t.Errorf("severityColor(empty) = %q, want dim", got) + } +} + +// TestRenderComment_BadgeInline verifies the badge is colorized and rendered inline +// with the first line of the comment content. +func TestRenderComment_BadgeInline(t *testing.T) { + out := captureStdout(t, func() { + renderComment(model.LlmComment{ + Path: "internal/mcp/client.go", + StartLine: 27, + EndLine: 27, + Content: "Potential environment variable leak.", + Category: "security", + Severity: "high", + }) + }) + if !strings.Contains(out, "[security · high]") { + t.Errorf("expected badge in output, got:\n%s", out) + } + // severity high → bright red; the badge must be wrapped in the color + reset. + if !strings.Contains(out, "\033[91m[security · high]\033[0m") { + t.Errorf("expected colorized badge, got:\n%q", out) + } + if !strings.Contains(out, "Potential environment variable leak.") { + t.Errorf("expected content in output, got:\n%s", out) + } +} func TestSanitizeTerminal(t *testing.T) { tests := []struct { @@ -21,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.go b/cmd/opencodereview/provider_cmd.go index 995efb7..db66b71 100644 --- a/cmd/opencodereview/provider_cmd.go +++ b/cmd/opencodereview/provider_cmd.go @@ -32,13 +32,9 @@ func runConfigProvider() error { final := finalModel.(providerTUIModel) if !final.confirmed { - // TUI persists changes (create/edit/model/add/delete) directly to disk - // during the session, so the on-disk file is already up to date for any - // savedInSession operation. No additional post-TUI apply step is needed. - if final.savedInSession { - return nil - } - fmt.Println("Cancelled.") + // TUI persists changes during the session; Esc only abandons the final + // provider/API-key confirmation step. + printWizardCancelled(final.savedInSession, "Configuration changes") return nil } @@ -55,6 +51,16 @@ func runConfigProvider() error { return applyOfficialProviderConfig(configPath, cfg, result) } +// printWizardCancelled prints the standard Esc-cancel message for config wizards. +// changesDescription is a short noun phrase, e.g. "Configuration changes". +func printWizardCancelled(savedInSession bool, changesDescription string) { + if savedInSession { + fmt.Printf("Cancelled. (%s made during this session were kept.)\n", changesDescription) + return + } + fmt.Println("Cancelled.") +} + func applyProviderDeletions(configPath string, cfg *Config, names []string) (bool, error) { clearedActive := false for _, name := range names { @@ -133,7 +139,8 @@ func applyCustomProviderConfig(configPath string, cfg *Config, result providerTU if result.provider == "" { return fmt.Errorf("provider name is required") } - if result.model == "" { + model := result.resolvedModel() + if model == "" { return fmt.Errorf("model is required") } @@ -142,11 +149,11 @@ func applyCustomProviderConfig(configPath string, cfg *Config, result providerTU } entry := cfg.CustomProviders[result.provider] - entry.Model = result.model + entry.Model = model if len(result.models) > 0 { entry.Models = append([]string(nil), result.models...) } - entry.Models = ensureModelInList(entry.Models, result.model) + entry.Models = ensureModelInList(entry.Models, model) if result.url != "" { entry.URL = result.url } @@ -162,14 +169,16 @@ func applyCustomProviderConfig(configPath string, cfg *Config, result providerTU } if result.apiKey != "" { entry.APIKey = result.apiKey + } else { + entry.APIKey = "" } cfg.CustomProviders[result.provider] = entry if !result.isEdit { cfg.Provider = result.provider - cfg.Model = result.model + cfg.Model = model } else if cfg.Provider == result.provider { - cfg.Model = result.model + cfg.Model = model } if err := saveConfig(configPath, cfg); err != nil { @@ -182,13 +191,13 @@ func applyCustomProviderConfig(configPath string, cfg *Config, result providerTU } else { fmt.Printf("\nCustom provider %q updated (not currently active).\n", result.provider) } - fmt.Printf("Model: %s\n", result.model) + fmt.Printf("Model: %s\n", model) fmt.Println("\nTip: run 'ocr config model' to switch model later.") return nil } fmt.Printf("\nProvider set to: %s (custom)\n", result.provider) - fmt.Printf("Model: %s\n", result.model) + fmt.Printf("Model: %s\n", model) fmt.Println("\nTesting connection...") if err := runLLMTest(); err != nil { @@ -202,7 +211,11 @@ func applyCustomProviderConfig(configPath string, cfg *Config, result providerTU } func applyOfficialProviderConfig(configPath string, cfg *Config, result providerTUIResult) error { - if result.provider == "" || result.model == "" { + if result.provider == "" { + return fmt.Errorf("provider and model are required") + } + model := result.resolvedModel() + if model == "" { return fmt.Errorf("provider and model are required") } @@ -223,12 +236,15 @@ func applyOfficialProviderConfig(configPath string, cfg *Config, result provider } entry := cfg.Providers[result.provider] - entry.Model = result.model + entry.Model = model if len(result.models) > 0 { entry.Models = mergeModelLists(entry.Models, result.models) } if result.apiKey != "" { entry.APIKey = result.apiKey + } else { + // Confirmed empty key: clear saved api_key so resolver falls back to $ENV_VAR. + entry.APIKey = "" } cfg.Providers[result.provider] = entry @@ -236,14 +252,14 @@ func applyOfficialProviderConfig(configPath string, cfg *Config, result provider cfg.Model = "" } cfg.Provider = result.provider - cfg.Model = result.model + cfg.Model = model if err := saveConfig(configPath, cfg); err != nil { return err } fmt.Printf("\nProvider set to: %s\n", result.provider) - fmt.Printf("Model: %s\n", result.model) + fmt.Printf("Model: %s\n", model) fmt.Println("\nTesting connection...") if err := runLLMTest(); err != nil { @@ -274,8 +290,10 @@ func runConfigModel() error { currentModel := "" provider := llm.Provider{Name: cfg.Provider, DisplayName: cfg.Provider} isCustom := false + registryModels := []string(nil) if preset, isPreset := llm.LookupProvider(cfg.Provider); isPreset { provider = preset + registryModels = append([]string(nil), preset.Models...) if entry, ok := cfg.Providers[cfg.Provider]; ok { currentModel = activeModelForProvider(cfg, cfg.Provider, entry) provider.Models = mergeModelLists(provider.Models, entry.Models) @@ -293,7 +311,15 @@ func runConfigModel() error { provider.Models = mergeModelLists(entry.Models) } - m := newModelTUI(provider, currentModel) + m := newModelTUIConfig(modelTUIConfig{ + Provider: provider, + CurrentModel: currentModel, + RegistryModels: registryModels, + ExistingCfg: cfg, + ConfigPath: configPath, + ProviderName: cfg.Provider, + IsCustom: isCustom, + }) p := tea.NewProgram(m) finalModel, err := p.Run() if err != nil { @@ -302,7 +328,7 @@ func runConfigModel() error { final := finalModel.(modelTUIModel) if final.cancelled { - fmt.Println("Cancelled.") + printWizardCancelled(final.savedInSession, "Model list changes") return nil } @@ -325,7 +351,9 @@ func runConfigModel() error { } entry := cfg.Providers[cfg.Provider] entry.Model = selectedModel - if !modelListContains(provider.Models, selectedModel) { + // Use registry-only list: provider.Models was captured before the TUI and + // may include stale entry.Models from add/delete during the session. + if !llm.ModelListContains(registryModels, selectedModel) { entry.Models = ensureModelInList(entry.Models, selectedModel) } cfg.Providers[cfg.Provider] = entry diff --git a/cmd/opencodereview/provider_cmd_test.go b/cmd/opencodereview/provider_cmd_test.go index 54dc906..d1f03bb 100644 --- a/cmd/opencodereview/provider_cmd_test.go +++ b/cmd/opencodereview/provider_cmd_test.go @@ -2,6 +2,7 @@ package main import ( "encoding/json" + "io" "os" "path/filepath" "testing" @@ -203,3 +204,175 @@ func TestApplyOfficialProviderConfig_MissingFields(t *testing.T) { t.Fatal("expected error for missing provider/model") } } + +func TestApplyOfficialProviderConfig_EmptyKeyClearsSavedAPIKey(t *testing.T) { + t.Setenv("DEEPSEEK_API_KEY", "sk-from-env") + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + cfg := &Config{ + Provider: "deepseek", + Model: "deepseek-v4-flash", + Providers: map[string]ProviderEntry{ + "deepseek": { + APIKey: "old-saved-key", + Model: "deepseek-v4-flash", + }, + }, + } + + err := applyOfficialProviderConfig(configPath, cfg, providerTUIResult{ + provider: "deepseek", + model: "deepseek-v4-flash", + apiKey: "", + }) + if err != nil { + t.Fatalf("applyOfficialProviderConfig: %v", err) + } + if got := cfg.Providers["deepseek"].APIKey; got != "" { + t.Errorf("in-memory APIKey = %q, want empty", got) + } + diskCfg, err := loadOrCreateConfig(configPath) + if err != nil { + t.Fatalf("load config: %v", err) + } + if got := diskCfg.Providers["deepseek"].APIKey; got != "" { + t.Errorf("persisted APIKey = %q, want empty", got) + } +} + +func TestApplyCustomProviderConfig_EmptyKeyClearsSavedAPIKey(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + cfg := &Config{ + Provider: "aaa", + Model: "test", + CustomProviders: map[string]ProviderEntry{ + "aaa": { + URL: "https://example.com/v1", + Protocol: "openai", + APIKey: "old-saved-key", + Model: "test", + Models: []string{"test"}, + }, + }, + } + + err := applyCustomProviderConfig(configPath, cfg, providerTUIResult{ + provider: "aaa", + model: "test", + models: []string{"test"}, + apiKey: "", + isCustom: true, + url: "https://example.com/v1", + protocol: "openai", + }) + if err != nil { + t.Fatalf("applyCustomProviderConfig: %v", err) + } + if got := cfg.CustomProviders["aaa"].APIKey; got != "" { + t.Errorf("APIKey = %q, want empty", got) + } +} + +func TestProviderTUIResult_ResolvedModel(t *testing.T) { + r := providerTUIResult{ + provider: "baidu-qianfan", + model: "glm-5", + } + if got := r.resolvedModel(); got != "glm-5" { + t.Errorf("resolvedModel() = %q, want glm-5", got) + } + + r = providerTUIResult{ + provider: "baidu-qianfan", + sessionModelPick: map[string]string{ + "baidu-qianfan": "glm-5", + }, + } + if got := r.resolvedModel(); got != "glm-5" { + t.Errorf("resolvedModel() from session pick = %q, want glm-5", got) + } + + r = providerTUIResult{provider: "baidu-qianfan"} + if got := r.resolvedModel(); got != "" { + t.Errorf("resolvedModel() = %q, want empty", got) + } +} + +func TestApplyOfficialProviderConfig_UsesSessionModelPick(t *testing.T) { + t.Setenv("QIANFAN_API_KEY", "sk-from-env") + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + cfg := &Config{ + Provider: "deepseek", + Model: "deepseek-v4-flash", + Providers: map[string]ProviderEntry{ + "deepseek": {Model: "deepseek-v4-flash"}, + }, + } + + err := applyOfficialProviderConfig(configPath, cfg, providerTUIResult{ + provider: "baidu-qianfan", + apiKey: "", + sessionModelPick: map[string]string{ + "baidu-qianfan": "glm-5", + }, + }) + if err != nil { + t.Fatalf("applyOfficialProviderConfig: %v", err) + } + if cfg.Provider != "baidu-qianfan" { + t.Errorf("Provider = %q, want baidu-qianfan", cfg.Provider) + } + if cfg.Model != "glm-5" { + t.Errorf("Model = %q, want glm-5", cfg.Model) + } +} + +func TestPrintWizardCancelled(t *testing.T) { + tests := []struct { + name string + savedInSession bool + scope string + want string + }{ + { + name: "no changes", + savedInSession: false, + scope: "Configuration changes", + want: "Cancelled.\n", + }, + { + name: "provider wizard kept changes", + savedInSession: true, + scope: "Configuration changes", + want: "Cancelled. (Configuration changes made during this session were kept.)\n", + }, + { + name: "model wizard kept changes", + savedInSession: true, + scope: "Model list changes", + want: "Cancelled. (Model list changes made during this session were kept.)\n", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + old := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stdout = w + printWizardCancelled(tc.savedInSession, tc.scope) + _ = w.Close() + os.Stdout = old + got, err := io.ReadAll(r) + if err != nil { + t.Fatal(err) + } + if string(got) != tc.want { + t.Errorf("output = %q, want %q", string(got), tc.want) + } + }) + } +} diff --git a/cmd/opencodereview/provider_tui.go b/cmd/opencodereview/provider_tui.go index 2e0b795..8e55a76 100644 --- a/cmd/opencodereview/provider_tui.go +++ b/cmd/opencodereview/provider_tui.go @@ -58,17 +58,50 @@ type customProviderListItem struct { } type providerTUIResult struct { - provider string - model string - models []string - apiKey string - isCustom bool - isEdit bool - editTargetName string - isManual bool - url string - protocol string - authHeader string + provider string + model string + models []string + apiKey string + isCustom bool + isEdit bool + editTargetName string + isManual bool + url string + protocol string + authHeader string + sessionModelPick map[string]string +} + +// resolvedModel returns the model to persist, falling back to the in-session pick +// for the provider being finalized when result.model is empty. +func (r providerTUIResult) resolvedModel() string { + if r.model != "" { + return r.model + } + if r.sessionModelPick != nil { + if pick, ok := r.sessionModelPick[r.provider]; ok && pick != "" { + return pick + } + } + return "" +} + +func (m providerTUIModel) sessionModelPickFor(providerName string) string { + if providerName == "" || m.sessionModelPick == nil { + return "" + } + return m.sessionModelPick[providerName] +} + +func (m providerTUIModel) sessionModelPickSnapshot() map[string]string { + if len(m.sessionModelPick) == 0 { + return nil + } + out := make(map[string]string, len(m.sessionModelPick)) + for k, v := range m.sessionModelPick { + out[k] = v + } + return out } type providerTUIModel struct { @@ -120,6 +153,9 @@ type providerTUIModel struct { cancelled bool formError string savedInSession bool + // sessionModelPick remembers model choices per provider during a wizard run + // without persisting inactive-provider selections to disk. + sessionModelPick map[string]string // --- delete confirmation --- confirmingDelete bool @@ -146,6 +182,17 @@ func (m providerTUIModel) customProviderActiveModel(cp customProviderListItem) s return activeModelForProvider(m.existingCfg, cp.name, entry) } +func (m providerTUIModel) officialProviderActiveModel(p llm.Provider) string { + if m.existingCfg == nil || m.existingCfg.Provider != p.Name { + return "" + } + entry := ProviderEntry{} + if m.existingCfg.Providers != nil { + entry = m.existingCfg.Providers[p.Name] + } + return activeModelForProvider(m.existingCfg, p.Name, entry) +} + func collectCustomProviders(cfg *Config) []customProviderListItem { if cfg == nil || cfg.CustomProviders == nil { return nil @@ -287,7 +334,7 @@ func newProviderTUI(cfg *Config, configPath string) providerTUIModel { if cfg.Llm.AuthToken != "" { m.manualTokenOriginal = cfg.Llm.AuthToken m.manualTokenMasked = true - m.manualTokenInput.SetValue(strings.Repeat("*", 20)) + m.manualTokenInput.SetValue(maskedSecretPlaceholder()) } if cfg.Llm.UseAnthropic == nil || *cfg.Llm.UseAnthropic { m.manualProtocolIdx = 0 // anthropic @@ -330,12 +377,97 @@ func (m providerTUIModel) modelProviderName() string { return provider.Name } +func isUserAddedOfficialModelName(name, providerName string, registryModels []string, cfg *Config) bool { + if providerName == "" || cfg == nil { + return false + } + if llm.ModelListContains(registryModels, name) { + return false + } + entry, ok := cfg.Providers[providerName] + if !ok { + return false + } + return llm.ModelListContains(entry.Models, name) +} + +func registryModelsForProvider(name string, fallback []string) []string { + if preset, ok := llm.LookupProvider(name); ok { + return append([]string(nil), preset.Models...) + } + if len(fallback) == 0 { + return nil + } + return append([]string(nil), fallback...) +} + +func applyModelDeleteToEntry(entry ProviderEntry, name string) ProviderEntry { + entry.Models = removeModels(entry.Models, []string{name}) + if entry.Model == name { + entry.Model = "" + } + return entry +} + +func clearCfgActiveModelIfDeleted(cfg *Config, providerName, name string) { + if cfg != nil && cfg.Provider == providerName && cfg.Model == name { + cfg.Model = "" + } +} + +func rollbackCfgActiveModel(cfg *Config, providerName, prevModel string) { + if cfg != nil && cfg.Provider == providerName { + cfg.Model = prevModel + } +} + +func (m *modelTUIModel) rollbackModelDelete(prevEntry ProviderEntry, prevCfgModel string) { + if m.existingCfg == nil { + return + } + if m.isCustomProvider { + m.existingCfg.CustomProviders[m.providerName] = prevEntry + m.syncModelsFromConfig() + } else { + m.existingCfg.Providers[m.providerName] = prevEntry + } + rollbackCfgActiveModel(m.existingCfg, m.providerName, prevCfgModel) +} + +func (m providerTUIModel) isUserAddedOfficialModel(name string) bool { + if m.activeTab != tabOfficial { + return false + } + provider := m.currentProvider() + return isUserAddedOfficialModelName(name, provider.Name, registryModelsForProvider(provider.Name, provider.Models), m.existingCfg) +} + +// cursorOnDeletableModel reports whether the model-step cursor is on a row that +// can be deleted (not on "Enter custom model name..."). +func (m providerTUIModel) cursorOnDeletableModel() bool { + if m.step != stepModel || m.confirmingDeleteModel { + return false + } + models := m.models() + if m.modelIdx >= len(models) { + return false + } + switch m.activeTab { + case tabCustom: + return m.customIdx < len(m.customProviders) + case tabOfficial: + return m.isUserAddedOfficialModel(models[m.modelIdx]) + default: + return false + } +} + func (m providerTUIModel) models() []string { switch m.activeTab { case tabOfficial: - models := m.currentProvider().Models + provider := m.currentProvider() + models := registryModelsForProvider(provider.Name, provider.Models) if m.existingCfg != nil { - provider := m.currentProvider() if entry, ok := m.existingCfg.Providers[provider.Name]; ok { models = mergeModelLists(models, entry.Models) } @@ -349,12 +481,19 @@ func (m providerTUIModel) models() []string { return nil } -func (m *providerTUIModel) prepareModelSelection(currentModel string) { +func (m *providerTUIModel) prepareModelSelection(providerName, configModel string) { m.modelIdx = 0 m.customModel = false m.modelInput.Blur() m.modelInput.SetValue("") + currentModel := configModel + if providerName != "" && m.sessionModelPick != nil { + if pick, ok := m.sessionModelPick[providerName]; ok && pick != "" { + currentModel = pick + } + } + models := m.models() if currentModel == "" { return @@ -370,6 +509,32 @@ func (m *providerTUIModel) prepareModelSelection(currentModel string) { m.modelInput.SetValue(currentModel) } +func (m providerTUIModel) providerNameForModelStep() string { + switch m.activeTab { + case tabOfficial: + return m.currentProvider().Name + case tabCustom: + if cp, ok := m.selectedCustomProvider(); ok { + return cp.name + } + } + return "" +} + +func (m *providerTUIModel) recordSessionModelPick(model string) { + if model == "" { + return + } + name := m.providerNameForModelStep() + if name == "" { + return + } + if m.sessionModelPick == nil { + m.sessionModelPick = make(map[string]string) + } + m.sessionModelPick[name] = model +} + func (m *providerTUIModel) customProviderEntry(name string, fallback ProviderEntry) ProviderEntry { if m.existingCfg != nil { if entry, ok := m.existingCfg.CustomProviders[name]; ok { @@ -387,43 +552,9 @@ func (m *providerTUIModel) syncSessionModelSelection() error { if model == "" { return nil } - - switch m.activeTab { - case tabCustom: - cp, ok := m.selectedCustomProvider() - if !ok { - return nil - } - entry := m.customProviderEntry(cp.name, cp.entry) - entry.Model = model - if m.existingCfg.CustomProviders == nil { - m.existingCfg.CustomProviders = make(map[string]ProviderEntry) - } - m.existingCfg.CustomProviders[cp.name] = entry - cp.entry = entry - m.customProviders[m.customIdx] = cp - if m.existingCfg.Provider == cp.name { - m.existingCfg.Model = model - } - case tabOfficial: - provider := m.currentProvider() - if m.existingCfg.Providers == nil { - m.existingCfg.Providers = make(map[string]ProviderEntry) - } - entry := m.existingCfg.Providers[provider.Name] - entry.Model = model - m.existingCfg.Providers[provider.Name] = entry - if m.existingCfg.Provider == provider.Name { - m.existingCfg.Model = model - } - } - - if m.configPath != "" { - if err := saveConfig(m.configPath, m.existingCfg); err != nil { - return fmt.Errorf("failed to save: %w", err) - } - } - m.savedInSession = true + // Remember the pick for in-wizard navigation only; persist provider/model on + // final confirm (applyOfficialProviderConfig / applyCustomProviderConfig). + m.recordSessionModelPick(model) return nil } @@ -530,12 +661,11 @@ func (m providerTUIModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.deleteTargetName = m.customProviders[m.customIdx].name return m, nil } - if m.step == stepModel && m.activeTab == tabCustom && m.customIdx < len(m.customProviders) { + if m.step == stepModel && m.cursorOnDeletableModel() { models := m.models() - if m.modelIdx < len(models) { - m.confirmingDeleteModel = true - m.deleteModelName = models[m.modelIdx] - } + m.confirmingDeleteModel = true + m.deleteModelName = models[m.modelIdx] + return m, nil } return m, nil @@ -555,6 +685,9 @@ func (m providerTUIModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.passThroughManualInput(msg) } if m.step == stepAPIKey { + if m.apiKeyMasked && isUserEditMsg(msg) { + m.beginAPIKeyReplace() + } var cmd tea.Cmd m.apiKeyInput, cmd = m.apiKeyInput.Update(msg) return m, cmd @@ -581,17 +714,21 @@ func (m providerTUIModel) updateCustomModelInput(key string, msg tea.KeyPressMsg if name == "" { return m, nil } - for _, existing := range m.models() { - if existing == name { - m.formError = fmt.Sprintf("Already in list: %s", name) - return m, nil - } + if llm.ModelListContains(m.models(), name) { + m.formError = fmt.Sprintf("Already in list: %s", name) + return m, nil } m.formError = "" - if err := m.addCustomModelToSession(name); err != nil { + persisted, err := m.persistCustomModelName(name) + if err != nil { m.formError = err.Error() return m, nil } + if !persisted { + // No active provider context — refuse with an error message. + m.formError = "no active provider to attach this model to" + return m, nil + } m.customModel = false m.modelInput.Blur() m.modelInput.SetValue("") @@ -607,37 +744,122 @@ func (m providerTUIModel) updateCustomModelInput(key string, msg tea.KeyPressMsg } } -// addCustomModelToSession appends a single model name to the current custom -// provider's Models list and persists in-memory state to disk. It does not -// change the active model — the user picks that explicitly from the list -// afterwards. -func (m *providerTUIModel) addCustomModelToSession(name string) error { +// persistCustomModelName appends a single model name to the active provider's +// Models list (official or custom) and saves the config. It does not change +// the active model — the user picks that explicitly from the list afterwards. +// +// Returns (persisted, error). When no provider is active (neither official +// nor custom), persisted is false and the caller decides how to handle it. +func (m *providerTUIModel) persistCustomModelName(name string) (bool, error) { + if name == "" { + return false, fmt.Errorf("model name must not be empty") + } if m.existingCfg == nil { - return nil + return false, nil } - cp, ok := m.selectedCustomProvider() - if !ok { - return nil - } - entry := m.customProviderEntry(cp.name, cp.entry) - prevEntry := cloneProviderEntry(entry) - entry.Models = append(entry.Models, name) - if m.existingCfg.CustomProviders == nil { - m.existingCfg.CustomProviders = make(map[string]ProviderEntry) - } - m.existingCfg.CustomProviders[cp.name] = entry - cp.entry = entry - m.customProviders[m.customIdx] = cp - if m.configPath != "" { - if err := saveConfig(m.configPath, m.existingCfg); err != nil { - m.existingCfg.CustomProviders[cp.name] = prevEntry - cp.entry = prevEntry - m.customProviders[m.customIdx] = cp - return fmt.Errorf("failed to save models: %w", err) + switch m.activeTab { + case tabCustom: + cp, ok := m.selectedCustomProvider() + if !ok { + return false, nil } + entry := m.customProviderEntry(cp.name, cp.entry) + prevEntry := cloneProviderEntry(entry) + entry.Models = append(entry.Models, name) + if m.existingCfg.CustomProviders == nil { + m.existingCfg.CustomProviders = make(map[string]ProviderEntry) + } + m.existingCfg.CustomProviders[cp.name] = entry + cp.entry = entry + m.customProviders[m.customIdx] = cp + if m.configPath != "" { + if err := saveConfig(m.configPath, m.existingCfg); err != nil { + if !m.reloadConfigAfterSaveFailure() { + m.existingCfg.CustomProviders[cp.name] = prevEntry + cp.entry = prevEntry + m.customProviders[m.customIdx] = cp + } + return false, fmt.Errorf("failed to save models: %w", err) + } + } + m.savedInSession = true + return true, nil + case tabOfficial: + provider := m.currentProvider() + if provider.Name == "" { + return false, nil + } + if m.existingCfg.Providers == nil { + m.existingCfg.Providers = make(map[string]ProviderEntry) + } + entry := m.existingCfg.Providers[provider.Name] + prevEntry := cloneProviderEntry(entry) + entry.Models = append(entry.Models, name) + m.existingCfg.Providers[provider.Name] = entry + // Intentionally do not mutate m.providers[officialIdx].Models: that slice + // is a read-only snapshot from the provider registry (llm.ListProviders). + // User-added models live only in existingCfg.Providers; models() merges both + // at display time, unlike custom tab where customProviders is the sole list. + if m.configPath != "" { + if err := saveConfig(m.configPath, m.existingCfg); err != nil { + if !m.reloadConfigAfterSaveFailure() { + m.existingCfg.Providers[provider.Name] = prevEntry + } + return false, fmt.Errorf("failed to save models: %w", err) + } + } + m.savedInSession = true + return true, nil + default: + return false, fmt.Errorf("unsupported tab for custom model: %v", m.activeTab) } - m.savedInSession = true - return nil +} + +const maskedSecretDisplayLen = 20 + +func maskedSecretPlaceholder() string { + return strings.Repeat("*", maskedSecretDisplayLen) +} + +// isUserEditMsg reports whether msg represents user text input (typing or paste). +func isUserEditMsg(msg tea.Msg) bool { + switch msg.(type) { + case tea.KeyPressMsg, tea.PasteMsg: + return true + default: + return false + } +} + +// beginAPIKeyReplace switches from the fixed-length mask placeholder to edit +// mode so the next keystroke or paste fully replaces the saved key. While +// editing, EchoPassword shows one '*' per typed character. +func (m *providerTUIModel) beginAPIKeyReplace() { + if !m.apiKeyMasked { + return + } + m.apiKeyMasked = false + m.apiKeyOriginal = "" + m.apiKeyInput.SetValue("") +} + +// customAPIKeyForSave reports the API key to persist and whether the user edited +// the field (vs left the masked placeholder untouched). +func (m providerTUIModel) customAPIKeyForSave() (key string, edited bool) { + if m.apiKeyMasked { + return m.apiKeyOriginal, false + } + return strings.TrimSpace(m.apiKeyInput.Value()), true +} + +// beginManualTokenReplace is the manual-tab equivalent of beginAPIKeyReplace. +func (m *providerTUIModel) beginManualTokenReplace() { + if !m.manualTokenMasked { + return + } + m.manualTokenMasked = false + m.manualTokenOriginal = "" + m.manualTokenInput.SetValue("") } // refreshModelSelectionForCustom moves the cursor to "Enter custom model name..." @@ -651,6 +873,34 @@ func (m *providerTUIModel) refreshModelSelectionForCustom() { m.modelIdx = len(models) // land on "Enter custom model name..." } +func officialProviderEnvKeySet(p llm.Provider) bool { + return p.EnvVar != "" && os.Getenv(p.EnvVar) != "" +} + +func officialAPIKeyRequiredError(p llm.Provider) string { + if p.EnvVar != "" { + return fmt.Sprintf("API key is required (or set $%s)", p.EnvVar) + } + return "API key is required" +} + +func (m providerTUIModel) apiKeyStepCanConfirm() (ok bool, errMsg string) { + if m.apiKeyOriginal != "" { + return true, "" + } + if !m.apiKeyMasked && strings.TrimSpace(m.apiKeyInput.Value()) != "" { + return true, "" + } + if m.activeTab == tabOfficial { + p := m.currentProvider() + if officialProviderEnvKeySet(p) { + return true, "" + } + return false, officialAPIKeyRequiredError(p) + } + return false, "API key is required" +} + func (m providerTUIModel) updateAPIKeyInput(key string, msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { switch key { case "esc": @@ -659,6 +909,11 @@ func (m providerTUIModel) updateAPIKeyInput(key string, msg tea.KeyPressMsg) (te m.formError = "" return m, nil case "enter": + if ok, errMsg := m.apiKeyStepCanConfirm(); !ok { + m.formError = errMsg + return m, nil + } + m.formError = "" m.confirmed = true return m, tea.Quit case "ctrl+c": @@ -666,12 +921,7 @@ func (m providerTUIModel) updateAPIKeyInput(key string, msg tea.KeyPressMsg) (te return m, tea.Quit default: if m.apiKeyMasked { - if len(key) == 1 { - m.apiKeyMasked = false - m.apiKeyInput.SetValue("") - } else { - return m, nil - } + m.beginAPIKeyReplace() } var cmd tea.Cmd m.apiKeyInput, cmd = m.apiKeyInput.Update(msg) @@ -727,12 +977,7 @@ func (m providerTUIModel) updateCustomProviderForm(key string, msg tea.KeyPressM } if m.cpStep == cpStepAPIKey { if m.apiKeyMasked { - if len(key) == 1 { - m.apiKeyMasked = false - m.apiKeyInput.SetValue("") - } else { - return m, nil - } + m.beginAPIKeyReplace() } var cmd tea.Cmd m.apiKeyInput, cmd = m.apiKeyInput.Update(msg) @@ -760,7 +1005,7 @@ func (m *providerTUIModel) enterEditCustomProvider() { if entry.APIKey != "" { m.apiKeyOriginal = entry.APIKey m.apiKeyMasked = true - m.apiKeyInput.SetValue(strings.Repeat("*", 20)) + m.apiKeyInput.SetValue(maskedSecretPlaceholder()) } else { m.apiKeyInput.SetValue("") m.apiKeyMasked = false @@ -775,6 +1020,8 @@ func authHeaderFormError(raw string) string { ) } +const manualAuthTokenRequiredError = "Auth token is required (whitespace-only input is not accepted)" + func (m providerTUIModel) handleCustomFormEnter() (tea.Model, tea.Cmd) { switch m.cpStep { case cpStepName: @@ -827,11 +1074,14 @@ func (m providerTUIModel) handleCustomFormEnter() (tea.Model, tea.Cmd) { // Edit succeeded — drop the user into the model list for this provider. m.editingCustom = false m.editTargetName = "" + m.apiKeyInput.SetValue("") + m.apiKeyMasked = false + m.apiKeyOriginal = "" if idx := m.findCustomIdx(r.provider); idx >= 0 { m.customIdx = idx } m.step = stepModel - m.prepareModelSelection(m.customProviderEntry(r.provider, ProviderEntry{}).Model) + m.prepareModelSelection(r.provider, m.customProviderEntry(r.provider, ProviderEntry{}).Model) return m, nil } if m.creatingCustom { @@ -872,9 +1122,7 @@ func (m providerTUIModel) applyCreateCustomProvider() (tea.Model, tea.Cmd) { URL: r.url, Protocol: r.protocol, AuthHeader: r.authHeader, - } - if r.apiKey != "" { - entry.APIKey = r.apiKey + APIKey: strings.TrimSpace(m.apiKeyInput.Value()), } m.existingCfg.CustomProviders[r.provider] = entry @@ -900,7 +1148,7 @@ func (m providerTUIModel) applyCreateCustomProvider() (tea.Model, tea.Cmd) { // Drop into the model selection step so the user picks/adds a model for // the newly created provider right away. m.step = stepModel - m.prepareModelSelection("") + m.prepareModelSelection(r.provider, "") return m, nil } @@ -977,8 +1225,8 @@ func (m *providerTUIModel) applyEditCustomProviderSave() error { entry.URL = r.url entry.Protocol = r.protocol entry.AuthHeader = r.authHeader - if r.apiKey != "" { - entry.APIKey = r.apiKey + if key, edited := m.customAPIKeyForSave(); edited { + entry.APIKey = key } // If name changed, delete old key if r.editTargetName != "" && r.editTargetName != r.provider { @@ -1059,7 +1307,9 @@ func (m providerTUIModel) passThroughCPInput(msg tea.Msg) (tea.Model, tea.Cmd) { case cpStepBaseURL: m.cpURLInput, cmd = m.cpURLInput.Update(msg) case cpStepAPIKey: - // masked unlock is handled in updateCustomProviderForm default branch + if m.apiKeyMasked && isUserEditMsg(msg) { + m.beginAPIKeyReplace() + } m.apiKeyInput, cmd = m.apiKeyInput.Update(msg) case cpStepAuthHeader: m.cpAuthInput, cmd = m.cpAuthInput.Update(msg) @@ -1086,7 +1336,7 @@ func (m providerTUIModel) updateManualForm(key string, msg tea.KeyPressMsg) (tea if m.existingCfg.Llm.AuthToken != "" { m.manualTokenOriginal = m.existingCfg.Llm.AuthToken m.manualTokenMasked = true - m.manualTokenInput.SetValue(strings.Repeat("*", 20)) + m.manualTokenInput.SetValue(maskedSecretPlaceholder()) } else { m.manualTokenInput.SetValue("") m.manualTokenMasked = false @@ -1125,12 +1375,7 @@ func (m providerTUIModel) updateManualForm(key string, msg tea.KeyPressMsg) (tea } } if m.manualStep == manualStepAuthToken && m.manualTokenMasked { - if len(key) == 1 { - m.manualTokenMasked = false - m.manualTokenInput.SetValue("") - } else { - return m, nil - } + m.beginManualTokenReplace() } return m.passThroughManualInput(msg) } @@ -1187,50 +1432,14 @@ func (m providerTUIModel) updateDeleteConfirm(key string) (tea.Model, tea.Cmd) { func (m providerTUIModel) updateDeleteModelConfirm(key string) (tea.Model, tea.Cmd) { switch key { case "y", "Y": - if m.customIdx >= len(m.customProviders) { + switch m.activeTab { + case tabCustom: + return m.confirmDeleteCustomModel() + case tabOfficial: + return m.confirmDeleteOfficialModel() + default: m.confirmingDeleteModel = false - return m, nil } - models := m.models() - if m.modelIdx < len(models) { - cp := m.customProviders[m.customIdx] - cp.entry.Models = removeModels(cp.entry.Models, []string{m.deleteModelName}) - if cp.entry.Model == m.deleteModelName { - cp.entry.Model = "" - } - if m.existingCfg != nil && m.existingCfg.Provider == cp.name && - m.existingCfg.Model == m.deleteModelName { - m.existingCfg.Model = "" - } - m.customProviders[m.customIdx] = cp - if m.existingCfg != nil { - if m.existingCfg.CustomProviders == nil { - m.existingCfg.CustomProviders = make(map[string]ProviderEntry) - } - m.existingCfg.CustomProviders[cp.name] = cp.entry - } - if m.configPath != "" { - if err := saveConfig(m.configPath, m.existingCfg); err != nil { - if reloaded, reloadErr := loadOrCreateConfig(m.configPath); reloadErr == nil { - m.existingCfg = reloaded - m.customProviders = collectCustomProviders(reloaded) - } - m.formError = fmt.Sprintf("failed to save: %v", err) - m.confirmingDeleteModel = false - return m, nil - } - } - updated := m.models() - if m.modelIdx >= len(updated) { - if len(updated) > 0 { - m.modelIdx = len(updated) - 1 - } else { - m.modelIdx = 0 - } - } - } - m.savedInSession = true - m.confirmingDeleteModel = false return m, nil case "n", "N", "esc": m.confirmingDeleteModel = false @@ -1242,6 +1451,118 @@ func (m providerTUIModel) updateDeleteModelConfirm(key string) (tea.Model, tea.C return m, nil } +func (m providerTUIModel) confirmDeleteCustomModel() (tea.Model, tea.Cmd) { + if m.customIdx >= len(m.customProviders) { + m.confirmingDeleteModel = false + return m, nil + } + models := m.models() + if m.modelIdx < len(models) { + cp := m.customProviders[m.customIdx] + prevEntry := cloneProviderEntry(cp.entry) + prevCfgModel := "" + if m.existingCfg != nil && m.existingCfg.Provider == cp.name { + prevCfgModel = m.existingCfg.Model + } + cp.entry = applyModelDeleteToEntry(cp.entry, m.deleteModelName) + clearCfgActiveModelIfDeleted(m.existingCfg, cp.name, m.deleteModelName) + m.customProviders[m.customIdx] = cp + if m.existingCfg != nil { + if m.existingCfg.CustomProviders == nil { + m.existingCfg.CustomProviders = make(map[string]ProviderEntry) + } + m.existingCfg.CustomProviders[cp.name] = cp.entry + } + if m.configPath != "" { + if err := saveConfig(m.configPath, m.existingCfg); err != nil { + if !m.reloadConfigAfterSaveFailure() { + cp.entry = prevEntry + m.customProviders[m.customIdx] = cp + if m.existingCfg != nil { + m.existingCfg.CustomProviders[cp.name] = prevEntry + rollbackCfgActiveModel(m.existingCfg, cp.name, prevCfgModel) + } + } + m.formError = fmt.Sprintf("failed to save: %v", err) + m.adjustModelIdxAfterDelete() + m.confirmingDeleteModel = false + return m, nil + } + } + m.adjustModelIdxAfterDelete() + m.savedInSession = true + } + m.confirmingDeleteModel = false + return m, nil +} + +func (m providerTUIModel) confirmDeleteOfficialModel() (tea.Model, tea.Cmd) { + if !m.isUserAddedOfficialModel(m.deleteModelName) { + m.confirmingDeleteModel = false + return m, nil + } + provider := m.currentProvider() + if m.existingCfg == nil || provider.Name == "" { + m.confirmingDeleteModel = false + return m, nil + } + if m.existingCfg.Providers == nil { + m.existingCfg.Providers = make(map[string]ProviderEntry) + } + prevEntry := cloneProviderEntry(m.existingCfg.Providers[provider.Name]) + prevCfgModel := "" + if m.existingCfg.Provider == provider.Name { + prevCfgModel = m.existingCfg.Model + } + entry := applyModelDeleteToEntry(m.existingCfg.Providers[provider.Name], m.deleteModelName) + clearCfgActiveModelIfDeleted(m.existingCfg, provider.Name, m.deleteModelName) + m.existingCfg.Providers[provider.Name] = entry + if m.configPath != "" { + if err := saveConfig(m.configPath, m.existingCfg); err != nil { + if !m.reloadConfigAfterSaveFailure() { + m.existingCfg.Providers[provider.Name] = prevEntry + rollbackCfgActiveModel(m.existingCfg, provider.Name, prevCfgModel) + } + m.formError = fmt.Sprintf("failed to save: %v", err) + m.adjustModelIdxAfterDelete() + m.confirmingDeleteModel = false + return m, nil + } + } + m.adjustModelIdxAfterDelete() + // In-memory delete succeeded; configPath may be empty in tests (no disk write). + m.savedInSession = true + m.confirmingDeleteModel = false + return m, nil +} + +func (m *providerTUIModel) adjustModelIdxAfterDelete() { + updated := m.models() + if m.modelIdx >= len(updated) { + if len(updated) > 0 { + m.modelIdx = len(updated) - 1 + } else { + m.modelIdx = 0 + } + } +} + +// reloadConfigAfterSaveFailure reloads on-disk config and refreshes derived +// provider TUI state so the UI matches persisted data after a failed save. +// Returns false when reload could not run (e.g. missing/invalid config path). +func (m *providerTUIModel) reloadConfigAfterSaveFailure() bool { + if m.configPath == "" { + return false + } + reloaded, err := loadOrCreateConfig(m.configPath) + if err != nil { + return false + } + m.existingCfg = reloaded + m.customProviders = collectCustomProviders(reloaded) + return true +} + func (m providerTUIModel) handleManualFormEnter() (tea.Model, tea.Cmd) { switch m.manualStep { case manualStepURL: @@ -1262,9 +1583,11 @@ func (m providerTUIModel) handleManualFormEnter() (tea.Model, tea.Cmd) { m.manualStep = manualStepAuthToken return m, m.manualTokenInput.Focus() case manualStepAuthToken: - if m.manualTokenInput.Value() == "" && m.manualTokenOriginal == "" { + if strings.TrimSpace(m.manualTokenInput.Value()) == "" && m.manualTokenOriginal == "" { + m.formError = manualAuthTokenRequiredError return m, nil } + m.formError = "" m.manualTokenInput.Blur() m.manualStep = manualStepAuthHeader return m, m.manualAuthHeaderInput.Focus() @@ -1322,6 +1645,9 @@ func (m providerTUIModel) passThroughManualInput(msg tea.Msg) (tea.Model, tea.Cm case manualStepModel: m.manualModelInput, cmd = m.manualModelInput.Update(msg) case manualStepAuthToken: + if m.manualTokenMasked && isUserEditMsg(msg) { + m.beginManualTokenReplace() + } m.manualTokenInput, cmd = m.manualTokenInput.Update(msg) case manualStepAuthHeader: m.manualAuthHeaderInput, cmd = m.manualAuthHeaderInput.Update(msg) @@ -1344,7 +1670,7 @@ func (m providerTUIModel) handleEnter() (tea.Model, tea.Cmd) { currentModel = activeModelForProvider(m.existingCfg, m.currentProvider().Name, entry) } } - m.prepareModelSelection(currentModel) + m.prepareModelSelection(m.currentProvider().Name, currentModel) return m, nil case tabCustom: @@ -1364,7 +1690,7 @@ func (m providerTUIModel) handleEnter() (tea.Model, tea.Cmd) { cp := m.customProviders[m.customIdx] m.step = stepModel entry := m.customProviderEntry(cp.name, cp.entry) - m.prepareModelSelection(activeModelForProvider(m.existingCfg, cp.name, entry)) + m.prepareModelSelection(cp.name, activeModelForProvider(m.existingCfg, cp.name, entry)) return m, nil case tabManual: @@ -1452,7 +1778,7 @@ func (m *providerTUIModel) loadExistingAPIKey() { if cp, ok := m.selectedCustomProvider(); ok && cp.entry.APIKey != "" { m.apiKeyOriginal = cp.entry.APIKey m.apiKeyMasked = true - m.apiKeyInput.SetValue(strings.Repeat("*", 20)) + m.apiKeyInput.SetValue(maskedSecretPlaceholder()) } return } @@ -1463,7 +1789,7 @@ func (m *providerTUIModel) loadExistingAPIKey() { if entry, ok := m.existingCfg.Providers[p.Name]; ok && entry.APIKey != "" { m.apiKeyOriginal = entry.APIKey m.apiKeyMasked = true - m.apiKeyInput.SetValue(strings.Repeat("*", 20)) + m.apiKeyInput.SetValue(maskedSecretPlaceholder()) } } @@ -1483,24 +1809,28 @@ func (m providerTUIModel) result() providerTUIResult { case tabOfficial: p := m.currentProvider() model := m.selectedModelFromState() + if model == "" { + model = m.sessionModelPickFor(p.Name) + } apiKey := "" if m.apiKeyMasked { apiKey = m.apiKeyOriginal } else { - apiKey = m.apiKeyInput.Value() + apiKey = strings.TrimSpace(m.apiKeyInput.Value()) } return providerTUIResult{ - provider: p.Name, - model: model, - apiKey: apiKey, + provider: p.Name, + model: model, + apiKey: apiKey, + sessionModelPick: m.sessionModelPickSnapshot(), } case tabCustom: if m.creatingCustom || m.editingCustom { protocol := cpProtocols[m.cpProtocolIdx] - apiKey := m.apiKeyInput.Value() + apiKey := strings.TrimSpace(m.apiKeyInput.Value()) if m.apiKeyMasked { apiKey = m.apiKeyOriginal } @@ -1528,6 +1858,9 @@ func (m providerTUIModel) result() providerTUIResult { if m.customIdx < len(m.customProviders) { cp := m.customProviders[m.customIdx] model := m.selectedModelFromState() + if model == "" { + model = m.sessionModelPickFor(cp.name) + } if model == "" { model = cp.entry.Model } @@ -1535,17 +1868,18 @@ func (m providerTUIModel) result() providerTUIResult { if m.apiKeyMasked { apiKey = m.apiKeyOriginal } else { - apiKey = m.apiKeyInput.Value() + apiKey = strings.TrimSpace(m.apiKeyInput.Value()) } return providerTUIResult{ - provider: cp.name, - model: model, - models: append([]string(nil), cp.entry.Models...), - apiKey: apiKey, - isCustom: true, - url: cp.entry.URL, - protocol: cp.entry.Protocol, - authHeader: cp.entry.AuthHeader, + provider: cp.name, + model: model, + models: append([]string(nil), cp.entry.Models...), + apiKey: apiKey, + isCustom: true, + url: cp.entry.URL, + protocol: cp.entry.Protocol, + authHeader: cp.entry.AuthHeader, + sessionModelPick: m.sessionModelPickSnapshot(), } } return providerTUIResult{} @@ -1576,6 +1910,16 @@ func listCursorPrefix(isCursor bool) string { return " " } +func listCursorPrefixForModel(isCursor, userAdded bool) string { + if !isCursor { + return " " + } + if userAdded { + return " " + tuiUserModelCursorStyle.Render(tuiCursor) + " " + } + return listCursorPrefix(true) +} + func renderListName(name string, isCursor bool) string { if isCursor { return tuiSelectedItemStyle.Render(name) @@ -1583,6 +1927,13 @@ func renderListName(name string, isCursor bool) string { return tuiItemStyle.Render(name) } +func renderModelName(name string, isCursor, userAdded bool) string { + if isCursor && userAdded { + return tuiUserModelSelectedStyle.Render(name) + } + return renderListName(name, isCursor) +} + // --- View --- func (m providerTUIModel) View() tea.View { @@ -1657,6 +2008,9 @@ func (m providerTUIModel) viewOfficialTab(s *strings.Builder) { for i, p := range m.providers { isCursor := i == m.officialIdx s.WriteString(listCursorPrefix(isCursor) + renderListName(p.DisplayName, isCursor)) + if activeModel := m.officialProviderActiveModel(p); activeModel != "" { + s.WriteString(" " + tuiDimStyle.Render("("+activeModel+")")) + } s.WriteString("\n") } } @@ -1673,8 +2027,11 @@ func (m providerTUIModel) viewCustomTab(s *strings.Builder) { for i, cp := range m.customProviders { isCursor := i == m.customIdx activeModel := m.customProviderActiveModel(cp) + // userAdded here means "green highlight when selected" for user-managed rows. + highlight := isCursor - s.WriteString(listCursorPrefix(isCursor) + renderListName(cp.name, isCursor)) + s.WriteString(listCursorPrefixForModel(isCursor, highlight)) + s.WriteString(renderModelName(cp.name, isCursor, highlight)) if activeModel != "" { s.WriteString(" " + tuiDimStyle.Render("("+activeModel+")")) } @@ -1750,6 +2107,9 @@ func (m providerTUIModel) viewCustomProviderForm(s *strings.Builder) { s.WriteString(" " + m.cpURLInput.View() + "\n") case cpStepAPIKey: s.WriteString(" " + m.apiKeyInput.View() + "\n") + if m.apiKeyMasked && m.apiKeyOriginal != "" { + s.WriteString(tuiDimStyle.Render(" "+savedSecretReplaceHint(m.apiKeyOriginal)) + "\n") + } case cpStepAuthHeader: s.WriteString(" " + m.cpAuthInput.View() + "\n") } @@ -1827,6 +2187,9 @@ func (m providerTUIModel) viewManualTab(s *strings.Builder) { s.WriteString(" " + m.manualModelInput.View() + "\n") case manualStepAuthToken: s.WriteString(" " + m.manualTokenInput.View() + "\n") + if m.manualTokenMasked && m.manualTokenOriginal != "" { + s.WriteString(tuiDimStyle.Render(" "+savedSecretReplaceHint(m.manualTokenOriginal)) + "\n") + } case manualStepAuthHeader: s.WriteString(" " + m.manualAuthHeaderInput.View() + "\n") } @@ -1858,7 +2221,16 @@ func (m providerTUIModel) viewModel(s *strings.Builder) { for i, model := range models { isCursor := i == m.modelIdx - s.WriteString(listCursorPrefix(isCursor) + renderListName(model, isCursor)) + if m.activeTab == tabOfficial { + userAdded := m.isUserAddedOfficialModel(model) + s.WriteString(listCursorPrefixForModel(isCursor, userAdded)) + s.WriteString(renderModelName(model, isCursor, userAdded)) + } else { + // Custom tab: all models are user-managed; pass isCursor as userAdded + // so green highlight applies only to the selected row (not registry semantics). + s.WriteString(listCursorPrefixForModel(isCursor, isCursor)) + s.WriteString(renderModelName(model, isCursor, isCursor)) + } s.WriteString("\n") } @@ -1888,7 +2260,7 @@ func (m providerTUIModel) viewModel(s *strings.Builder) { s.WriteString(" " + tuiSelectedItemStyle.Render(fmt.Sprintf("Delete %q? (y/n)", m.deleteModelName))) s.WriteString("\n") s.WriteString(tuiHelpStyle.Render(" y Confirm · n/Esc Cancel")) - } else if m.activeTab == tabCustom && m.customIdx < len(m.customProviders) { + } else if m.cursorOnDeletableModel() { s.WriteString(tuiHelpStyle.Render(" ↑/↓ Select Enter Confirm d Delete Esc Back")) } else { s.WriteString(tuiHelpStyle.Render(" ↑/↓ Select Enter Confirm Esc Back")) @@ -1910,11 +2282,22 @@ func (m providerTUIModel) viewAPIKey(s *strings.Builder) { s.WriteString(" " + m.apiKeyInput.View()) s.WriteString("\n") + // When an API key is already saved, the input starts masked. Surface a + // hint so the user knows typing or pasting will replace the saved key, + // and show a short prefix fingerprint so they can sanity-check which key + // is currently saved without exposing it. + if m.apiKeyMasked && m.apiKeyOriginal != "" { + s.WriteString("\n") + s.WriteString(tuiDimStyle.Render(savedSecretReplaceHintLine(m.apiKeyOriginal))) + s.WriteString("\n") + } + if m.activeTab == tabOfficial { provider := m.currentProvider() if envKey := os.Getenv(provider.EnvVar); envKey != "" { s.WriteString("\n") - s.WriteString(tuiDimStyle.Render(fmt.Sprintf(" $%s is set", provider.EnvVar))) + hasSavedKey := m.apiKeyMasked && m.apiKeyOriginal != "" + s.WriteString(tuiDimStyle.Render(officialAPIKeyEnvSetHintLine(provider.EnvVar, hasSavedKey))) s.WriteString("\n") } else { s.WriteString("\n") @@ -1923,11 +2306,70 @@ func (m providerTUIModel) viewAPIKey(s *strings.Builder) { } } + if m.formError != "" { + s.WriteString("\n") + s.WriteString(tuiErrorStyle.Render(" " + m.formError)) + s.WriteString("\n") + } + s.WriteString("\n") s.WriteString(tuiHelpStyle.Render(" Enter Confirm Esc Back")) s.WriteString("\n") } +// savedSecretFingerprintMinHiddenLen is the minimum number of runes that must +// sit between the visible prefix and suffix so the fingerprint does not expose +// the entire key (e.g. a 10-rune key with prefix 6 + suffix 4). +const savedSecretFingerprintMinHiddenLen = 5 + +// savedSecretFingerprintMinLen is the minimum trimmed secret length required +// before a fingerprint is shown. Shorter keys hide the parenthetical hint. +const savedSecretFingerprintMinLen = savedSecretFingerprintPrefixLen + savedSecretFingerprintSuffixLen + savedSecretFingerprintMinHiddenLen + +// savedSecretFingerprintPrefixLen is how many leading runes to show. +const savedSecretFingerprintPrefixLen = 6 + +// savedSecretFingerprintSuffixLen is how many trailing runes to show. +const savedSecretFingerprintSuffixLen = 4 + +// savedSecretFingerprint returns a short fingerprint for display, e.g. +// "sk-a1b2...wxyz" (first 6 + "..." + last 4). Returns "" when the trimmed +// secret is shorter than savedSecretFingerprintMinLen runes. +func savedSecretFingerprint(s string) string { + s = strings.TrimSpace(s) + runes := []rune(s) + if len(runes) < savedSecretFingerprintMinLen { + return "" + } + prefix := string(runes[:savedSecretFingerprintPrefixLen]) + suffix := string(runes[len(runes)-savedSecretFingerprintSuffixLen:]) + return prefix + "..." + suffix +} + +// savedSecretReplaceHint builds the replace hint text without leading indent. +func savedSecretReplaceHint(original string) string { + hint := "Type or paste to replace the saved key." + if fp := savedSecretFingerprint(original); fp != "" { + hint += fmt.Sprintf(" (saved: %s)", fp) + } + return hint +} + +func savedSecretReplaceHintLine(original string) string { + return " " + savedSecretReplaceHint(original) +} + +func officialAPIKeyEnvSetHint(envVar string, hasSavedKey bool) string { + if hasSavedKey { + return fmt.Sprintf("$%s is set; used only when no key is saved here.", envVar) + } + return fmt.Sprintf("$%s is set. Leave empty to use it; enter a key here to override.", envVar) +} + +func officialAPIKeyEnvSetHintLine(envVar string, hasSavedKey bool) string { + return " " + officialAPIKeyEnvSetHint(envVar, hasSavedKey) +} + // --- Styles --- const tuiCursor = "▸" @@ -1944,6 +2386,13 @@ var ( Bold(true). Foreground(lipgloss.Color("12")) + tuiUserModelSelectedStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("10")) + + tuiUserModelCursorStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("10")) + tuiItemStyle = lipgloss.NewStyle() tuiDimStyle = lipgloss.NewStyle(). @@ -1965,6 +2414,16 @@ var ( // --- Model-only TUI (for `ocr config model`) --- +type modelTUIConfig struct { + Provider llm.Provider + CurrentModel string + RegistryModels []string + ExistingCfg *Config + ConfigPath string + ProviderName string + IsCustom bool +} + type modelTUIModel struct { width int height int @@ -1976,52 +2435,231 @@ type modelTUIModel struct { modelInput textinput.Model activeModel string + registryModels []string + existingCfg *Config + configPath string + providerName string + isCustomProvider bool + + confirmingDeleteModel bool + deleteModelName string + formError string + confirmed bool cancelled bool + + // savedInSession is true after a model add/delete was persisted during the session. + savedInSession bool } +// newModelTUI builds a model-only TUI for tests. It has no config path or existing +// config, so add/delete/persist operations are unavailable — use newModelTUIConfig +// in production (ocr config model). IsCustom follows whether the provider is a preset. func newModelTUI(provider llm.Provider, currentModel string) modelTUIModel { + registryModels := append([]string(nil), provider.Models...) + isCustom := true + if preset, ok := llm.LookupProvider(provider.Name); ok { + isCustom = false + registryModels = append([]string(nil), preset.Models...) + } + return newModelTUIConfig(modelTUIConfig{ + Provider: provider, + CurrentModel: currentModel, + ProviderName: provider.Name, + RegistryModels: registryModels, + IsCustom: isCustom, + }) +} + +func newModelTUIConfig(cfg modelTUIConfig) modelTUIModel { mi := textinput.New() mi.Placeholder = "model name(s), comma-separated" mi.SetWidth(50) m := modelTUIModel{ - provider: provider, - models: provider.Models, - width: 80, - height: 24, - modelInput: mi, - activeModel: currentModel, + provider: cfg.Provider, + width: 80, + height: 24, + modelInput: mi, + activeModel: cfg.CurrentModel, + registryModels: append([]string(nil), cfg.RegistryModels...), + existingCfg: cfg.ExistingCfg, + configPath: cfg.ConfigPath, + providerName: cfg.ProviderName, + isCustomProvider: cfg.IsCustom, } - if currentModel != "" { + if cfg.IsCustom { + m.models = append([]string(nil), cfg.Provider.Models...) + } + + if cfg.CurrentModel != "" { found := false - for i, model := range m.models { - if model == currentModel { + models := m.displayModels() + for i, model := range models { + if model == cfg.CurrentModel { m.modelIdx = i found = true break } } if !found { - m.modelIdx = len(m.models) - m.modelInput.SetValue(currentModel) + m.modelIdx = len(models) + m.modelInput.SetValue(cfg.CurrentModel) } } return m } +func (m modelTUIModel) displayModels() []string { + // Custom providers store the list in m.models (updated on add/delete). + // Official providers derive the list from registry + config on each call. + if m.isCustomProvider { + return m.models + } + models := append([]string(nil), m.registryModels...) + if m.existingCfg != nil && m.providerName != "" { + if entry, ok := m.existingCfg.Providers[m.providerName]; ok { + models = mergeModelLists(models, entry.Models) + } + } + return models +} + +func (m modelTUIModel) isUserAddedModel(name string) bool { + if m.isCustomProvider { + // Custom providers have no llm registry; every model comes from config and + // is user-managed. List membership guards confirm against stale names. + return llm.ModelListContains(m.displayModels(), name) + } + return isUserAddedOfficialModelName(name, m.providerName, m.registryModels, m.existingCfg) +} + +func (m modelTUIModel) cursorOnUserAddedModel() bool { + if m.confirmingDeleteModel { + return false + } + models := m.displayModels() + if m.modelIdx >= len(models) { + return false + } + if m.isCustomProvider { + return true + } + return m.isUserAddedModel(models[m.modelIdx]) +} + +func (m *modelTUIModel) adjustModelIdxAfterDelete() { + m.syncModelsFromConfig() + models := m.displayModels() + if m.modelIdx >= len(models) { + if len(models) > 0 { + m.modelIdx = len(models) - 1 + } else { + m.modelIdx = 0 + } + } +} + +// reloadConfigAfterSaveFailure reloads on-disk config so in-memory state matches +// persisted data after a failed save. Returns false when reload could not run. +func (m *modelTUIModel) reloadConfigAfterSaveFailure() bool { + if m.configPath == "" { + return false + } + reloaded, err := loadOrCreateConfig(m.configPath) + if err != nil { + return false + } + m.existingCfg = reloaded + m.syncModelsFromConfig() + return true +} + +func (m *modelTUIModel) syncModelsFromConfig() { + if !m.isCustomProvider || m.existingCfg == nil || m.providerName == "" { + return + } + if entry, ok := m.existingCfg.CustomProviders[m.providerName]; ok { + m.models = append([]string(nil), entry.Models...) + } +} + +func (m *modelTUIModel) refreshModelSelectionAfterAdd(name string) { + models := m.displayModels() + for i, model := range models { + if model == name { + m.modelIdx = i + return + } + } + if len(models) > 0 { + m.modelIdx = len(models) - 1 + } else { + m.modelIdx = 0 + } +} + +// persistAddedModelName appends a model to the provider's Models list in config +// and saves to disk. It does not change the active model. +func (m *modelTUIModel) persistAddedModelName(name string) error { + if name == "" { + return fmt.Errorf("model name must not be empty") + } + if m.existingCfg == nil || m.providerName == "" { + return fmt.Errorf("config not available") + } + if m.isCustomProvider { + if m.existingCfg.CustomProviders == nil { + m.existingCfg.CustomProviders = make(map[string]ProviderEntry) + } + entry := m.existingCfg.CustomProviders[m.providerName] + prevEntry := cloneProviderEntry(entry) + entry.Models = ensureModelInList(entry.Models, name) + m.existingCfg.CustomProviders[m.providerName] = entry + if m.configPath != "" { + if err := saveConfig(m.configPath, m.existingCfg); err != nil { + if !m.reloadConfigAfterSaveFailure() { + m.existingCfg.CustomProviders[m.providerName] = prevEntry + m.models = append([]string(nil), prevEntry.Models...) + } + return fmt.Errorf("failed to save models: %w", err) + } + } + m.models = append([]string(nil), entry.Models...) + m.savedInSession = true + return nil + } + if m.existingCfg.Providers == nil { + m.existingCfg.Providers = make(map[string]ProviderEntry) + } + entry := m.existingCfg.Providers[m.providerName] + prevEntry := cloneProviderEntry(entry) + entry.Models = ensureModelInList(entry.Models, name) + m.existingCfg.Providers[m.providerName] = entry + if m.configPath != "" { + if err := saveConfig(m.configPath, m.existingCfg); err != nil { + if !m.reloadConfigAfterSaveFailure() { + m.existingCfg.Providers[m.providerName] = prevEntry + } + return fmt.Errorf("failed to save models: %w", err) + } + } + m.savedInSession = true + return nil +} + func (m modelTUIModel) Init() tea.Cmd { return nil } func (m modelTUIModel) isCustomItem(idx int) bool { - return idx == len(m.models) + return idx == len(m.displayModels()) } func (m modelTUIModel) itemCount() int { - return len(m.models) + 1 + return len(m.displayModels()) + 1 } func (m modelTUIModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -2034,6 +2672,10 @@ func (m modelTUIModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyPressMsg: key := msg.String() + if m.confirmingDeleteModel { + return m.updateDeleteModelConfirm(key) + } + if m.customModel { switch key { case "esc": @@ -2042,14 +2684,28 @@ func (m modelTUIModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.modelInput.SetValue("") return m, nil case "enter": - if m.modelInput.Value() != "" { - m.confirmed = true - return m, tea.Quit + name := strings.TrimSpace(m.modelInput.Value()) + if name == "" { + return m, nil } + if llm.ModelListContains(m.displayModels(), name) { + m.formError = fmt.Sprintf("Already in list: %s", name) + return m, nil + } + m.formError = "" + if err := m.persistAddedModelName(name); err != nil { + m.formError = err.Error() + return m, nil + } + m.customModel = false + m.modelInput.Blur() + m.modelInput.SetValue("") + m.refreshModelSelectionAfterAdd(name) return m, nil default: var cmd tea.Cmd m.modelInput, cmd = m.modelInput.Update(msg) + m.formError = "" return m, cmd } } @@ -2079,6 +2735,13 @@ func (m modelTUIModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.modelIdx = 0 } return m, nil + case "d": + if m.cursorOnUserAddedModel() { + models := m.displayModels() + m.confirmingDeleteModel = true + m.deleteModelName = models[m.modelIdx] + } + return m, nil } default: @@ -2091,12 +2754,116 @@ func (m modelTUIModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } +func (m *modelTUIModel) updateDeleteModelConfirm(key string) (tea.Model, tea.Cmd) { + switch key { + case "y", "Y": + return m.confirmDeleteModel() + case "n", "N", "esc": + m.confirmingDeleteModel = false + return *m, nil + case "ctrl+c": + m.cancelled = true + return *m, tea.Quit + } + return *m, nil +} + +func (m *modelTUIModel) confirmDeleteModel() (tea.Model, tea.Cmd) { + if m.isCustomProvider { + return m.confirmDeleteCustomProviderModel() + } + return m.confirmDeleteOfficialModel() +} + +func (m *modelTUIModel) confirmDeleteCustomProviderModel() (tea.Model, tea.Cmd) { + if !m.isUserAddedModel(m.deleteModelName) { + m.confirmingDeleteModel = false + return *m, nil + } + if m.existingCfg == nil || m.providerName == "" { + m.confirmingDeleteModel = false + return *m, nil + } + if m.existingCfg.CustomProviders == nil { + m.existingCfg.CustomProviders = make(map[string]ProviderEntry) + } + prevEntry := cloneProviderEntry(m.existingCfg.CustomProviders[m.providerName]) + prevCfgModel := "" + if m.existingCfg.Provider == m.providerName { + prevCfgModel = m.existingCfg.Model + } + entry := applyModelDeleteToEntry(m.existingCfg.CustomProviders[m.providerName], m.deleteModelName) + clearCfgActiveModelIfDeleted(m.existingCfg, m.providerName, m.deleteModelName) + m.existingCfg.CustomProviders[m.providerName] = entry + if m.configPath != "" { + if err := saveConfig(m.configPath, m.existingCfg); err != nil { + if !m.reloadConfigAfterSaveFailure() { + m.rollbackModelDelete(prevEntry, prevCfgModel) + } + m.formError = fmt.Sprintf("failed to save: %v", err) + m.adjustModelIdxAfterDelete() + m.confirmingDeleteModel = false + return *m, nil + } + } + m.adjustModelIdxAfterDelete() + m.resetCustomModelInput() + m.savedInSession = true + m.confirmingDeleteModel = false + return *m, nil +} + +func (m *modelTUIModel) confirmDeleteOfficialModel() (tea.Model, tea.Cmd) { + if !m.isUserAddedModel(m.deleteModelName) { + m.confirmingDeleteModel = false + return *m, nil + } + if m.existingCfg == nil || m.providerName == "" { + m.confirmingDeleteModel = false + return *m, nil + } + if m.existingCfg.Providers == nil { + m.existingCfg.Providers = make(map[string]ProviderEntry) + } + prevEntry := cloneProviderEntry(m.existingCfg.Providers[m.providerName]) + prevCfgModel := "" + if m.existingCfg.Provider == m.providerName { + prevCfgModel = m.existingCfg.Model + } + entry := applyModelDeleteToEntry(m.existingCfg.Providers[m.providerName], m.deleteModelName) + clearCfgActiveModelIfDeleted(m.existingCfg, m.providerName, m.deleteModelName) + m.existingCfg.Providers[m.providerName] = entry + if m.configPath != "" { + if err := saveConfig(m.configPath, m.existingCfg); err != nil { + if !m.reloadConfigAfterSaveFailure() { + m.rollbackModelDelete(prevEntry, prevCfgModel) + } + m.formError = fmt.Sprintf("failed to save: %v", err) + m.adjustModelIdxAfterDelete() + m.confirmingDeleteModel = false + return *m, nil + } + } + m.adjustModelIdxAfterDelete() + m.resetCustomModelInput() + m.savedInSession = true + m.confirmingDeleteModel = false + return *m, nil +} + +func (m *modelTUIModel) resetCustomModelInput() { + m.customModel = false + m.modelInput.SetValue("") + m.modelInput.Blur() +} + func (m modelTUIModel) selectedModel() string { if m.customModel || m.isCustomItem(m.modelIdx) { return m.modelInput.Value() } - if m.modelIdx < len(m.models) { - return m.models[m.modelIdx] + models := m.displayModels() + if m.modelIdx < len(models) { + return models[m.modelIdx] } return "" } @@ -2107,13 +2874,22 @@ func (m modelTUIModel) View() tea.View { s.WriteString(tuiTitleStyle.Render(fmt.Sprintf(" Select a model (%s)", m.provider.DisplayName))) s.WriteString("\n\n") - for i, model := range m.models { + models := m.displayModels() + for i, model := range models { isCursor := i == m.modelIdx - s.WriteString(listCursorPrefix(isCursor) + renderListName(model, isCursor)) + if m.isCustomProvider { + // All models are user-managed; isCursor drives green highlight on selection. + s.WriteString(listCursorPrefixForModel(isCursor, isCursor)) + s.WriteString(renderModelName(model, isCursor, isCursor)) + } else { + userAdded := m.isUserAddedModel(model) + s.WriteString(listCursorPrefixForModel(isCursor, userAdded)) + s.WriteString(renderModelName(model, isCursor, userAdded)) + } s.WriteString("\n") } - customIdx := len(m.models) + customIdx := len(models) isCursor := m.modelIdx == customIdx customLabel := "Enter custom model name..." if isCursor { @@ -2129,8 +2905,23 @@ func (m modelTUIModel) View() tea.View { s.WriteString("\n") } + if m.formError != "" { + s.WriteString("\n") + s.WriteString(tuiErrorStyle.Render(" " + m.formError)) + s.WriteString("\n") + } + s.WriteString("\n") - s.WriteString(tuiHelpStyle.Render(" ↑/↓ Select Enter Confirm Esc Cancel")) + + if m.confirmingDeleteModel { + s.WriteString(" " + tuiSelectedItemStyle.Render(fmt.Sprintf("Delete %q? (y/n)", m.deleteModelName))) + s.WriteString("\n") + s.WriteString(tuiHelpStyle.Render(" y Confirm · n/Esc Cancel")) + } else if m.cursorOnUserAddedModel() { + s.WriteString(tuiHelpStyle.Render(" ↑/↓ Select Enter Confirm d Delete Esc Cancel")) + } else { + s.WriteString(tuiHelpStyle.Render(" ↑/↓ Select Enter Confirm Esc Cancel")) + } s.WriteString("\n") v := tea.NewView(s.String()) diff --git a/cmd/opencodereview/provider_tui_funcs_test.go b/cmd/opencodereview/provider_tui_funcs_test.go index a5f0001..932b1b0 100644 --- a/cmd/opencodereview/provider_tui_funcs_test.go +++ b/cmd/opencodereview/provider_tui_funcs_test.go @@ -1,6 +1,8 @@ package main import ( + "os" + "path/filepath" "strings" "testing" @@ -44,6 +46,73 @@ func TestCustomProviderActiveModel_MatchingProvider(t *testing.T) { } } +func TestOfficialProviderActiveModel_NilCfg(t *testing.T) { + m := providerTUIModel{existingCfg: nil} + got := m.officialProviderActiveModel(llm.Provider{Name: "anthropic"}) + if got != "" { + t.Errorf("expected empty string for nil cfg, got %q", got) + } +} + +func TestOfficialProviderActiveModel_DifferentProvider(t *testing.T) { + cfg := &Config{ + Provider: "deepseek", + Model: "deepseek-v4-flash", + Providers: map[string]ProviderEntry{ + "deepseek": {Model: "deepseek-v4-flash"}, + }, + } + m := newProviderTUI(cfg, "") + got := m.officialProviderActiveModel(llm.Provider{Name: "anthropic", DisplayName: "Anthropic Claude API"}) + if got != "" { + t.Errorf("expected empty string for non-active provider, got %q", got) + } +} + +func TestOfficialProviderActiveModel_MatchingProvider(t *testing.T) { + cfg := &Config{ + Provider: "anthropic", + Model: "claude-opus-4-8", + Providers: map[string]ProviderEntry{ + "anthropic": {Model: "claude-opus-4-8"}, + }, + } + m := newProviderTUI(cfg, "") + got := m.officialProviderActiveModel(llm.Provider{Name: "anthropic", DisplayName: "Anthropic Claude API"}) + if got != "claude-opus-4-8" { + t.Errorf("expected claude-opus-4-8, got %q", got) + } +} + +func TestOfficialProviderActiveModel_EmptyModel(t *testing.T) { + cfg := &Config{ + Provider: "anthropic", + Providers: map[string]ProviderEntry{ + "anthropic": {}, + }, + } + m := newProviderTUI(cfg, "") + got := m.officialProviderActiveModel(llm.Provider{Name: "anthropic"}) + if got != "" { + t.Errorf("expected empty model, got %q", got) + } +} + +func TestProviderTUIView_OfficialTab_ShowsActiveModelSuffix(t *testing.T) { + cfg := &Config{ + Provider: "anthropic", + Model: "claude-opus-4-8", + Providers: map[string]ProviderEntry{ + "anthropic": {Model: "claude-opus-4-8"}, + }, + } + m := newProviderTUI(cfg, "") + got := stripANSI(m.View().Content) + if !strings.Contains(got, "(claude-opus-4-8)") { + t.Errorf("view missing active model suffix; got:\n%s", got) + } +} + func TestModelProviderName_OfficialTab(t *testing.T) { m := newProviderTUI(&Config{}, "") name := m.modelProviderName() @@ -547,6 +616,12 @@ func TestNewModelTUI(t *testing.T) { t.Skip("no providers") } m := newModelTUI(p[0], "") + if m.isCustomProvider { + t.Error("preset provider test helper should set isCustomProvider=false") + } + if len(m.registryModels) == 0 { + t.Error("registryModels should be populated for preset provider") + } if m.modelIdx != 0 { t.Errorf("modelIdx = %d, want 0 for empty currentModel", m.modelIdx) } @@ -861,6 +936,447 @@ func TestModelTUI_View_CustomModel(t *testing.T) { } } +func officialConfigModelTUI(t *testing.T, configPath string, extraModels []string) modelTUIModel { + t.Helper() + preset, ok := llm.LookupProvider("dashscope") + if !ok { + t.Skip("dashscope provider not in registry") + } + models := []string{"qwen3.7-max"} + models = append(models, extraModels...) + cfg := &Config{ + Provider: "dashscope", + Model: "qwen3.7-max", + Providers: map[string]ProviderEntry{ + "dashscope": { + Model: "qwen3.7-max", + Models: models, + }, + }, + } + provider := preset + provider.Models = mergeModelLists(preset.Models, cfg.Providers["dashscope"].Models) + return newModelTUIConfig(modelTUIConfig{ + Provider: provider, + RegistryModels: preset.Models, + ExistingCfg: cfg, + ConfigPath: configPath, + ProviderName: "dashscope", + IsCustom: false, + }) +} + +func customConfigModelTUI(t *testing.T, configPath string, models []string) modelTUIModel { + t.Helper() + cfg := &Config{ + Provider: "my-llm", + Model: "m1", + CustomProviders: map[string]ProviderEntry{ + "my-llm": { + URL: "https://custom.api/v1", + Protocol: "openai", + Model: "m1", + Models: append([]string(nil), models...), + }, + }, + } + provider := llm.Provider{ + Name: "my-llm", + DisplayName: "my-llm (custom)", + Models: mergeModelLists(models), + } + return newModelTUIConfig(modelTUIConfig{ + Provider: provider, + CurrentModel: "m1", + ExistingCfg: cfg, + ConfigPath: configPath, + ProviderName: "my-llm", + IsCustom: true, + }) +} + +func asModelTUIModel(t *testing.T, m tea.Model) modelTUIModel { + t.Helper() + switch v := m.(type) { + case modelTUIModel: + return v + case *modelTUIModel: + return *v + default: + t.Fatalf("expected modelTUIModel, got %T", m) + return modelTUIModel{} + } +} + +func modelTUIEnterCustomModelName(t *testing.T, m modelTUIModel, name string) modelTUIModel { + t.Helper() + m.modelIdx = len(m.displayModels()) + result, _ := m.Update(enterKey()) + m2 := result.(modelTUIModel) + if !m2.customModel { + t.Fatal("expected customModel after enter on custom item") + } + m2.modelInput.SetValue(name) + result, _ = m2.Update(enterKey()) + return result.(modelTUIModel) +} + +func modelTUIIdxForName(t *testing.T, m modelTUIModel, name string) int { + t.Helper() + for i, model := range m.displayModels() { + if model == name { + return i + } + } + t.Fatalf("model %q not found in %v", name, m.displayModels()) + return -1 +} + +func TestModelTUI_Official_AddCustomModelStaysOnList(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + m := officialConfigModelTUI(t, configPath, nil) + m3 := modelTUIEnterCustomModelName(t, m, "new-model") + + if m3.confirmed { + t.Error("adding a model should not confirm and quit") + } + if m3.customModel { + t.Error("customModel should be cleared after add") + } + got := m3.existingCfg.Providers["dashscope"].Models + if !llm.ModelListContains(got, "new-model") { + t.Errorf("Models = %v, want new-model appended", got) + } + if m3.existingCfg.Model != "qwen3.7-max" { + t.Errorf("cfg.Model = %q, want active model unchanged", m3.existingCfg.Model) + } + diskCfg, err := loadOrCreateConfig(configPath) + if err != nil { + t.Fatalf("load disk config: %v", err) + } + if !llm.ModelListContains(diskCfg.Providers["dashscope"].Models, "new-model") { + t.Errorf("disk Models = %v, want new-model persisted", diskCfg.Providers["dashscope"].Models) + } +} + +func TestModelTUI_Official_ListEnterConfirmsSelection(t *testing.T) { + m := officialConfigModelTUI(t, "", nil) + m2 := modelTUIEnterCustomModelName(t, m, "picked-model") + m2.modelIdx = modelTUIIdxForName(t, m2, "picked-model") + result, _ := m2.Update(enterKey()) + m3 := result.(modelTUIModel) + if !m3.confirmed { + t.Error("enter on list item should confirm selection") + } + if m3.selectedModel() != "picked-model" { + t.Errorf("selectedModel() = %q, want picked-model", m3.selectedModel()) + } +} + +func TestModelTUI_CustomProvider_AddCustomModelStaysOnList(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + m := customConfigModelTUI(t, configPath, []string{"m1"}) + m3 := modelTUIEnterCustomModelName(t, m, "new-custom-model") + + if m3.confirmed { + t.Error("adding a model should not confirm and quit") + } + if !llm.ModelListContains(m3.existingCfg.CustomProviders["my-llm"].Models, "new-custom-model") { + t.Errorf("Models = %v, want new-custom-model appended", m3.existingCfg.CustomProviders["my-llm"].Models) + } + if m3.existingCfg.CustomProviders["my-llm"].Model != "m1" { + t.Errorf("entry.Model = %q, want active model unchanged", m3.existingCfg.CustomProviders["my-llm"].Model) + } + if !m3.savedInSession { + t.Error("savedInSession should be true after add") + } +} + +func TestModelTUI_EscCancelWithoutChangesNoSavedInSession(t *testing.T) { + m := officialConfigModelTUI(t, "", nil) + result, _ := m.Update(escKey()) + m2 := result.(modelTUIModel) + if !m2.cancelled { + t.Fatal("esc should cancel") + } + if m2.savedInSession { + t.Error("savedInSession should be false when no add/delete occurred") + } +} + +func TestModelTUI_DeleteSetsSavedInSession(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + m := customConfigModelTUI(t, configPath, []string{"m1", "aaa"}) + m.modelIdx = modelTUIIdxForName(t, m, "aaa") + m.deleteModelName = "aaa" + m.confirmingDeleteModel = true + + result, _ := m.confirmDeleteCustomProviderModel() + m2 := asModelTUIModel(t, result) + if !m2.savedInSession { + t.Error("savedInSession should be true after delete") + } +} + +func TestModelTUI_AddCustomModelRejectsDuplicate(t *testing.T) { + m := officialConfigModelTUI(t, "", []string{"dup-model"}) + m.modelIdx = len(m.displayModels()) + result, _ := m.Update(enterKey()) + m2 := result.(modelTUIModel) + m2.modelInput.SetValue("dup-model") + result, _ = m2.Update(enterKey()) + m3 := result.(modelTUIModel) + if m3.formError != "Already in list: dup-model" { + t.Errorf("formError = %q, want duplicate message", m3.formError) + } + if !m3.customModel { + t.Error("customModel should stay open after duplicate reject") + } +} + +func TestModelTUI_Official_DeleteUserAddedModel(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + m := officialConfigModelTUI(t, configPath, []string{"my-custom-model"}) + m.modelIdx = modelTUIIdxForName(t, m, "my-custom-model") + + result, _ := m.Update(dKey()) + m2 := result.(modelTUIModel) + if !m2.confirmingDeleteModel { + t.Fatal("pressing d on user-added model should set confirmingDeleteModel = true") + } + + result, _ = m2.Update(yKey()) + m3 := result.(modelTUIModel) + got := m3.existingCfg.Providers["dashscope"].Models + if len(got) != 1 || got[0] != "qwen3.7-max" { + t.Errorf("Models = %v, want [qwen3.7-max]", got) + } + + diskCfg, err := loadOrCreateConfig(configPath) + if err != nil { + t.Fatalf("load disk config: %v", err) + } + if len(diskCfg.Providers["dashscope"].Models) != 1 { + t.Errorf("disk Models = %v, want [qwen3.7-max]", diskCfg.Providers["dashscope"].Models) + } +} + +func TestModelTUI_Official_DeleteBuiltInModelIgnored(t *testing.T) { + m := officialConfigModelTUI(t, "", []string{"my-custom-model"}) + m.modelIdx = modelTUIIdxForName(t, m, "qwen3.7-max") + + result, _ := m.Update(dKey()) + m2 := result.(modelTUIModel) + if m2.confirmingDeleteModel { + t.Error("pressing d on built-in model should not trigger delete confirmation") + } +} + +func TestIsUserAddedOfficialModelName_RegistryDuplicateNotUserAdded(t *testing.T) { + preset, ok := llm.LookupProvider("dashscope") + if !ok { + t.Skip("dashscope provider not in registry") + } + cfg := &Config{ + Providers: map[string]ProviderEntry{ + "dashscope": {Models: []string{"qwen3.7-max", "my-custom-model"}}, + }, + } + if isUserAddedOfficialModelName("qwen3.7-max", "dashscope", preset.Models, cfg) { + t.Error("registry model should not be treated as user-added even when listed in config") + } + if !isUserAddedOfficialModelName("my-custom-model", "dashscope", preset.Models, cfg) { + t.Error("config-only model should be user-added") + } +} + +func TestModelTUI_Official_RegistryModelNotDeletable(t *testing.T) { + m := officialConfigModelTUI(t, "", []string{"my-custom-model"}) + m.modelIdx = modelTUIIdxForName(t, m, "qwen3.7-max") + + if m.isUserAddedModel("qwen3.7-max") { + t.Error("qwen3.7-max should not be user-added when it is in the registry") + } + + result, _ := m.Update(dKey()) + m2 := result.(modelTUIModel) + if m2.confirmingDeleteModel { + t.Error("pressing d on registry model should not trigger delete confirmation") + } +} + +func TestModelTUI_Official_DeleteOnCustomModelInputIgnored(t *testing.T) { + m := officialConfigModelTUI(t, "", []string{"my-custom-model"}) + m.modelIdx = len(m.displayModels()) + + result, _ := m.Update(dKey()) + m2 := result.(modelTUIModel) + if m2.confirmingDeleteModel { + t.Error("pressing d on Enter custom model name... should not trigger delete confirmation") + } +} + +func TestModelTUI_CustomProvider_DeleteOnCustomModelInputIgnored(t *testing.T) { + m := customConfigModelTUI(t, "", []string{"m1", "aaa"}) + m.modelIdx = len(m.displayModels()) + + result, _ := m.Update(dKey()) + m2 := result.(modelTUIModel) + if m2.confirmingDeleteModel { + t.Error("pressing d on Enter custom model name... should not trigger delete confirmation") + } +} + +func TestModelTUI_Official_UserAddedModelShowsDeleteHint(t *testing.T) { + m := officialConfigModelTUI(t, "", []string{"my-custom-model"}) + + m.modelIdx = modelTUIIdxForName(t, m, "qwen3.7-max") + got := stripANSI(m.View().Content) + if strings.Contains(got, "d Delete") { + t.Errorf("built-in model should not show d Delete hint; got:\n%s", got) + } + + m.modelIdx = modelTUIIdxForName(t, m, "my-custom-model") + got = stripANSI(m.View().Content) + if !strings.Contains(got, "d Delete") { + t.Errorf("user-added model should show d Delete hint; got:\n%s", got) + } +} + +func TestModelTUI_CustomProvider_ShowsDeleteHint(t *testing.T) { + m := customConfigModelTUI(t, "", []string{"m1", "aaa"}) + + m.modelIdx = len(m.displayModels()) + got := stripANSI(m.View().Content) + if strings.Contains(got, "d Delete") { + t.Errorf("custom input row should not show d Delete hint; got:\n%s", got) + } + + m.modelIdx = modelTUIIdxForName(t, m, "aaa") + got = stripANSI(m.View().Content) + if !strings.Contains(got, "d Delete") { + t.Errorf("custom model row should show d Delete hint; got:\n%s", got) + } +} + +func TestModelTUI_CustomProvider_DeleteClearsCustomInput(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + m := customConfigModelTUI(t, configPath, []string{"m1", "aaa"}) + m.modelIdx = modelTUIIdxForName(t, m, "aaa") + m.customModel = true + m.modelInput.SetValue("aaa") + m.deleteModelName = "aaa" + m.confirmingDeleteModel = true + + result, _ := m.confirmDeleteCustomProviderModel() + m2 := asModelTUIModel(t, result) + + if m2.customModel { + t.Error("customModel should be false after delete") + } + if m2.modelInput.Value() != "" { + t.Errorf("modelInput = %q, want empty after delete", m2.modelInput.Value()) + } +} + +func TestModelTUI_CustomProvider_DeleteModelViaDKey(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + m := customConfigModelTUI(t, configPath, []string{"m1", "aaa"}) + m.modelIdx = modelTUIIdxForName(t, m, "aaa") + + result, _ := m.Update(dKey()) + m2 := result.(modelTUIModel) + if !m2.confirmingDeleteModel || m2.deleteModelName != "aaa" { + t.Fatalf("after d: confirming=%v deleteModelName=%q", m2.confirmingDeleteModel, m2.deleteModelName) + } + + result, _ = m2.Update(yKey()) + m3 := result.(modelTUIModel) + got := m3.existingCfg.CustomProviders["my-llm"].Models + if len(got) != 1 || got[0] != "m1" { + t.Errorf("Models = %v, want [m1]", got) + } +} + +func TestModelTUI_CustomProvider_DeleteCancel(t *testing.T) { + m := customConfigModelTUI(t, "", []string{"m1", "aaa"}) + m.modelIdx = modelTUIIdxForName(t, m, "aaa") + + result, _ := m.Update(dKey()) + m2 := result.(modelTUIModel) + result, _ = m2.Update(nKey()) + m3 := result.(modelTUIModel) + if m3.confirmingDeleteModel { + t.Error("confirmingDeleteModel should be false after n") + } + got := m3.existingCfg.CustomProviders["my-llm"].Models + if len(got) != 2 { + t.Errorf("Models = %v, want unchanged", got) + } +} + +func TestModelTUI_Official_DeleteCancel(t *testing.T) { + cancelKeys := []struct { + name string + key tea.KeyPressMsg + }{ + {"n", nKey()}, + {"esc", escKey()}, + } + for _, tc := range cancelKeys { + t.Run(tc.name, func(t *testing.T) { + m := officialConfigModelTUI(t, "", []string{"my-custom-model"}) + m.modelIdx = modelTUIIdxForName(t, m, "my-custom-model") + + result, _ := m.Update(dKey()) + m2 := result.(modelTUIModel) + if !m2.confirmingDeleteModel { + t.Fatal("expected confirmingDeleteModel after d") + } + + result, _ = m2.Update(tc.key) + m3 := result.(modelTUIModel) + if m3.confirmingDeleteModel { + t.Error("confirmingDeleteModel should be false after cancel") + } + got := m3.existingCfg.Providers["dashscope"].Models + if len(got) != 2 || got[1] != "my-custom-model" { + t.Errorf("Models = %v, want model unchanged", got) + } + }) + } +} + +func TestModelTUI_Official_DeleteActiveUserModelClearsCfg(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + m := officialConfigModelTUI(t, configPath, []string{"my-custom-model"}) + m.existingCfg.Model = "my-custom-model" + m.existingCfg.Providers["dashscope"] = ProviderEntry{ + Model: "my-custom-model", + Models: []string{"qwen3.7-max", "my-custom-model"}, + } + m.modelIdx = modelTUIIdxForName(t, m, "my-custom-model") + + result, _ := m.Update(dKey()) + m2 := result.(modelTUIModel) + result, _ = m2.Update(yKey()) + m3 := result.(modelTUIModel) + + if m3.existingCfg.Providers["dashscope"].Model != "" { + t.Errorf("entry.Model = %q, want empty", m3.existingCfg.Providers["dashscope"].Model) + } + if m3.existingCfg.Model != "" { + t.Errorf("cfg.Model = %q, want empty", m3.existingCfg.Model) + } +} + // --- result() tests --- func TestResult_OfficialTab(t *testing.T) { @@ -1077,6 +1593,269 @@ func TestSyncSessionModelSelection_EmptyModel(t *testing.T) { } } +func TestSyncSessionModelSelection_CrossOfficialProviderNoPersist(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + cfg := &Config{ + Provider: "deepseek", + Model: "deepseek-v4-flash", + Providers: map[string]ProviderEntry{ + "deepseek": {Model: "deepseek-v4-flash"}, + }, + } + m := newProviderTUI(cfg, configPath) + m.activeTab = tabOfficial + for i, p := range m.providers { + if p.Name == "baidu-qianfan" { + m.officialIdx = i + break + } + } + m.modelIdx = 0 + for i, name := range m.models() { + if name == "glm-5" { + m.modelIdx = i + break + } + } + + if err := m.syncSessionModelSelection(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if m.savedInSession { + t.Error("savedInSession should be false when browsing a non-active provider") + } + if _, err := os.Stat(configPath); err == nil { + t.Fatal("config file should not be written for cross-provider navigation") + } + if got := cfg.Providers["baidu-qianfan"].Model; got != "" { + t.Errorf("in-memory baidu model = %q, want empty", got) + } +} + +func TestSyncSessionModelSelection_ActiveOfficialProviderDefersPersist(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + cfg := &Config{ + Provider: "deepseek", + Model: "deepseek-v4-flash", + Providers: map[string]ProviderEntry{ + "deepseek": {Model: "deepseek-v4-flash"}, + }, + } + m := newProviderTUI(cfg, configPath) + m.activeTab = tabOfficial + for i, name := range m.models() { + if name == "deepseek-v4-pro" { + m.modelIdx = i + break + } + } + + if err := m.syncSessionModelSelection(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if m.savedInSession { + t.Error("savedInSession should be false before wizard confirm") + } + if got := m.sessionModelPick["deepseek"]; got != "deepseek-v4-pro" { + t.Errorf("sessionModelPick = %q, want deepseek-v4-pro", got) + } + if _, err := os.Stat(configPath); err == nil { + t.Fatal("config file should not be written before wizard confirm") + } + if cfg.Model != "deepseek-v4-flash" { + t.Errorf("cfg.Model = %q, want deepseek-v4-flash", cfg.Model) + } +} + +func TestSyncSessionModelSelection_CrossCustomProviderNoPersist(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + cfg := &Config{ + Provider: "stepfun", + Model: "step-3.5-flash", + CustomProviders: map[string]ProviderEntry{ + "stepfun": { + URL: "https://api.stepfun.com/v1", + Model: "step-3.5-flash", + Models: []string{"step-3.5-flash", "step-3.7-flash"}, + }, + "other": { + URL: "https://example.com/v1", + Model: "step-3.7-flash", + Models: []string{"step-3.7-flash"}, + }, + }, + } + m := newProviderTUI(cfg, configPath) + m.activeTab = tabCustom + for i, cp := range m.customProviders { + if cp.name == "other" { + m.customIdx = i + break + } + } + m.modelIdx = 0 + + if err := m.syncSessionModelSelection(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if m.savedInSession { + t.Error("savedInSession should be false when browsing a non-active custom provider") + } + if _, err := os.Stat(configPath); err == nil { + t.Fatal("config file should not be written for cross-provider navigation") + } +} + +func TestSyncSessionModelSelection_RecordsSessionPickForInactiveOfficialProvider(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + cfg := &Config{ + Provider: "deepseek", + Model: "deepseek-v4-flash", + Providers: map[string]ProviderEntry{ + "deepseek": {Model: "deepseek-v4-flash"}, + }, + } + m := newProviderTUI(cfg, configPath) + m.activeTab = tabOfficial + for i, p := range m.providers { + if p.Name == "baidu-qianfan" { + m.officialIdx = i + break + } + } + for i, name := range m.models() { + if name == "glm-5" { + m.modelIdx = i + break + } + } + + if err := m.syncSessionModelSelection(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if m.savedInSession { + t.Error("savedInSession should be false for inactive provider") + } + if got := m.sessionModelPick["baidu-qianfan"]; got != "glm-5" { + t.Errorf("sessionModelPick = %q, want glm-5", got) + } + if _, err := os.Stat(configPath); err == nil { + t.Fatal("config file should not be written for inactive provider") + } +} + +func TestProviderTUI_ResultUsesSessionModelPickWhenSelectionEmpty(t *testing.T) { + cfg := &Config{ + Provider: "deepseek", + Model: "deepseek-v4-flash", + Providers: map[string]ProviderEntry{ + "deepseek": {Model: "deepseek-v4-flash"}, + }, + } + m := newProviderTUI(cfg, "") + m.activeTab = tabOfficial + for i, p := range m.providers { + if p.Name == "baidu-qianfan" { + m.officialIdx = i + break + } + } + m.sessionModelPick = map[string]string{"baidu-qianfan": "glm-5"} + m.modelIdx = 9999 // force selectedModelFromState() empty + + r := m.result() + if r.model != "glm-5" { + t.Errorf("result().model = %q, want glm-5", r.model) + } + if got := r.resolvedModel(); got != "glm-5" { + t.Errorf("resolvedModel() = %q, want glm-5", got) + } +} + +func TestApiKeyStepCanConfirm_OfficialEmptyWithoutEnv(t *testing.T) { + t.Setenv("DEEPSEEK_API_KEY", "") + cfg := &Config{ + Provider: "deepseek", + Model: "deepseek-v4-flash", + Providers: map[string]ProviderEntry{ + "deepseek": {Model: "deepseek-v4-flash"}, + }, + } + m := newProviderTUI(cfg, "") + m.activeTab = tabOfficial + m.step = stepAPIKey + + ok, errMsg := m.apiKeyStepCanConfirm() + if ok { + t.Fatal("expected confirmation to be blocked") + } + if errMsg != "API key is required (or set $DEEPSEEK_API_KEY)" { + t.Errorf("errMsg = %q", errMsg) + } +} + +func TestApiKeyStepCanConfirm_OfficialEmptyWithEnv(t *testing.T) { + t.Setenv("DEEPSEEK_API_KEY", "sk-from-env") + cfg := &Config{ + Provider: "deepseek", + Model: "deepseek-v4-flash", + Providers: map[string]ProviderEntry{ + "deepseek": {Model: "deepseek-v4-flash"}, + }, + } + m := newProviderTUI(cfg, "") + m.activeTab = tabOfficial + m.step = stepAPIKey + + ok, errMsg := m.apiKeyStepCanConfirm() + if !ok { + t.Fatalf("expected confirmation allowed, errMsg = %q", errMsg) + } +} + +func TestApiKeyStepCanConfirm_CustomEmpty(t *testing.T) { + cfg := &Config{ + Provider: "stepfun", + CustomProviders: map[string]ProviderEntry{ + "stepfun": {APIKey: ""}, + }, + } + m := newProviderTUI(cfg, "") + m.activeTab = tabCustom + m.customIdx = 0 + m.step = stepAPIKey + + ok, errMsg := m.apiKeyStepCanConfirm() + if ok { + t.Fatal("expected confirmation to be blocked") + } + if errMsg != "API key is required" { + t.Errorf("errMsg = %q", errMsg) + } +} + +func TestApiKeyStepCanConfirm_MaskedSavedKey(t *testing.T) { + cfg := &Config{ + Provider: "deepseek", + Providers: map[string]ProviderEntry{ + "deepseek": {APIKey: "keep-me"}, + }, + } + m := newProviderTUI(cfg, "") + m.activeTab = tabOfficial + m.step = stepAPIKey + m.loadExistingAPIKey() + + ok, errMsg := m.apiKeyStepCanConfirm() + if !ok { + t.Fatalf("expected confirmation allowed, errMsg = %q", errMsg) + } +} + // --- viewCustomProviderForm field steps --- func TestProviderTUIView_CustomForm_AllSteps(t *testing.T) { @@ -1146,15 +1925,23 @@ func TestProviderTUIView_ManualTab_WithExistingConfig(t *testing.T) { func TestProviderTUIView_StepModel_CustomTabDeleteHelp(t *testing.T) { cfg := &Config{ CustomProviders: map[string]ProviderEntry{ - "cp": {URL: "http://cp", Models: []string{"m1"}}, + "cp": {URL: "http://cp", Models: []string{"m1", "m2"}}, }, } m := newProviderTUI(cfg, "") m.step = stepModel m.activeTab = tabCustom m.customIdx = 0 - v := m.View() - if !strings.Contains(v.Content, "Delete") { - t.Errorf("expected delete help for custom tab model view") + + m.modelIdx = len(m.models()) + got := stripANSI(m.View().Content) + if strings.Contains(got, "d Delete") { + t.Errorf("custom input row should not show d Delete hint; got:\n%s", got) + } + + m.modelIdx = 0 + got = stripANSI(m.View().Content) + if !strings.Contains(got, "d Delete") { + t.Errorf("custom model row should show d Delete hint; got:\n%s", got) } } diff --git a/cmd/opencodereview/provider_tui_test.go b/cmd/opencodereview/provider_tui_test.go index 42e9d41..624bc50 100644 --- a/cmd/opencodereview/provider_tui_test.go +++ b/cmd/opencodereview/provider_tui_test.go @@ -8,6 +8,8 @@ import ( "testing" tea "charm.land/bubbletea/v2" + + "github.com/open-code-review/open-code-review/internal/llm" ) func escKey() tea.KeyPressMsg { @@ -443,11 +445,76 @@ func TestProviderTUI_ManualFormRequiresTokenOnFirstSetup(t *testing.T) { m.manualTokenInput.SetValue("") m.manualTokenInput.Focus() - result, _ := m.Update(enterKey()) + result, cmd := m.Update(enterKey()) m2 := result.(providerTUIModel) if m2.manualStep != manualStepAuthToken { t.Errorf("should stay on auth token step, got %d", m2.manualStep) } + if m2.formError != manualAuthTokenRequiredError { + t.Errorf("formError = %q, want %q", m2.formError, manualAuthTokenRequiredError) + } + if cmd != nil { + t.Error("Enter with empty token should not quit") + } +} + +func TestProviderTUI_ManualFormRejectsWhitespaceOnlyToken(t *testing.T) { + m := newProviderTUI(&Config{}, "") + m.inManualForm = true + m.manualStep = manualStepAuthToken + m.manualTokenInput.SetValue(" ") + m.manualTokenInput.Focus() + + result, cmd := m.Update(enterKey()) + m2 := result.(providerTUIModel) + if m2.manualStep != manualStepAuthToken { + t.Errorf("should stay on auth token step, got %d", m2.manualStep) + } + if m2.formError != manualAuthTokenRequiredError { + t.Errorf("formError = %q, want %q", m2.formError, manualAuthTokenRequiredError) + } + if cmd != nil { + t.Error("Enter with whitespace-only token should not quit") + } +} + +func TestProviderTUI_SessionModelPickSurvivesOfficialProviderSwitch(t *testing.T) { + cfg := &Config{ + Provider: "deepseek", + Model: "deepseek-v4-flash", + Providers: map[string]ProviderEntry{ + "deepseek": {Model: "deepseek-v4-flash"}, + }, + } + m := newProviderTUI(cfg, "") + m.activeTab = tabOfficial + for i, p := range m.providers { + if p.Name == "baidu-qianfan" { + m.officialIdx = i + break + } + } + + result, _ := m.Update(enterKey()) + m2 := result.(providerTUIModel) + m2.modelIdx = modelIdxForName(t, m2, "glm-5") + + result, _ = m2.Update(enterKey()) + m3 := result.(providerTUIModel) + if got := m3.sessionModelPick["baidu-qianfan"]; got != "glm-5" { + t.Errorf("sessionModelPick = %q, want glm-5", got) + } + + result, _ = m3.Update(escKey()) + m4 := result.(providerTUIModel) + result, _ = m4.Update(escKey()) + m5 := result.(providerTUIModel) + + result, _ = m5.Update(enterKey()) + m6 := result.(providerTUIModel) + if got := m6.models()[m6.modelIdx]; got != "glm-5" { + t.Errorf("model cursor = %q, want glm-5", got) + } } // --- Custom tab tests --- @@ -681,6 +748,180 @@ func TestProviderTUI_EditCustomProviderSaveRejectsDuplicateRename(t *testing.T) } } +func TestApplyEditCustomProviderSave_ClearsAPIKeyWhenEditedEmpty(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + cfg := &Config{ + CustomProviders: map[string]ProviderEntry{ + "aaa": { + URL: "https://example.com/v1", + Protocol: "anthropic", + APIKey: "old-saved-key", + Model: "test", + Models: []string{"test"}, + }, + }, + } + m := newProviderTUI(cfg, configPath) + m.activeTab = tabCustom + m.editingCustom = true + m.editTargetName = "aaa" + m.cpProtocolIdx = 0 + m.cpNameInput.SetValue("aaa") + m.cpURLInput.SetValue("https://example.com/v1") + m.beginAPIKeyReplace() + + if err := m.applyEditCustomProviderSave(); err != nil { + t.Fatalf("applyEditCustomProviderSave: %v", err) + } + if got := cfg.CustomProviders["aaa"].APIKey; got != "" { + t.Errorf("APIKey = %q, want empty", got) + } + diskCfg, err := loadOrCreateConfig(configPath) + if err != nil { + t.Fatalf("load config: %v", err) + } + if got := diskCfg.CustomProviders["aaa"].APIKey; got != "" { + t.Errorf("persisted APIKey = %q, want empty", got) + } +} + +func TestApplyEditCustomProviderSave_PreservesAPIKeyWhenMasked(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + cfg := &Config{ + CustomProviders: map[string]ProviderEntry{ + "aaa": { + URL: "https://example.com/v1", + Protocol: "anthropic", + APIKey: "keep-me", + }, + }, + } + m := newProviderTUI(cfg, configPath) + m.activeTab = tabCustom + m.customIdx = 0 + m.enterEditCustomProvider() + + if err := m.applyEditCustomProviderSave(); err != nil { + t.Fatalf("applyEditCustomProviderSave: %v", err) + } + if got := cfg.CustomProviders["aaa"].APIKey; got != "keep-me" { + t.Errorf("APIKey = %q, want keep-me", got) + } +} + +func TestProviderTUI_EditCustomClearKey_NoMaskedOnStepAPIKey(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + cfg := &Config{ + CustomProviders: map[string]ProviderEntry{ + "aaa": { + URL: "https://example.com/v1", + Protocol: "anthropic", + APIKey: "old-saved-key", + Model: "test", + Models: []string{"test", "aaa"}, + }, + }, + } + m := newProviderTUI(cfg, configPath) + m.activeTab = tabCustom + for i, cp := range m.customProviders { + if cp.name == "aaa" { + m.customIdx = i + break + } + } + m.enterEditCustomProvider() + m.cpStep = cpStepAPIKey + m.beginAPIKeyReplace() + m.cpStep = cpStepAuthHeader + m.cpAuthInput.SetValue("") + m.cpAuthInput.Focus() + + result, _ := m.Update(enterKey()) + m2 := result.(providerTUIModel) + if m2.step != stepModel { + t.Fatalf("step = %d, want stepModel", m2.step) + } + if got := m2.customProviders[m2.customIdx].entry.APIKey; got != "" { + t.Errorf("saved APIKey = %q, want empty", got) + } + + m2.modelIdx = modelIdxForName(t, m2, "test") + result, _ = m2.Update(enterKey()) + m3 := result.(providerTUIModel) + if m3.step != stepAPIKey { + t.Fatalf("step = %d, want stepAPIKey", m3.step) + } + if m3.apiKeyMasked { + t.Error("apiKeyMasked should be false after clearing key in edit") + } + got := stripANSI(m3.View().Content) + if strings.Contains(got, "Type or paste to replace the saved key") { + t.Errorf("view should not show replace hint; got:\n%s", got) + } +} + +func TestProviderTUI_ReenterEditAfterClearKey_ShowsEmpty(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + cfg := &Config{ + CustomProviders: map[string]ProviderEntry{ + "aaa": { + URL: "https://example.com/v1", + Protocol: "anthropic", + APIKey: "old-saved-key", + }, + }, + } + m := newProviderTUI(cfg, configPath) + m.activeTab = tabCustom + m.customIdx = 0 + m.editingCustom = true + m.editTargetName = "aaa" + m.cpProtocolIdx = 0 + m.cpNameInput.SetValue("aaa") + m.cpURLInput.SetValue("https://example.com/v1") + m.beginAPIKeyReplace() + if err := m.applyEditCustomProviderSave(); err != nil { + t.Fatalf("applyEditCustomProviderSave: %v", err) + } + + m.enterEditCustomProvider() + if m.apiKeyMasked { + t.Error("apiKeyMasked should be false when key was cleared") + } + if got := m.apiKeyInput.Value(); got != "" { + t.Errorf("apiKeyInput = %q, want empty", got) + } +} + +func TestCustomAPIKeyForSave(t *testing.T) { + m := providerTUIModel{ + apiKeyMasked: true, + apiKeyOriginal: "keep-me", + } + key, edited := m.customAPIKeyForSave() + if edited { + t.Fatal("masked key should not count as edited") + } + if key != "keep-me" { + t.Errorf("key = %q, want keep-me", key) + } + + m.apiKeyMasked = false + m.apiKeyInput.SetValue(" ") + key, edited = m.customAPIKeyForSave() + if !edited { + t.Fatal("cleared field should count as edited") + } + if key != "" { + t.Errorf("key = %q, want empty", key) + } +} + func TestProviderTUI_CustomFormCreateReturnsToModelList(t *testing.T) { dir := t.TempDir() configPath := filepath.Join(dir, "config.json") @@ -1112,6 +1353,478 @@ func TestProviderTUI_CustomModelInput_RejectsDuplicate(t *testing.T) { } } +func TestProviderTUI_OfficialTab_CustomModelInput_PersistsName(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + cfg := &Config{ + Provider: "dashscope", + Model: "qwen3.7-max", + Providers: map[string]ProviderEntry{ + "dashscope": { + Model: "qwen3.7-max", + Models: []string{"qwen3.7-max"}, + }, + }, + } + m := newProviderTUI(cfg, configPath) + m.activeTab = tabOfficial + + // Land the cursor on the official provider we configured. + for i, p := range m.providers { + if p.Name == "dashscope" { + m.officialIdx = i + break + } + } + m.step = stepModel + m.modelIdx = len(m.models()) // "Enter custom model name..." + m.customModel = true + m.modelInput.SetValue("my-custom-model") + m.modelInput.Focus() + + result, _ := m.Update(enterKey()) + m2 := result.(providerTUIModel) + + if m2.customModel { + t.Error("customModel should be cleared after Enter") + } + if m2.formError != "" { + t.Errorf("formError = %q, want empty", m2.formError) + } + if !m2.savedInSession { + t.Error("savedInSession should be true after successful add") + } + got := m2.existingCfg.Providers["dashscope"].Models + want := []string{"qwen3.7-max", "my-custom-model"} + if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] { + t.Errorf("official Models = %v, want %v", got, want) + } + + diskCfg, err := loadOrCreateConfig(configPath) + if err != nil { + t.Fatalf("load disk config: %v", err) + } + diskModels := diskCfg.Providers["dashscope"].Models + if len(diskModels) != 2 || diskModels[1] != "my-custom-model" { + t.Errorf("disk Models = %v, want [qwen3.7-max my-custom-model]", diskModels) + } +} + +func TestProviderTUI_OfficialTab_CustomModelInput_RejectsDuplicate(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + cfg := &Config{ + Provider: "dashscope", + Model: "qwen3.7-max", + Providers: map[string]ProviderEntry{ + "dashscope": { + Model: "qwen3.7-max", + Models: []string{"qwen3.7-max"}, + }, + }, + } + m := newProviderTUI(cfg, configPath) + m.activeTab = tabOfficial + for i, p := range m.providers { + if p.Name == "dashscope" { + m.officialIdx = i + break + } + } + m.step = stepModel + m.modelIdx = len(m.models()) + m.customModel = true + m.modelInput.SetValue("qwen3.7-max") + m.modelInput.Focus() + + result, _ := m.Update(enterKey()) + m2 := result.(providerTUIModel) + + if !m2.customModel { + t.Error("customModel should stay true after duplicate reject") + } + if m2.formError != "Already in list: qwen3.7-max" { + t.Errorf("formError = %q, want %q", m2.formError, "Already in list: qwen3.7-max") + } + if _, err := os.Stat(configPath); err == nil { + t.Errorf("disk file should not exist; duplicate did not persist") + } +} + +func officialDashscopeModelTUI(t *testing.T, configPath string, extraModels []string) providerTUIModel { + t.Helper() + models := []string{"qwen3.7-max"} + models = append(models, extraModels...) + cfg := &Config{ + Provider: "dashscope", + Model: "qwen3.7-max", + Providers: map[string]ProviderEntry{ + "dashscope": { + Model: "qwen3.7-max", + Models: models, + }, + }, + } + m := newProviderTUI(cfg, configPath) + m.activeTab = tabOfficial + for i, p := range m.providers { + if p.Name == "dashscope" { + m.officialIdx = i + break + } + } + m.step = stepModel + return m +} + +func customStepfunModelTUI(t *testing.T, configPath string, models []string) providerTUIModel { + t.Helper() + if len(models) == 0 { + models = []string{"step-3.5-flash"} + } + cfg := &Config{ + Provider: "stepfun", + Model: models[0], + CustomProviders: map[string]ProviderEntry{ + "stepfun": { + URL: "https://api.stepfun.com/v1", + Protocol: "openai", + Model: models[0], + Models: models, + }, + }, + } + m := newProviderTUI(cfg, configPath) + m.activeTab = tabCustom + for i, cp := range m.customProviders { + if cp.name == "stepfun" { + m.customIdx = i + break + } + } + m.step = stepModel + return m +} + +func modelIdxForName(t *testing.T, m providerTUIModel, name string) int { + t.Helper() + for i, model := range m.models() { + if model == name { + return i + } + } + t.Fatalf("model %q not found in %v", name, m.models()) + return -1 +} + +func TestProviderTUI_OfficialTab_DeleteUserAddedModel(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + m := officialDashscopeModelTUI(t, configPath, []string{"my-custom-model"}) + m.modelIdx = modelIdxForName(t, m, "my-custom-model") + + result, _ := m.Update(dKey()) + m2 := result.(providerTUIModel) + if !m2.confirmingDeleteModel { + t.Fatal("pressing d on user-added model should set confirmingDeleteModel = true") + } + if m2.deleteModelName != "my-custom-model" { + t.Errorf("deleteModelName = %q, want my-custom-model", m2.deleteModelName) + } + + result, _ = m2.Update(yKey()) + m3 := result.(providerTUIModel) + if m3.confirmingDeleteModel { + t.Error("confirmingDeleteModel should be false after y") + } + got := m3.existingCfg.Providers["dashscope"].Models + if len(got) != 1 || got[0] != "qwen3.7-max" { + t.Errorf("Models = %v, want [qwen3.7-max]", got) + } + if !m3.savedInSession { + t.Error("savedInSession should be true after delete") + } + + diskCfg, err := loadOrCreateConfig(configPath) + if err != nil { + t.Fatalf("load disk config: %v", err) + } + if len(diskCfg.Providers["dashscope"].Models) != 1 { + t.Errorf("disk Models = %v, want [qwen3.7-max]", diskCfg.Providers["dashscope"].Models) + } +} + +func TestProviderTUI_OfficialTab_DeleteBuiltInModelIgnored(t *testing.T) { + m := officialDashscopeModelTUI(t, "", []string{"my-custom-model"}) + m.modelIdx = modelIdxForName(t, m, "qwen3.7-max") + + result, _ := m.Update(dKey()) + m2 := result.(providerTUIModel) + if m2.confirmingDeleteModel { + t.Error("pressing d on built-in model should not trigger delete confirmation") + } +} + +func TestProviderTUI_OfficialTab_RegistryModelNotDeletable(t *testing.T) { + m := officialDashscopeModelTUI(t, "", []string{"my-custom-model"}) + m.modelIdx = modelIdxForName(t, m, "qwen3.7-max") + + if m.isUserAddedOfficialModel("qwen3.7-max") { + t.Error("qwen3.7-max should not be user-added when it is in the registry") + } + + result, _ := m.Update(dKey()) + m2 := result.(providerTUIModel) + if m2.confirmingDeleteModel { + t.Error("pressing d on registry model should not trigger delete confirmation") + } +} + +func TestProviderTUI_OfficialTab_DeleteOnCustomModelInputIgnored(t *testing.T) { + m := officialDashscopeModelTUI(t, "", []string{"my-custom-model"}) + m.modelIdx = len(m.models()) + + result, _ := m.Update(dKey()) + m2 := result.(providerTUIModel) + if m2.confirmingDeleteModel { + t.Error("pressing d on Enter custom model name... should not trigger delete confirmation") + } +} + +func TestProviderTUI_CustomTab_DeleteOnCustomModelInputIgnored(t *testing.T) { + m := customStepfunModelTUI(t, "", []string{"step-3.5-flash", "aaa"}) + m.modelIdx = len(m.models()) + + result, _ := m.Update(dKey()) + m2 := result.(providerTUIModel) + if m2.confirmingDeleteModel { + t.Error("pressing d on Enter custom model name... should not trigger delete confirmation") + } +} + +func TestProviderTUI_CustomTab_ModelShowsDeleteHint(t *testing.T) { + m := customStepfunModelTUI(t, "", []string{"step-3.5-flash", "aaa"}) + + m.modelIdx = len(m.models()) + got := stripANSI(m.View().Content) + if strings.Contains(got, "d Delete") { + t.Errorf("custom input row should not show d Delete hint; got:\n%s", got) + } + + m.modelIdx = modelIdxForName(t, m, "aaa") + got = stripANSI(m.View().Content) + if !strings.Contains(got, "d Delete") { + t.Errorf("custom model row should show d Delete hint; got:\n%s", got) + } +} + +func TestProviderTUI_OfficialTab_UserAddedModelShowsDeleteHint(t *testing.T) { + m := officialDashscopeModelTUI(t, "", []string{"my-custom-model"}) + + m.modelIdx = modelIdxForName(t, m, "qwen3.7-max") + got := stripANSI(m.View().Content) + if strings.Contains(got, "d Delete") { + t.Errorf("built-in model should not show d Delete hint; got:\n%s", got) + } + + m.modelIdx = modelIdxForName(t, m, "my-custom-model") + got = stripANSI(m.View().Content) + if !strings.Contains(got, "d Delete") { + t.Errorf("user-added model should show d Delete hint; got:\n%s", got) + } +} + +func TestProviderTUI_OfficialTab_DeleteModelPreservesActiveModel(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + m := officialDashscopeModelTUI(t, configPath, []string{"my-custom-model"}) + m.existingCfg.Model = "qwen3.7-max" + m.existingCfg.Providers["dashscope"] = ProviderEntry{ + Model: "qwen3.7-max", + Models: []string{"qwen3.7-max", "my-custom-model"}, + } + m.modelIdx = modelIdxForName(t, m, "my-custom-model") + + result, _ := m.Update(dKey()) + m2 := result.(providerTUIModel) + result, _ = m2.Update(yKey()) + m3 := result.(providerTUIModel) + + if m3.existingCfg.Providers["dashscope"].Model != "qwen3.7-max" { + t.Errorf("entry.Model = %q, want qwen3.7-max", m3.existingCfg.Providers["dashscope"].Model) + } + if m3.existingCfg.Model != "qwen3.7-max" { + t.Errorf("cfg.Model = %q, want qwen3.7-max", m3.existingCfg.Model) + } +} + +func TestProviderTUI_OfficialTab_DeleteUserAddedModelCancel(t *testing.T) { + cancelKeys := []struct { + name string + key tea.KeyPressMsg + }{ + {"n", nKey()}, + {"esc", escKey()}, + } + for _, tc := range cancelKeys { + t.Run(tc.name, func(t *testing.T) { + m := officialDashscopeModelTUI(t, "", []string{"my-custom-model"}) + m.modelIdx = modelIdxForName(t, m, "my-custom-model") + + result, _ := m.Update(dKey()) + m2 := result.(providerTUIModel) + if !m2.confirmingDeleteModel { + t.Fatal("expected confirmingDeleteModel after d") + } + + result, _ = m2.Update(tc.key) + m3 := result.(providerTUIModel) + if m3.confirmingDeleteModel { + t.Error("confirmingDeleteModel should be false after cancel") + } + got := m3.existingCfg.Providers["dashscope"].Models + if len(got) != 2 || got[1] != "my-custom-model" { + t.Errorf("Models = %v, want model unchanged", got) + } + }) + } +} + +func TestProviderTUI_OfficialTab_DeleteActiveUserModelClearsCfg(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + m := officialDashscopeModelTUI(t, configPath, []string{"my-custom-model"}) + m.existingCfg.Model = "my-custom-model" + m.existingCfg.Providers["dashscope"] = ProviderEntry{ + Model: "my-custom-model", + Models: []string{"qwen3.7-max", "my-custom-model"}, + } + m.modelIdx = modelIdxForName(t, m, "my-custom-model") + + result, _ := m.Update(dKey()) + m2 := result.(providerTUIModel) + result, _ = m2.Update(yKey()) + m3 := result.(providerTUIModel) + + if m3.existingCfg.Providers["dashscope"].Model != "" { + t.Errorf("entry.Model = %q, want empty", m3.existingCfg.Providers["dashscope"].Model) + } + if m3.existingCfg.Model != "" { + t.Errorf("cfg.Model = %q, want empty", m3.existingCfg.Model) + } +} + +func TestOfficialSelectedModelUsesRegistryNotStaleMerge(t *testing.T) { + registry := []string{"built-in"} + deletedCustom := "my-custom" + staleMerged := mergeModelLists(registry, []string{deletedCustom}) + + if llm.ModelListContains(registry, deletedCustom) { + t.Fatal("test setup: custom name should not be in registry") + } + if !llm.ModelListContains(staleMerged, deletedCustom) { + t.Fatal("test setup: stale merge should still list deleted custom name") + } + // runConfigModel must use registryModels, not staleMerged, or re-selected custom + // names would skip ensureModelInList when still present in the pre-TUI merge. + if llm.ModelListContains(staleMerged, deletedCustom) && llm.ModelListContains(registry, deletedCustom) { + t.Error("would skip persisting re-selected custom model") + } + if llm.ModelListContains(registry, deletedCustom) { + t.Error("registry-only check must not treat custom model as built-in") + } +} + +func TestProviderTUI_CustomTab_DeleteModelSkipsSavedInSessionWhenNoModelDeleted(t *testing.T) { + m := customStepfunModelTUI(t, "", []string{"step-3.5-flash", "aaa"}) + m.modelIdx = len(m.models()) // out of range — no model row selected + m.deleteModelName = "aaa" + m.confirmingDeleteModel = true + + result, _ := m.confirmDeleteCustomModel() + m2 := result.(providerTUIModel) + if m2.savedInSession { + t.Error("savedInSession should be false when modelIdx is out of range") + } +} + +func TestProviderTUI_CustomTab_DeleteModelViaDKey(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + cfg := &Config{ + Provider: "stepfun", + Model: "step-3.5-flash", + CustomProviders: map[string]ProviderEntry{ + "stepfun": { + URL: "https://api.stepfun.com/v1", + Protocol: "openai", + Model: "step-3.5-flash", + Models: []string{"step-3.5-flash", "aaa"}, + }, + }, + } + m := newProviderTUI(cfg, configPath) + m.activeTab = tabCustom + for i, cp := range m.customProviders { + if cp.name == "stepfun" { + m.customIdx = i + break + } + } + m.step = stepModel + m.modelIdx = modelIdxForName(t, m, "aaa") + + result, _ := m.Update(dKey()) + m2 := result.(providerTUIModel) + if !m2.confirmingDeleteModel || m2.deleteModelName != "aaa" { + t.Fatalf("after d: confirming=%v deleteModelName=%q", m2.confirmingDeleteModel, m2.deleteModelName) + } + + result, _ = m2.Update(yKey()) + m3 := result.(providerTUIModel) + got := m3.existingCfg.CustomProviders["stepfun"].Models + if len(got) != 1 || got[0] != "step-3.5-flash" { + t.Errorf("Models = %v, want [step-3.5-flash]", got) + } + + diskCfg, err := loadOrCreateConfig(configPath) + if err != nil { + t.Fatalf("load disk config: %v", err) + } + if len(diskCfg.CustomProviders["stepfun"].Models) != 1 { + t.Errorf("disk Models = %v, want [step-3.5-flash]", diskCfg.CustomProviders["stepfun"].Models) + } +} + +func TestProviderTUI_PersistCustomModelName_SaveFailureRollsBack(t *testing.T) { + blockPath := filepath.Join(t.TempDir(), "blocked") + if err := os.Mkdir(blockPath, 0o755); err != nil { + t.Fatal(err) + } + m := officialDashscopeModelTUI(t, blockPath, nil) + before := append([]string(nil), m.existingCfg.Providers["dashscope"].Models...) + m.modelIdx = len(m.models()) + m.customModel = true + m.modelInput.SetValue("failed-model") + m.modelInput.Focus() + + result, _ := m.Update(enterKey()) + m2 := result.(providerTUIModel) + if m2.formError == "" { + t.Fatal("expected formError on save failure") + } + if !strings.Contains(m2.formError, "failed to save") { + t.Errorf("formError = %q, want save failure message", m2.formError) + } + got := m2.existingCfg.Providers["dashscope"].Models + if len(got) != len(before) { + t.Errorf("Models = %v, want unchanged %v", got, before) + } + if m2.savedInSession { + t.Error("savedInSession should be false after failed persist") + } +} + func TestProviderTUI_ManualFormPassesKToAuthHeaderInput(t *testing.T) { dir := t.TempDir() configPath := filepath.Join(dir, "config.json") @@ -1167,6 +1880,692 @@ func TestProviderTUI_CustomFormPassesKToAuthHeaderInput(t *testing.T) { } } +func TestProviderTUI_ViewAPIKey_MaskedShowsReplaceHintAndLastFour(t *testing.T) { + cfg := &Config{ + Provider: "dashscope", + Model: "qwen3.7-max", + Providers: map[string]ProviderEntry{ + "dashscope": {APIKey: "sk-secret-1234567890abcd"}, + }, + } + m := newProviderTUI(cfg, "") + m.activeTab = tabOfficial + for i, p := range m.providers { + if p.Name == "dashscope" { + m.officialIdx = i + break + } + } + m.step = stepAPIKey + m.loadExistingAPIKey() + m.apiKeyInput.Focus() + + if !m.apiKeyMasked { + t.Fatal("apiKeyMasked should be true when an existing key is loaded") + } + + got := stripANSI(m.View().Content) + if !strings.Contains(got, "Type or paste to replace the saved key") { + t.Errorf("view missing replace hint; got:\n%s", got) + } + if !strings.Contains(got, "(saved: sk-sec...abcd)") { + t.Errorf("view missing prefix+suffix fingerprint; got:\n%s", got) + } +} + +func TestProviderTUI_ViewAPIKey_ShortKeyOmitsFingerprint(t *testing.T) { + cfg := &Config{ + Provider: "dashscope", + Model: "qwen3.7-max", + Providers: map[string]ProviderEntry{ + "dashscope": {APIKey: "12345678901234"}, // 14 runes — below min length 15 + }, + } + m := newProviderTUI(cfg, "") + m.activeTab = tabOfficial + for i, p := range m.providers { + if p.Name == "dashscope" { + m.officialIdx = i + break + } + } + m.step = stepAPIKey + m.loadExistingAPIKey() + m.apiKeyInput.Focus() + + got := stripANSI(m.View().Content) + if !strings.Contains(got, "Type or paste to replace the saved key") { + t.Errorf("view missing replace hint; got:\n%s", got) + } + if strings.Contains(got, "(saved:") { + t.Errorf("view should omit fingerprint for keys shorter than 15 runes; got:\n%s", got) + } +} + +func TestProviderTUI_ViewAPIKey_MinLenKeyShowsFingerprint(t *testing.T) { + cfg := &Config{ + Provider: "dashscope", + Model: "qwen3.7-max", + Providers: map[string]ProviderEntry{ + "dashscope": {APIKey: "123456789012345"}, // 15 runes — at min length + }, + } + m := newProviderTUI(cfg, "") + m.activeTab = tabOfficial + for i, p := range m.providers { + if p.Name == "dashscope" { + m.officialIdx = i + break + } + } + m.step = stepAPIKey + m.loadExistingAPIKey() + m.apiKeyInput.Focus() + + got := stripANSI(m.View().Content) + if !strings.Contains(got, "(saved: 123456...2345)") { + t.Errorf("view should show fingerprint at min length 15; got:\n%s", got) + } +} + +func TestSavedSecretFingerprint_TrimsLeadingWhitespace(t *testing.T) { + const key = "sk-secret-1234567890abcd" + got := savedSecretFingerprint(" " + key) + want := "sk-sec...abcd" + if got != want { + t.Errorf("savedSecretFingerprint(%q) = %q, want %q", " "+key, got, want) + } +} + +func TestProviderTUI_ViewAPIKey_FreshHidesReplaceHint(t *testing.T) { + cfg := &Config{ + Provider: "dashscope", + Model: "qwen3.7-max", + Providers: map[string]ProviderEntry{ + "dashscope": {}, + }, + } + m := newProviderTUI(cfg, "") + m.activeTab = tabOfficial + for i, p := range m.providers { + if p.Name == "dashscope" { + m.officialIdx = i + break + } + } + m.step = stepAPIKey + m.loadExistingAPIKey() + + if m.apiKeyMasked { + t.Fatal("apiKeyMasked should be false when no key is loaded") + } + + got := stripANSI(m.View().Content) + if strings.Contains(got, "Type or paste to replace the saved key") { + t.Errorf("view should not show replace hint when fresh; got:\n%s", got) + } +} + +func TestOfficialAPIKeyEnvSetHint(t *testing.T) { + const envVar = "DEEPSEEK_API_KEY" + if got := officialAPIKeyEnvSetHint(envVar, false); got != "$DEEPSEEK_API_KEY is set. Leave empty to use it; enter a key here to override." { + t.Errorf("no saved key hint = %q", got) + } + if got := officialAPIKeyEnvSetHint(envVar, true); got != "$DEEPSEEK_API_KEY is set; used only when no key is saved here." { + t.Errorf("saved key hint = %q", got) + } +} + +func TestProviderTUI_ViewAPIKey_EnvSetNoSavedKey(t *testing.T) { + t.Setenv("DEEPSEEK_API_KEY", "sk-from-env") + cfg := &Config{ + Provider: "deepseek", + Model: "deepseek-v4-flash", + Providers: map[string]ProviderEntry{ + "deepseek": {Model: "deepseek-v4-flash"}, + }, + } + m := newProviderTUI(cfg, "") + m.activeTab = tabOfficial + for i, p := range m.providers { + if p.Name == "deepseek" { + m.officialIdx = i + break + } + } + m.step = stepAPIKey + m.loadExistingAPIKey() + m.apiKeyInput.Focus() + + got := stripANSI(m.View().Content) + want := "$DEEPSEEK_API_KEY is set. Leave empty to use it; enter a key here to override." + if !strings.Contains(got, want) { + t.Errorf("view missing env hint; want %q; got:\n%s", want, got) + } +} + +func TestProviderTUI_ViewAPIKey_EnvSetWithSavedKey(t *testing.T) { + t.Setenv("DEEPSEEK_API_KEY", "sk-from-env") + cfg := &Config{ + Provider: "deepseek", + Model: "deepseek-v4-flash", + Providers: map[string]ProviderEntry{ + "deepseek": {APIKey: "sk-secret-1234567890abcd", Model: "deepseek-v4-flash"}, + }, + } + m := newProviderTUI(cfg, "") + m.activeTab = tabOfficial + for i, p := range m.providers { + if p.Name == "deepseek" { + m.officialIdx = i + break + } + } + m.step = stepAPIKey + m.loadExistingAPIKey() + m.apiKeyInput.Focus() + + got := stripANSI(m.View().Content) + if !strings.Contains(got, "Type or paste to replace the saved key") { + t.Errorf("view missing replace hint; got:\n%s", got) + } + want := "$DEEPSEEK_API_KEY is set; used only when no key is saved here." + if !strings.Contains(got, want) { + t.Errorf("view missing env hint; want %q; got:\n%s", want, got) + } + if strings.Contains(got, "Leave empty to use it") { + t.Errorf("saved-key view should not show empty-env hint; got:\n%s", got) + } +} + +func TestProviderTUI_ApiKeyPasteReplacesMaskedKey(t *testing.T) { + cfg := &Config{ + Provider: "stepfun", + Model: "step-3.5-flash", + CustomProviders: map[string]ProviderEntry{ + "stepfun": { + URL: "https://api.stepfun.com/v1", + APIKey: "old-key-ssss", + Model: "step-3.5-flash", + Models: []string{"step-3.5-flash"}, + }, + }, + } + m := newProviderTUI(cfg, "") + m.activeTab = tabCustom + m.customIdx = 0 + m.step = stepAPIKey + m.loadExistingAPIKey() + m.apiKeyInput.Focus() + + if !m.apiKeyMasked { + t.Fatal("expected masked key on load") + } + if m.apiKeyInput.Value() != maskedSecretPlaceholder() { + t.Fatalf("placeholder = %q, want fixed %d asterisks", m.apiKeyInput.Value(), maskedSecretDisplayLen) + } + + result, _ := m.Update(tea.PasteMsg{Content: "sk-new-pasted-key"}) + m2 := result.(providerTUIModel) + + if m2.apiKeyMasked { + t.Fatal("paste should unmask the field") + } + if got := m2.apiKeyInput.Value(); got != "sk-new-pasted-key" { + t.Errorf("input value = %q, want pasted key", got) + } + if r := m2.result(); r.apiKey != "sk-new-pasted-key" { + t.Errorf("result().apiKey = %q, want pasted key", r.apiKey) + } +} + +func TestProviderTUI_ManualTokenPasteReplacesMaskedToken(t *testing.T) { + cfg := &Config{ + Llm: LlmConfig{ + URL: "https://example.com/v1", + Model: "m", + AuthToken: "old-token-secret", + }, + } + m := newProviderTUI(cfg, "") + m.activeTab = tabManual + m.inManualForm = true + m.manualStep = manualStepAuthToken + m.manualTokenMasked = true + m.manualTokenOriginal = "old-token-secret" + m.manualTokenInput.SetValue(maskedSecretPlaceholder()) + m.manualTokenInput.Focus() + + if !m.manualTokenMasked { + t.Fatal("expected masked token on load") + } + + result, _ := m.Update(tea.PasteMsg{Content: "new-pasted-token"}) + m2 := result.(providerTUIModel) + + if m2.manualTokenMasked { + t.Fatal("paste should unmask the token field") + } + if got := m2.manualTokenInput.Value(); got != "new-pasted-token" { + t.Errorf("input value = %q, want pasted token", got) + } + if r := m2.result(); r.apiKey != "new-pasted-token" { + t.Errorf("result().apiKey = %q, want pasted token", r.apiKey) + } +} + +func TestProviderTUI_ApiKeyTypingShowsOneStarPerChar(t *testing.T) { + cfg := &Config{ + Provider: "stepfun", + Model: "step-3.5-flash", + CustomProviders: map[string]ProviderEntry{ + "stepfun": {APIKey: "old-key-ssss", Model: "step-3.5-flash"}, + }, + } + m := newProviderTUI(cfg, "") + m.activeTab = tabCustom + m.customIdx = 0 + m.step = stepAPIKey + m.loadExistingAPIKey() + m.apiKeyInput.Focus() + + for _, ch := range "abc" { + result, _ := m.Update(charKey(ch)) + m = result.(providerTUIModel) + } + if m.apiKeyInput.Value() != "abc" { + t.Errorf("value = %q, want abc", m.apiKeyInput.Value()) + } + // EchoPassword renders one '*' per character in the input view. + masked := stripANSI(m.apiKeyInput.View()) + starCount := strings.Count(masked, "*") + if starCount != 3 { + t.Errorf("masked view has %d asterisks, want 3 (one * per char)", starCount) + } +} + +func TestProviderTUI_ApiKeyEnterWithoutEditKeepsOriginal(t *testing.T) { + cfg := &Config{ + Provider: "stepfun", + CustomProviders: map[string]ProviderEntry{ + "stepfun": {APIKey: "keep-me"}, + }, + } + m := newProviderTUI(cfg, "") + m.activeTab = tabCustom + m.customIdx = 0 + m.step = stepAPIKey + m.loadExistingAPIKey() + m.apiKeyInput.Focus() + + if r := m.result(); r.apiKey != "keep-me" { + t.Fatalf("before edit result().apiKey = %q", r.apiKey) + } + + result, cmd := m.Update(enterKey()) + m2 := result.(providerTUIModel) + if !m2.confirmed { + t.Error("Enter without edit should confirm") + } + if cmd == nil { + t.Error("Enter without edit should quit") + } + if r := m2.result(); r.apiKey != "keep-me" { + t.Errorf("after Enter result().apiKey = %q, want keep-me", r.apiKey) + } +} + +func TestProviderTUI_ApiKeyClearSavedKeyReturnsEmpty(t *testing.T) { + cfg := &Config{ + Provider: "deepseek", + Model: "deepseek-v4-flash", + Providers: map[string]ProviderEntry{ + "deepseek": {APIKey: "old-saved-key", Model: "deepseek-v4-flash"}, + }, + } + m := newProviderTUI(cfg, "") + m.activeTab = tabOfficial + for i, p := range m.providers { + if p.Name == "deepseek" { + m.officialIdx = i + break + } + } + m.step = stepAPIKey + m.loadExistingAPIKey() + m.beginAPIKeyReplace() + + if r := m.result(); r.apiKey != "" { + t.Errorf("result().apiKey = %q, want empty after clearing saved key", r.apiKey) + } +} + +func TestProviderTUI_ApiKeyResultTrimSpace(t *testing.T) { + cfg := &Config{ + Provider: "deepseek", + Model: "deepseek-v4-flash", + Providers: map[string]ProviderEntry{ + "deepseek": {Model: "deepseek-v4-flash"}, + }, + } + m := newProviderTUI(cfg, "") + m.activeTab = tabOfficial + for i, p := range m.providers { + if p.Name == "deepseek" { + m.officialIdx = i + break + } + } + m.step = stepAPIKey + m.apiKeyInput.SetValue(" ") + + if r := m.result(); r.apiKey != "" { + t.Errorf("result().apiKey = %q, want empty for whitespace-only input", r.apiKey) + } +} + +func TestProviderTUI_OfficialApiKeyEmptyWithoutEnvBlocksEnter(t *testing.T) { + cfg := &Config{ + Provider: "dashscope", + Model: "qwen3.7-max", + Providers: map[string]ProviderEntry{ + "dashscope": {}, + }, + } + m := newProviderTUI(cfg, "") + m.activeTab = tabOfficial + for i, p := range m.providers { + if p.Name == "dashscope" { + m.officialIdx = i + break + } + } + m.step = stepAPIKey + m.loadExistingAPIKey() + m.apiKeyInput.Focus() + + t.Setenv("DASHSCOPE_API_KEY", "") + + result, cmd := m.Update(enterKey()) + m2 := result.(providerTUIModel) + if m2.step != stepAPIKey { + t.Errorf("step = %d, want stepAPIKey", m2.step) + } + if m2.formError != "API key is required (or set $DASHSCOPE_API_KEY)" { + t.Errorf("formError = %q", m2.formError) + } + if cmd != nil { + t.Error("Enter without key or env should not quit") + } +} + +func TestProviderTUI_OfficialApiKeyEmptyWithEnvAllowsEnter(t *testing.T) { + cfg := &Config{ + Provider: "dashscope", + Model: "qwen3.7-max", + Providers: map[string]ProviderEntry{ + "dashscope": {}, + }, + } + m := newProviderTUI(cfg, "") + m.activeTab = tabOfficial + for i, p := range m.providers { + if p.Name == "dashscope" { + m.officialIdx = i + break + } + } + m.step = stepAPIKey + m.loadExistingAPIKey() + m.apiKeyInput.Focus() + + t.Setenv("DASHSCOPE_API_KEY", "sk-from-env") + + result, cmd := m.Update(enterKey()) + m2 := result.(providerTUIModel) + if !m2.confirmed { + t.Error("Enter with env set should confirm") + } + if cmd == nil { + t.Error("Enter with env set should quit") + } + if m2.formError != "" { + t.Errorf("formError = %q, want empty", m2.formError) + } +} + +func TestProviderTUI_CustomExistingApiKeyEmptyBlocksEnter(t *testing.T) { + cfg := &Config{ + Provider: "stepfun", + CustomProviders: map[string]ProviderEntry{ + "stepfun": {APIKey: "old-key"}, + }, + } + m := newProviderTUI(cfg, "") + m.activeTab = tabCustom + m.customIdx = 0 + m.step = stepAPIKey + m.loadExistingAPIKey() + m.apiKeyInput.Focus() + m.beginAPIKeyReplace() + + result, cmd := m.Update(enterKey()) + m2 := result.(providerTUIModel) + if m2.step != stepAPIKey { + t.Errorf("step = %d, want stepAPIKey", m2.step) + } + if m2.formError != "API key is required" { + t.Errorf("formError = %q, want %q", m2.formError, "API key is required") + } + if cmd != nil { + t.Error("Enter with cleared key should not quit") + } +} + +func TestProviderTUI_CustomCreateApiKeyOptional(t *testing.T) { + m := newProviderTUI(&Config{}, "") + m.activeTab = tabCustom + m.creatingCustom = true + m.cpStep = cpStepAPIKey + m.apiKeyInput.SetValue("") + m.apiKeyInput.Focus() + + result, _ := m.Update(enterKey()) + m2 := result.(providerTUIModel) + if m2.cpStep != cpStepAuthHeader { + t.Errorf("cpStep = %d, want cpStepAuthHeader", m2.cpStep) + } + if m2.formError != "" { + t.Errorf("formError = %q, want empty for optional API key", m2.formError) + } +} + +func TestProviderTUI_ViewAPIKey_ShowsFormError(t *testing.T) { + cfg := &Config{ + Provider: "dashscope", + Model: "qwen3.7-max", + Providers: map[string]ProviderEntry{ + "dashscope": {}, + }, + } + m := newProviderTUI(cfg, "") + m.activeTab = tabOfficial + for i, p := range m.providers { + if p.Name == "dashscope" { + m.officialIdx = i + break + } + } + m.step = stepAPIKey + m.loadExistingAPIKey() + m.formError = "API key is required (or set $DASHSCOPE_API_KEY)" + m.apiKeyInput.Focus() + + got := stripANSI(m.View().Content) + if !strings.Contains(got, "API key is required (or set $DASHSCOPE_API_KEY)") { + t.Errorf("view missing formError; got:\n%s", got) + } +} + +func TestProviderTUI_CancelIncompleteOfficialProviderSwitch_NoPersistedChanges(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + cfg := &Config{ + Provider: "deepseek", + Model: "deepseek-v4-flash", + Providers: map[string]ProviderEntry{ + "deepseek": {Model: "deepseek-v4-flash"}, + }, + } + m := newProviderTUI(cfg, configPath) + m.activeTab = tabOfficial + for i, p := range m.providers { + if p.Name == "baidu-qianfan" { + m.officialIdx = i + break + } + } + m.step = stepModel + m.modelIdx = modelIdxForName(t, m, "glm-5") + + result, _ := m.Update(enterKey()) + m2 := result.(providerTUIModel) + if m2.savedInSession { + t.Error("savedInSession should be false for cross-provider navigation") + } + if m2.step != stepAPIKey { + t.Fatalf("step = %d, want stepAPIKey", m2.step) + } + + result, _ = m2.Update(escKey()) + m3 := result.(providerTUIModel) + result, _ = m3.Update(escKey()) + m4 := result.(providerTUIModel) + result, cmd := m4.Update(escKey()) + m5 := result.(providerTUIModel) + if !m5.cancelled { + t.Error("expected cancelled = true") + } + if m5.savedInSession { + t.Error("savedInSession should remain false after cancel") + } + if cmd == nil { + t.Error("expected tea.Quit on final Esc") + } + if _, err := os.Stat(configPath); err == nil { + diskCfg, err := loadOrCreateConfig(configPath) + if err != nil { + t.Fatalf("load config: %v", err) + } + if diskCfg.Provider != "deepseek" { + t.Errorf("Provider = %q, want deepseek", diskCfg.Provider) + } + if entry, ok := diskCfg.Providers["baidu-qianfan"]; ok && entry.Model != "" { + t.Errorf("baidu-qianfan model = %q, want no cross-provider draft persisted", entry.Model) + } + } +} + +func TestProviderTUI_SameOfficialProviderModelChange_DefersPersistUntilConfirm(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + cfg := &Config{ + Provider: "deepseek", + Model: "deepseek-v4-flash", + Providers: map[string]ProviderEntry{ + "deepseek": {Model: "deepseek-v4-flash"}, + }, + } + m := newProviderTUI(cfg, configPath) + m.activeTab = tabOfficial + m.step = stepModel + m.modelIdx = modelIdxForName(t, m, "deepseek-v4-pro") + + result, _ := m.Update(enterKey()) + m2 := result.(providerTUIModel) + if m2.savedInSession { + t.Error("savedInSession should be false before API key confirm") + } + if m2.step != stepAPIKey { + t.Fatalf("step = %d, want stepAPIKey", m2.step) + } + if _, err := os.Stat(configPath); err == nil { + t.Fatal("config should not be written before wizard confirm") + } + if cfg.Model != "deepseek-v4-flash" { + t.Errorf("cfg.Model = %q, want deepseek-v4-flash", cfg.Model) + } +} + +func TestProviderTUI_OfficialModelChangeBlockedAtAPIKey_KeepsGlobalModel(t *testing.T) { + t.Setenv("ANTHROPIC_API_KEY", "") + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + cfg := &Config{ + Provider: "anthropic", + Model: "claude-opus-4-8", + Providers: map[string]ProviderEntry{ + "anthropic": { + Model: "claude-opus-4-8", + APIKey: "sk-test-key", + }, + }, + } + m := newProviderTUI(cfg, configPath) + m.activeTab = tabOfficial + for i, p := range m.providers { + if p.Name == "anthropic" { + m.officialIdx = i + break + } + } + m.step = stepModel + m.modelIdx = modelIdxForName(t, m, "claude-opus-4-7") + + result, _ := m.Update(enterKey()) + m2 := result.(providerTUIModel) + m2.beginAPIKeyReplace() + + result, cmd := m2.Update(enterKey()) + m3 := result.(providerTUIModel) + if cmd != nil { + t.Error("Enter without key or env should not quit") + } + if m3.step != stepAPIKey { + t.Fatalf("step = %d, want stepAPIKey", m3.step) + } + if cfg.Model != "claude-opus-4-8" { + t.Errorf("cfg.Model = %q, want claude-opus-4-8", cfg.Model) + } + if got := cfg.Providers["anthropic"].Model; got != "claude-opus-4-8" { + t.Errorf("providers.anthropic.Model = %q, want claude-opus-4-8", got) + } + if _, err := os.Stat(configPath); err == nil { + t.Fatal("config should not be written when API key validation fails") + } +} + +// stripANSI removes ANSI escape sequences from a string so tests can assert +// against plain text content. +func stripANSI(s string) string { + var b strings.Builder + inEscape := false + for _, r := range s { + if r == 0x1b { + inEscape = true + continue + } + if inEscape { + if r == 'm' { + inEscape = false + } + continue + } + b.WriteRune(r) + } + return b.String() +} + func TestProviderTUI_DeleteModelPreservesActiveModel(t *testing.T) { cfg := &Config{ Provider: "stepfun", diff --git a/cmd/opencodereview/review_cmd.go b/cmd/opencodereview/review_cmd.go index d76951b..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,34 +125,75 @@ 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 resolveRepoDir(input string) (string, error) { - if input == "" { - var err error - input, err = os.Getwd() - if err != nil { - return "", fmt.Errorf("get working directory: %w", err) - } +func loadReviewResumeState(repoDir string, opts reviewOptions) (*session.ResumeState, error) { + if opts.resume == "" { + return nil, nil } - absPath, err := filepath.Abs(input) + 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 "", fmt.Errorf("resolve absolute path: %w", err) + return nil, fmt.Errorf("load resume session: %w (run 'ocr session list' to see available sessions)", err) } - out, err := runGitCmd(absPath, "rev-parse", "--git-dir") - if err != nil || len(out) == 0 { - return "", fmt.Errorf("%s is not a git repository", absPath) + if err := state.ValidateOptions(current); err != nil { + return nil, fmt.Errorf("%w (run 'ocr session list' to see available sessions)", err) } - return absPath, nil + 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 +// monorepo subdirectory (#287). +func resolveRepoDir(input string) (string, error) { + absPath, _, err := resolveWorkingDir(input, true) + return absPath, err } // requireGitRepo validates that the given directory is part of a git repository. 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 8d898a1..c99c1b2 100644 --- a/cmd/opencodereview/shared.go +++ b/cmd/opencodereview/shared.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "time" "github.com/open-code-review/open-code-review/internal/agent" @@ -100,6 +101,25 @@ func resolveWorkingDir(input string, requireGit bool) (string, bool, error) { if !isGit && requireGit { return "", false, fmt.Errorf("%s is not a git repository", absPath) } + // #287: git reports diff and `git show HEAD:` paths relative to the + // repository root, not the current directory. When `ocr review` runs from a + // subdirectory of a monorepo, anchor RepoDir at the git top-level so those + // root-relative paths resolve for both disk reads and git-show reads. + // requireGit is true only for the review path; scan (requireGit=false) keeps + // the CWD so its `git ls-files` walk stays scoped to the subdirectory. + if isGit && requireGit { + // runGitCmdStdout captures stdout only so git stderr notices can't + // pollute the resolved path. --show-toplevel fails (or is empty) when + // there is no work tree — e.g. a bare repo, where --git-dir succeeds so + // isGit is true. Fail loudly there instead of silently reusing the + // subdir, which would reproduce the #287 root-relative-path bug. + top, topErr := runGitCmdStdout(absPath, "rev-parse", "--show-toplevel") + t := strings.TrimSpace(string(top)) + if topErr != nil || t == "" { + return "", false, fmt.Errorf("%s is a git repository without a work tree (bare repo?); cannot resolve its top level for review", absPath) + } + absPath = t + } return absPath, isGit, nil } @@ -231,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 @@ -256,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 @@ -273,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 8c9b819..417a65f 100644 --- a/cmd/opencodereview/shared_test.go +++ b/cmd/opencodereview/shared_test.go @@ -2,6 +2,7 @@ package main import ( "os" + "os/exec" "path/filepath" "testing" @@ -78,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 { @@ -109,6 +119,89 @@ func TestResolveWorkingDir_NonExistent(t *testing.T) { } } +// TestResolveWorkingDir_MonorepoSubdir reproduces #287: running `ocr review` +// from a subdirectory of a git repo must anchor RepoDir at the git top-level +// (git reports diff / `git show HEAD:` paths relative to the repo root), +// while `ocr scan` (requireGit=false) must keep the subdirectory so its walk +// stays scoped. +func TestResolveWorkingDir_MonorepoSubdir(t *testing.T) { + root := t.TempDir() + git := func(args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = root + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + } + git("init") + git("config", "user.email", "t@t.co") + git("config", "user.name", "t") + + sub := filepath.Join(root, "subproject1", "src") + if err := os.MkdirAll(sub, 0o755); err != nil { + t.Fatal(err) + } + + // macOS /var -> /private/var symlink means t.TempDir() differs from the + // canonicalized toplevel git returns; compare via EvalSymlinks. + wantRoot, err := filepath.EvalSymlinks(root) + if err != nil { + t.Fatalf("EvalSymlinks(%q): %v", root, err) + } + + // review path: hoisted to the git top-level. + got, isGit, err := resolveWorkingDir(sub, true) + if err != nil { + t.Fatalf("resolveWorkingDir(sub, true) error: %v", err) + } + if !isGit { + t.Error("expected isGit=true for a git subdirectory") + } + gotResolved, err := filepath.EvalSymlinks(got) + if err != nil { + t.Fatalf("EvalSymlinks(%q): %v", got, err) + } + if gotResolved != wantRoot { + t.Errorf("review RepoDir = %q, want git top-level %q", gotResolved, wantRoot) + } + + // scan path: keeps the subdirectory unchanged. + gotScan, _, err := resolveWorkingDir(sub, false) + if err != nil { + t.Fatalf("resolveWorkingDir(sub, false) error: %v", err) + } + gotScanResolved, err := filepath.EvalSymlinks(gotScan) + if err != nil { + t.Fatalf("EvalSymlinks(%q): %v", gotScan, err) + } + wantSub, err := filepath.EvalSymlinks(sub) + if err != nil { + t.Fatalf("EvalSymlinks(%q): %v", sub, err) + } + if gotScanResolved != wantSub { + t.Errorf("scan RepoDir = %q, want subdir %q (must stay scoped)", gotScanResolved, wantSub) + } +} + +// TestResolveWorkingDir_BareRepoFailsLoudly guards the #287 fix: a bare repo has +// no work tree, so `git rev-parse --git-dir` succeeds (isGit=true) but +// `--show-toplevel` fails. The review path (requireGit=true) must return an +// error rather than silently reusing the input dir, which would reproduce the +// original root-relative-path bug. +func TestResolveWorkingDir_BareRepoFailsLoudly(t *testing.T) { + bare := t.TempDir() + cmd := exec.Command("git", "init", "--bare", bare) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git init --bare: %v\n%s", err, out) + } + + _, _, err := resolveWorkingDir(bare, true) + if err == nil { + t.Fatal("expected error for a bare repo (no work tree), got nil") + } +} + func TestResolveWorkingDir_GitRepo(t *testing.T) { dir := t.TempDir() gitDir := filepath.Join(dir, ".git") 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/.gitignore b/extensions/vscode/.gitignore index ae7fae2..e7377a7 100644 --- a/extensions/vscode/.gitignore +++ b/extensions/vscode/.gitignore @@ -2,4 +2,5 @@ .superpowers/ node_modules/ reference -out/ \ No newline at end of file +out/ +*.vsix \ No newline at end of file diff --git a/extensions/vscode/.vscodeignore b/extensions/vscode/.vscodeignore index 213ba03..d64358b 100644 --- a/extensions/vscode/.vscodeignore +++ b/extensions/vscode/.vscodeignore @@ -13,3 +13,4 @@ jest.config.js .superpowers/** prototype.html resources/icon-old.svg +__mocks__/** diff --git a/extensions/vscode/LICENSE b/extensions/vscode/LICENSE new file mode 100644 index 0000000..a4f1fec --- /dev/null +++ b/extensions/vscode/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this definition, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a complaint) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that you distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act on + Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the same + "printed page" as the copyright notice for easier identification within + third-party archives. + + Copyright 2026 Alibaba + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/extensions/vscode/open-code-review-vscode-0.1.0.vsix b/extensions/vscode/open-code-review-vscode-0.1.0.vsix deleted file mode 100644 index 428b077..0000000 Binary files a/extensions/vscode/open-code-review-vscode-0.1.0.vsix and /dev/null differ diff --git a/extensions/vscode/package.json b/extensions/vscode/package.json index 7644112..53d9678 100644 --- a/extensions/vscode/package.json +++ b/extensions/vscode/package.json @@ -2,12 +2,12 @@ "name": "open-code-review-vscode", "displayName": "Open Code Review", "description": "%ocr.description%", - "version": "0.1.0", + "version": "0.1.1", "publisher": "open-code-review", "license": "Apache-2.0", "repository": { "type": "git", - "url": "git+https://github.com/nigulasikk/open-code-review.git", + "url": "git+https://github.com/alibaba/open-code-review.git", "directory": "extensions/vscode" }, "engines": { @@ -16,6 +16,7 @@ "categories": [ "Other" ], + "icon": "resources/icon.png", "main": "./out/extension.js", "activationEvents": [ "onStartupFinished" diff --git a/extensions/vscode/resources/icon-old.svg b/extensions/vscode/resources/icon-old.svg deleted file mode 100644 index 3ce9a0e..0000000 --- a/extensions/vscode/resources/icon-old.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/extensions/vscode/resources/icon.png b/extensions/vscode/resources/icon.png new file mode 100644 index 0000000..0c2add0 Binary files /dev/null and b/extensions/vscode/resources/icon.png differ diff --git a/extensions/vscode/resources/icon.svg b/extensions/vscode/resources/icon.svg index 7a0e418..c4a054b 100644 --- a/extensions/vscode/resources/icon.svg +++ b/extensions/vscode/resources/icon.svg @@ -1,21 +1 @@ - - - - - + \ No newline at end of file diff --git a/extensions/vscode/src/extension/extension.ts b/extensions/vscode/src/extension/extension.ts index c398e6b..5bc0c56 100644 --- a/extensions/vscode/src/extension/extension.ts +++ b/extensions/vscode/src/extension/extension.ts @@ -16,7 +16,7 @@ export function activate(context: vscode.ExtensionContext): void { const cli = new CliService('ocr'); const config = new ConfigService(cli); const git = new GitService(output); - const comments = new CommentProvider(extensionUri); + const comments = new CommentProvider(extensionUri, git); const sidebar = new SidebarProvider(extensionUri, cli, config, git, comments); const configPanel = new ConfigPanelProvider(extensionUri, cli, config, (cfg) => sidebar.pushConfig(cfg)); diff --git a/extensions/vscode/src/extension/providers/CommentProvider.ts b/extensions/vscode/src/extension/providers/CommentProvider.ts index 02d1dba..84742f7 100644 --- a/extensions/vscode/src/extension/providers/CommentProvider.ts +++ b/extensions/vscode/src/extension/providers/CommentProvider.ts @@ -1,22 +1,34 @@ import { t, resolveLocale, SupportedLocale } from '../../shared/i18n'; import * as vscode from 'vscode'; -import { ReviewComment, CommentStatus, CommentSyncState } from '../../shared/types'; +import { ReviewComment, CommentStatus, CommentSyncState, ReviewContext, ReviewMode } from '../../shared/types'; import { COMMENT_CONTROLLER_ID } from '../../shared/constants'; import { LineOffsetTracker } from './lineOffset'; +import { GitService } from '../services/GitService'; +import { + MountableCommentAnchor, + resolveCommentAnchor, + CommentAnchorDeps, + SidebarOnlyReason, +} from './commentAnchor'; export class CommentProvider { private controller: vscode.CommentController; - // 以 comment 在 result.comments 中的原始下标为 key,与 webview 共用同一索引空间。 - // 打不开的文件(如目录)没有 thread,但下标依旧保留,避免错位。 private threads = new Map(); + private mounts = new Map(); + private jumpable = new Set(); + private jumpBlockReasons = new Map(); private comments: ReviewComment[] = []; private status = new Map(); private offsets = new LineOffsetTracker(); private syncListeners: Array<(s: CommentSyncState[]) => void> = []; + private reviewContext: ReviewContext = { mode: ReviewMode.Workspace }; private locale: SupportedLocale; - constructor(private extensionUri: vscode.Uri) { + constructor( + private extensionUri: vscode.Uri, + private git: GitService, + ) { this.locale = resolveLocale(vscode.env.language); this.controller = vscode.comments.createCommentController(COMMENT_CONTROLLER_ID, t(this.locale, 'ext.commentController')); } @@ -27,62 +39,92 @@ export class CommentProvider { private emitSync(): void { const states: CommentSyncState[] = this.comments.map((_, i) => ({ - index: i, status: this.status.get(i) ?? 'pending', + index: i, + status: this.status.get(i) ?? 'pending', + jumpable: this.jumpable.has(i), })); this.syncListeners.forEach((fn) => fn(states)); } - /** - * 展示审查评论。 - * @param inEditor 是否在编辑器内创建 CommentThread。仅工作区模式为 true; - * 分支对比/单次提交模式下被审查代码不在当前工作区,行号会错位,故只在侧边栏展示。 - */ - async show(comments: ReviewComment[], inEditor = true): Promise { + /** 展示审查评论:能解析到 git/工作区快照的挂 thread,否则仅侧边栏。 */ + async show(comments: ReviewComment[], ctx: ReviewContext): Promise { this.clear(); - // 不重排:保持与 webview(result.comments)相同的顺序与下标 + this.reviewContext = ctx; this.comments = comments; + const root = vscode.workspace.workspaceFolders?.[0].uri.fsPath; if (!root) return; - // 非工作区模式:只登记评论与状态供侧边栏同步,不在编辑器内放置 thread。 - if (!inEditor) { - for (let i = 0; i < this.comments.length; i++) this.status.set(i, 'pending'); - this.emitSync(); - return; - } + await this.git.prepareReviewFileStatus(ctx); + const deps = this.buildAnchorDeps(root, ctx); let firstShown = -1; for (let i = 0; i < this.comments.length; i++) { const c = this.comments[i]; this.status.set(i, 'pending'); + + const anchor = await resolveCommentAnchor(c, ctx, deps); + if (anchor.kind === 'sidebar') { + this.jumpBlockReasons.set(i, anchor.reason); + continue; + } + try { - const uri = vscode.Uri.file(`${root}/${c.path}`); - const doc = await vscode.workspace.openTextDocument(uri); - const range = new vscode.Range(Math.max(0, c.startLine - 1), 0, Math.max(0, c.endLine - 1), 0); - const body = this.renderBody(c, i, 'pending'); - const thread = this.controller.createCommentThread(doc.uri, range, [{ - body, mode: vscode.CommentMode.Preview, + await vscode.workspace.openTextDocument(anchor.uri); + const body = this.renderBody(c, i, 'pending', anchor.locateNote); + const thread = this.controller.createCommentThread(anchor.uri, anchor.range, [{ + body, + mode: vscode.CommentMode.Preview, author: { name: t(this.locale, 'ext.comment.pending') }, }]); thread.canReply = false; thread.label = `${t(this.locale, 'ext.comment.threadLabel')} (${i + 1} / ${this.comments.length})`; - // 有代码建议 → 'pending'(显示应用+忽略);无建议 → 'pendingNoSuggestion'(仅忽略) - thread.contextValue = this.hasSuggestion(c) ? 'pending' : 'pendingNoSuggestion'; + thread.contextValue = this.threadContextValue(c, ctx); thread.collapsibleState = vscode.CommentThreadCollapsibleState.Expanded; this.threads.set(i, thread); + this.mounts.set(i, anchor); + this.jumpable.add(i); if (firstShown < 0) firstShown = i; - } catch { /* 文件打不开(如目录)则无 thread,但保留下标 */ } + } catch { + this.jumpBlockReasons.set(i, 'mount-failed'); + } } + if (firstShown >= 0) await this.jumpTo(firstShown); this.emitSync(); } + private buildAnchorDeps(root: string, ctx: ReviewContext): CommentAnchorDeps { + return { + repoRoot: root, + fileStatus: (path) => this.git.getReviewFileStatus(path), + readAtRef: (ref, path) => this.git.readFileAtRef(ref, path), + readWorkspace: (path) => this.git.readWorkspaceFile(path), + buildDiffUris: (path, status) => this.git.buildCommentDiffUris(path, status, ctx), + toGitUri: async (path, ref) => { + const uri = await this.git.createGitFileUri(path, ref); + if (!uri) throw new Error('git uri unavailable'); + return uri; + }, + }; + } + + private threadContextValue(c: ReviewComment, ctx: ReviewContext): string { + if (ctx.mode !== ReviewMode.Workspace) return 'pendingNoSuggestion'; + return this.hasSuggestion(c) ? 'pending' : 'pendingNoSuggestion'; + } + private hasSuggestion(c: ReviewComment): boolean { return !!(c.suggestionCode && c.suggestionCode.trim()); } - private renderBody(c: ReviewComment, _index: number, _status: CommentStatus): vscode.MarkdownString { - let md = c.content; + private renderBody( + c: ReviewComment, + _index: number, + _status: CommentStatus, + locateNote?: string, + ): vscode.MarkdownString { + let md = locateNote ? `${locateNote}\n\n${c.content}` : c.content; if (this.hasSuggestion(c)) { md += `\n***\n\`\`\`diff\n${c.suggestionCode}\n\`\`\``; } else { @@ -94,6 +136,10 @@ export class CommentProvider { } async apply(index: number): Promise { + if (this.reviewContext.mode !== ReviewMode.Workspace) { + vscode.window.showWarningMessage(t(this.locale, 'ext.comment.applyWorkspaceOnly')); + return; + } const c = this.comments[index]; if (!c) return; const root = vscode.workspace.workspaceFolders?.[0].uri.fsPath; @@ -110,8 +156,6 @@ export class CommentProvider { const range = new vscode.Range(start, 0, end, doc.lineAt(end).text.length); const hasSuggestion = !!(c.suggestionCode && c.suggestionCode.trim()); - // 用 WorkspaceEdit 而非 editor.edit:后者要求目标编辑器为活动编辑器, - // 从评论标题栏按钮触发时焦点在评论控件上,会静默返回 false 导致“点不动”。 const edit = new vscode.WorkspaceEdit(); if (hasSuggestion) edit.replace(uri, range, c.suggestionCode!); else edit.delete(uri, range); @@ -132,6 +176,7 @@ export class CommentProvider { private setStatus(index: number, status: CommentStatus): void { this.status.set(index, status); const thread = this.threads.get(index); + const mount = this.mounts.get(index); if (thread) { const label = { applied: t(this.locale, 'ext.comment.statusApplied'), @@ -139,7 +184,11 @@ export class CommentProvider { falsePositive: t(this.locale, 'ext.comment.statusFalsePositive'), pending: t(this.locale, 'ext.comment.pending'), }[status]; - thread.comments = [{ ...thread.comments[0], author: { name: label }, body: this.renderBody(this.comments[index], index, status) }] as any; + thread.comments = [{ + ...thread.comments[0], + author: { name: label }, + body: this.renderBody(this.comments[index], index, status, mount?.locateNote), + }] as any; thread.contextValue = status; thread.collapsibleState = vscode.CommentThreadCollapsibleState.Collapsed; } @@ -147,27 +196,128 @@ export class CommentProvider { } async jumpTo(index: number): Promise { + const mount = this.mounts.get(index); const thread = this.threads.get(index); - if (!thread) { - const c = this.comments[index]; - if (c) vscode.window.showWarningMessage(`${t(this.locale, 'ext.comment.jumpFailed')}${c.path}${t(this.locale, 'ext.comment.jumpNotAFile')}`); + if (!mount || !thread) { + this.showJumpFailed(index); return; } - await vscode.window.showTextDocument(thread.uri, { selection: thread.range, preview: false }); + + if (mount.diff) { + const opened = await this.openDiffEditor(mount); + if (opened) { + await this.revealInDiffSide(mount.uri, mount.range, mount.side); + } else { + await vscode.window.showTextDocument(mount.uri, { selection: mount.range, preview: false }); + } + } else { + await vscode.window.showTextDocument(mount.uri, { selection: mount.range, preview: false }); + } thread.collapsibleState = vscode.CommentThreadCollapsibleState.Expanded; } + private showJumpFailed(index: number): void { + const c = this.comments[index]; + if (!c) return; + + const reason = this.jumpBlockReasons.get(index) ?? this.inferJumpBlockReason(c); + const key = reason === 'missing-file' || reason === 'mount-failed' + ? 'ext.comment.jumpFileMissing' + : 'ext.comment.jumpLineUnresolved'; + vscode.window.showWarningMessage( + t(this.locale, key).replace('{path}', c.path), + ); + } + + /** 未记录原因时根据评论元数据推断(如 L0 多为行号未解析)。 */ + private inferJumpBlockReason(c: ReviewComment): SidebarOnlyReason | 'mount-failed' { + if (c.startLine <= 0 && c.endLine <= 0) return 'unresolved'; + return 'missing-file'; + } + + private async openDiffEditor(mount: MountableCommentAnchor): Promise { + if (!mount.diff) return false; + try { + await vscode.commands.executeCommand( + 'vscode.diff', + mount.diff.left, + mount.diff.right, + mount.diff.title, + { preview: false }, + ); + return true; + } catch { + return false; + } + } + + /** 在已打开的 diff 编辑器中定位到挂载侧行,不再额外打开单文件 tab。 */ + private async revealInDiffSide( + uri: vscode.Uri, + range: vscode.Range, + side: MountableCommentAnchor['side'], + ): Promise { + for (let attempt = 0; attempt < 8; attempt++) { + const editor = this.findEditorForUri(uri, side); + if (editor) { + editor.selection = new vscode.Selection(range.start, range.end); + editor.revealRange(range, vscode.TextEditorRevealType.InCenter); + return; + } + await new Promise((r) => setTimeout(r, 40)); + } + } + + private findEditorForUri( + uri: vscode.Uri, + side?: MountableCommentAnchor['side'], + ): vscode.TextEditor | undefined { + for (const editor of vscode.window.visibleTextEditors) { + if (this.urisMatch(editor.document.uri, uri)) return editor; + } + + const tab = vscode.window.tabGroups.activeTabGroup.activeTab; + if (tab?.input instanceof vscode.TabInputTextDiff) { + const { original, modified } = tab.input; + const prefer = side === 'left' ? original : side === 'right' ? modified : undefined; + const candidates = prefer ? [prefer, original, modified] : [original, modified]; + for (const candidate of candidates) { + if (!this.urisMatch(candidate, uri)) continue; + const editor = vscode.window.visibleTextEditors.find( + (ed) => this.urisMatch(ed.document.uri, candidate), + ); + if (editor) return editor; + } + } + return undefined; + } + + private urisMatch(a: vscode.Uri, b: vscode.Uri): boolean { + if (a.toString() === b.toString()) return true; + if (a.scheme !== b.scheme) return false; + if (a.scheme === 'git') { + const refA = new URLSearchParams(a.query).get('ref'); + const refB = new URLSearchParams(b.query).get('ref'); + return a.path === b.path && refA === refB; + } + return a.fsPath === b.fsPath; + } + indexOfThread(thread: vscode.CommentThread): number { - for (const [i, t] of this.threads) if (t === thread) return i; + for (const [i, th] of this.threads) if (th === thread) return i; return -1; } clear(): void { - this.threads.forEach((t) => t.dispose()); + this.threads.forEach((th) => th.dispose()); this.threads.clear(); + this.mounts.clear(); + this.jumpable.clear(); + this.jumpBlockReasons.clear(); this.comments = []; this.status.clear(); this.offsets.clear(); + this.reviewContext = { mode: ReviewMode.Workspace }; } dispose(): void { diff --git a/extensions/vscode/src/extension/providers/SidebarProvider.ts b/extensions/vscode/src/extension/providers/SidebarProvider.ts index f63ff58..f3beebd 100644 --- a/extensions/vscode/src/extension/providers/SidebarProvider.ts +++ b/extensions/vscode/src/extension/providers/SidebarProvider.ts @@ -2,7 +2,7 @@ import { resolveLocale, toHtmlLang } from '../../shared/i18n'; import * as vscode from 'vscode'; import { ConfigPanelFocus } from '../../shared/configUtils'; import { HostToWebview, WebviewToHost } from '../../shared/messages'; -import { FileChange } from '../../shared/types'; +import { FileChange, ReviewMode } from '../../shared/types'; import { CliService } from '../services/CliService'; import { ConfigService } from '../services/ConfigService'; import { GitService } from '../services/GitService'; @@ -13,6 +13,7 @@ export class SidebarProvider implements vscode.WebviewViewProvider { private view?: vscode.WebviewView; private session?: ReviewSession; private openConfigPanel?: (focus?: ConfigPanelFocus) => void; + private gitWatchDisposable?: vscode.Disposable; constructor( private extensionUri: vscode.Uri, @@ -37,6 +38,16 @@ export class SidebarProvider implements vscode.WebviewViewProvider { view.webview.options = { enableScripts: true, localResourceRoots: [this.extensionUri] }; view.webview.html = this.html(view.webview); view.webview.onDidReceiveMessage((msg: WebviewToHost) => this.handle(msg)); + + this.gitWatchDisposable?.dispose(); + this.gitWatchDisposable = this.git.watchWorkspaceChanges((gitState) => { + this.post({ type: 'gitState', gitState }); + }); + view.onDidDispose(() => { + this.gitWatchDisposable?.dispose(); + this.gitWatchDisposable = undefined; + this.view = undefined; + }); } private post(msg: HostToWebview): void { @@ -48,7 +59,7 @@ export class SidebarProvider implements vscode.WebviewViewProvider { switch (msg.type) { case 'ready': { const config = this.config.read(); - const gitState = await this.git.getState('workspace'); + const gitState = await this.git.getState(ReviewMode.Workspace); const locale = resolveLocale(vscode.env.language); this.post({ type: 'init', config, gitState, locale }); break; @@ -59,9 +70,9 @@ export class SidebarProvider implements vscode.WebviewViewProvider { } case 'getModeFiles': { let files: FileChange[] = []; - if (msg.mode === 'branch' && msg.from && msg.to) { + if (msg.mode === ReviewMode.Branch && msg.from && msg.to) { files = await this.git.getBranchDiff(msg.from, msg.to); - } else if (msg.mode === 'commit' && msg.commit) { + } else if (msg.mode === ReviewMode.Commit && msg.commit) { files = await this.git.getCommitFiles(msg.commit); } this.post({ type: 'modeFiles', mode: msg.mode, files }); @@ -75,14 +86,21 @@ export class SidebarProvider implements vscode.WebviewViewProvider { break; case 'startReview': { this.session = new ReviewSession(this.cli, cwd); - // 仅工作区模式在编辑器内放置评论 thread;分支/提交模式代码不在工作区,会错位。 - const inEditor = msg.options.mode === 'workspace'; await this.session.run(msg.options, { onState: (state, error) => this.post({ type: 'stateChange', state, error }), onLog: (line) => this.post({ type: 'logLine', line }), onDone: (result) => { - this.post({ type: 'reviewDone', result }); - if (result.comments.length) this.comments.show(result.comments, inEditor); + void (async () => { + if (result.comments.length) { + await this.comments.show(result.comments, { + mode: msg.options.mode, + from: msg.options.from, + to: msg.options.to, + commit: msg.options.commit, + }); + } + this.post({ type: 'reviewDone', result }); + })(); }, }); break; diff --git a/extensions/vscode/src/extension/providers/__tests__/commentAnchor.test.ts b/extensions/vscode/src/extension/providers/__tests__/commentAnchor.test.ts new file mode 100644 index 0000000..22a14dd --- /dev/null +++ b/extensions/vscode/src/extension/providers/__tests__/commentAnchor.test.ts @@ -0,0 +1,39 @@ +import { + findLinesByExistingCode, + normalizeLine, + resolveLinesInContent, + splitAndNormalize, +} from '../commentAnchor'; + +describe('commentAnchor line resolution', () => { + const content = ['line1', 'for (let i = 0; i <= 30, i++) {', ' console.log(i);', '}', 'line5'].join('\n'); + + it('normalizeLine strips diff markers', () => { + expect(normalizeLine('+added')).toBe('added'); + expect(normalizeLine('-removed')).toBe('removed'); + }); + + it('splitAndNormalize skips blank lines', () => { + expect(splitAndNormalize('a\n\n b ')).toEqual(['a', 'b']); + }); + + it('resolveLinesInContent uses explicit line numbers when in range', () => { + expect(resolveLinesInContent(content, 2, 2)).toEqual({ start: 2, end: 2, relocated: false }); + }); + + it('resolveLinesInContent falls back to existingCode when line out of range', () => { + const code = 'for (let i = 0; i <= 30, i++) {'; + const result = resolveLinesInContent(content, 99, 99, code); + expect(result).toEqual({ start: 2, end: 2, relocated: true }); + }); + + it('findLinesByExistingCode matches consecutive non-blank lines', () => { + const found = findLinesByExistingCode(content, 'console.log(i);'); + expect(found).toEqual({ start: 3, end: 3 }); + }); + + it('returns null when neither line nor existingCode resolves', () => { + expect(resolveLinesInContent(content, 99, 99)).toBeNull(); + expect(resolveLinesInContent(content, 0, 0)).toBeNull(); + }); +}); diff --git a/extensions/vscode/src/extension/providers/commentAnchor.ts b/extensions/vscode/src/extension/providers/commentAnchor.ts new file mode 100644 index 0000000..b18bc2a --- /dev/null +++ b/extensions/vscode/src/extension/providers/commentAnchor.ts @@ -0,0 +1,197 @@ +import * as vscode from 'vscode'; +import { ReviewComment, ReviewContext, ReviewMode } from '../../shared/types'; + +export type CommentMountSide = 'left' | 'right' | 'workspace'; + +/** 可挂载到编辑器 / diff 的定位结果。 */ +export interface MountableCommentAnchor { + kind: 'mountable'; + uri: vscode.Uri; + range: vscode.Range; + side: CommentMountSide; + /** commit/branch 模式跳转时打开原生 diff。 */ + diff?: { + left: vscode.Uri; + right: vscode.Uri; + title: string; + }; + /** 行号经 existingCode 重新定位时附带说明。 */ + locateNote?: string; +} + +/** 无法在快照中定位,仅侧边栏展示。 */ +export interface SidebarOnlyCommentAnchor { + kind: 'sidebar'; + reason: 'binary' | 'unresolved' | 'missing-file'; +} + +export type SidebarOnlyReason = SidebarOnlyCommentAnchor['reason']; + +export type CommentAnchorResult = MountableCommentAnchor | SidebarOnlyCommentAnchor; + +export interface CommentAnchorDeps { + repoRoot: string; + fileStatus: (path: string) => Promise<'added' | 'modified' | 'deleted' | 'renamed' | 'binary' | null>; + readAtRef: (ref: string, path: string) => Promise; + readWorkspace: (path: string) => Promise; + buildDiffUris: ( + path: string, + status: 'added' | 'modified' | 'deleted' | 'renamed' | 'binary', + ) => Promise<{ + left: vscode.Uri; + right: vscode.Uri; + title: string; + mountRef: string; + mountSide: 'left' | 'right'; + leftRef: string | null; + rightRef: string | null; + } | null>; + toGitUri: (path: string, ref: string) => Promise; +} + +export function normalizeLine(s: string): string { + let line = s.trim(); + if (line.startsWith('+') || line.startsWith('-')) line = line.slice(1).trim(); + return line; +} + +export function splitAndNormalize(code: string): string[] { + const result: string[] = []; + for (const raw of code.split('\n')) { + const n = normalizeLine(raw); + if (n) result.push(n); + } + return result; +} + +/** 在文件内容中滑动匹配 existingCode,返回 1-based 行号。 */ +export function findLinesByExistingCode(content: string, existingCode: string): { start: number; end: number } | null { + const targetLines = splitAndNormalize(existingCode); + if (targetLines.length === 0) return null; + + const fileLines = content.split('\n'); + const normalized: string[] = []; + const lineNums: number[] = []; + for (let i = 0; i < fileLines.length; i++) { + const n = normalizeLine(fileLines[i].replace(/\r$/, '')); + if (!n) continue; + normalized.push(n); + lineNums.push(i + 1); + } + if (normalized.length < targetLines.length) return null; + + for (let i = 0; i <= normalized.length - targetLines.length; i++) { + let matched = true; + for (let j = 0; j < targetLines.length; j++) { + if (normalized[i + j] !== targetLines[j]) { + matched = false; + break; + } + } + if (matched) { + return { start: lineNums[i], end: lineNums[i + targetLines.length - 1] }; + } + } + return null; +} + +export function resolveLinesInContent( + content: string, + startLine: number, + endLine: number, + existingCode?: string, +): { start: number; end: number; relocated: boolean } | null { + const lineCount = content.split('\n').length; + const start = startLine > 0 ? startLine : 0; + const end = endLine > 0 ? endLine : start; + + if (start > 0 && end > 0 && start <= lineCount && end <= lineCount && start <= end) { + return { start, end, relocated: false }; + } + + if (existingCode?.trim()) { + const found = findLinesByExistingCode(content, existingCode); + if (found) return { ...found, relocated: true }; + } + return null; +} + +function toRange(start: number, end: number): vscode.Range { + return new vscode.Range(Math.max(0, start - 1), 0, Math.max(0, end - 1), 0); +} + +export async function resolveCommentAnchor( + comment: ReviewComment, + ctx: ReviewContext, + deps: CommentAnchorDeps, +): Promise { + const status = await deps.fileStatus(comment.path); + if (status === 'binary') return { kind: 'sidebar', reason: 'binary' }; + if (status === null && ctx.mode !== ReviewMode.Workspace) { + return { kind: 'sidebar', reason: 'missing-file' }; + } + + const effectiveStatus = status ?? 'modified'; + + if (ctx.mode === ReviewMode.Workspace) { + const content = await deps.readWorkspace(comment.path); + if (content === null) return { kind: 'sidebar', reason: 'missing-file' }; + const lines = resolveLinesInContent(content, comment.startLine, comment.endLine, comment.existingCode); + if (!lines) return { kind: 'sidebar', reason: 'unresolved' }; + const uri = vscode.Uri.file(`${deps.repoRoot}/${comment.path}`); + return { + kind: 'mountable', + uri, + range: toRange(lines.start, lines.end), + side: 'workspace', + locateNote: lines.relocated ? formatLocateNote(comment.startLine, lines.start) : undefined, + }; + } + + const diff = await deps.buildDiffUris(comment.path, effectiveStatus); + if (!diff) return { kind: 'sidebar', reason: 'missing-file' }; + + const primaryRef = diff.mountRef; + const primaryContent = await deps.readAtRef(primaryRef, comment.path); + let lines = primaryContent + ? resolveLinesInContent(primaryContent, comment.startLine, comment.endLine, comment.existingCode) + : null; + + let mountRef = primaryRef; + let mountSide = diff.mountSide; + + if (!lines && effectiveStatus !== 'added') { + const altRef = diff.mountSide === 'right' ? diff.leftRef : diff.rightRef; + const altSide: CommentMountSide = diff.mountSide === 'right' ? 'left' : 'right'; + if (altRef) { + const altContent = await deps.readAtRef(altRef, comment.path); + const altLines = altContent + ? resolveLinesInContent(altContent, comment.startLine, comment.endLine, comment.existingCode) + : null; + if (altLines) { + lines = altLines; + mountRef = altRef; + mountSide = altSide; + } + } + } + + if (!lines) return { kind: 'sidebar', reason: 'unresolved' }; + + const uri = await deps.toGitUri(comment.path, mountRef); + return { + kind: 'mountable', + uri, + range: toRange(lines.start, lines.end), + side: mountSide, + diff: { left: diff.left, right: diff.right, title: diff.title }, + locateNote: lines.relocated ? formatLocateNote(comment.startLine, lines.start) : undefined, + }; +} + +function formatLocateNote(originalLine: number, resolvedLine: number): string { + if (originalLine > 0 && originalLine !== resolvedLine) { + return `⚠ Original line L${originalLine} could not be matched; showing L${resolvedLine} instead.`; + } + return '⚠ Line number was re-located from code content.'; +} diff --git a/extensions/vscode/src/extension/services/GitService.ts b/extensions/vscode/src/extension/services/GitService.ts index a3b29f0..8d4eaef 100644 --- a/extensions/vscode/src/extension/services/GitService.ts +++ b/extensions/vscode/src/extension/services/GitService.ts @@ -1,11 +1,16 @@ import { t, resolveLocale } from '../../shared/i18n'; import * as vscode from 'vscode'; +import { readFile } from 'fs/promises'; import { execFile } from 'child_process'; -import { GitState, CommitInfo, FileChange, ReviewMode } from '../../shared/types'; -import { parsePorcelain, parseNameStatus, pickRepoRoot } from './gitMap'; +import { GitState, FileChange, ReviewMode, ReviewContext } from '../../shared/types'; +import { buildWorkspaceFiles, branchRefCandidates, parseNameStatus, pickRepoRoot } from './gitMap'; + +const WORKSPACE_REFRESH_DEBOUNCE_MS = 300; export class GitService { private api: any | null = null; + private cache: GitState = { branches: [], currentBranch: '', recentCommits: [], workspaceFiles: [] }; + private reviewFileStatus = new Map(); constructor(private log?: vscode.OutputChannel) {} @@ -61,57 +66,155 @@ export class GitService { async getState(mode: ReviewMode): Promise { const empty: GitState = { branches: [], currentBranch: '', recentCommits: [], workspaceFiles: [] }; + + if (mode === ReviewMode.Workspace) { + await this.refreshWorkspaceFiles(); + return { ...this.cache }; + } + const repo = await this.waitForRepo(); if (!repo) { this.trace(`getState(${mode}): no repo`); return empty; } - let currentBranch = ''; try { - currentBranch = repo.state.HEAD?.name || ''; + this.cache.currentBranch = repo.state.HEAD?.name || ''; } catch { /* ignore */ } - let branches: string[] = []; + if (mode === ReviewMode.Branch) { + await this.refreshBranches(repo); + } else if (mode === ReviewMode.Commit) { + await this.refreshRecentCommits(repo); + } + + return { ...this.cache }; + } + + /** + * 订阅 VS Code Git 扩展的仓库状态变化,debounce 后刷新工作区文件列表。 + * 用于侧边栏工作区模式实时反映暂存/工作区/未跟踪变更。 + */ + watchWorkspaceChanges(onUpdate: (state: GitState) => void): vscode.Disposable { + const cleanups: vscode.Disposable[] = []; + let debounceTimer: ReturnType | undefined; + let repoDisposable: vscode.Disposable | undefined; + let cancelled = false; + + const scheduleRefresh = () => { + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + debounceTimer = undefined; + void this.refreshWorkspaceFiles().then(() => { + if (!cancelled) onUpdate({ ...this.cache }); + }); + }, WORKSPACE_REFRESH_DEBOUNCE_MS); + }; + + const attachRepo = (repo: any) => { + repoDisposable?.dispose(); + repoDisposable = repo?.state?.onDidChange?.(scheduleRefresh); + }; + + void this.ensureApi().then(async (api) => { + if (!api || cancelled) return; + + const repo = await this.waitForRepo(); + if (!cancelled && repo) attachRepo(repo); + + if (api.onDidOpenRepository) { + cleanups.push(api.onDidOpenRepository(() => { + if (cancelled) return; + const selected = this.selectRepo(api); + if (selected) attachRepo(selected); + })); + } + }); + + return new vscode.Disposable(() => { + cancelled = true; + if (debounceTimer) clearTimeout(debounceTimer); + repoDisposable?.dispose(); + for (const d of cleanups) d.dispose(); + }); + } + + /** 工作区模式仅刷新变更文件,不等待 VS Code Git 扩展,也不拉分支/提交历史。 */ + private async refreshWorkspaceFiles(): Promise { + const root = await this.repoRootFast(); + if (!root) { + this.cache.workspaceFiles = []; + return; + } + + try { + const [diffHeadOut, untrackedOut] = await Promise.all([ + runGit(root, ['diff', '--name-status', 'HEAD']), + runGit(root, ['ls-files', '--others', '--exclude-standard']), + ]); + let diffCachedOut = ''; + if (!diffHeadOut.trim()) { + diffCachedOut = await runGit(root, ['diff', '--cached', '--name-status']); + } + this.cache.workspaceFiles = buildWorkspaceFiles(diffHeadOut, diffCachedOut, untrackedOut); + this.trace(`refreshWorkspaceFiles: root=${root} files=${this.cache.workspaceFiles.length}`); + } catch (e) { + this.trace(`refreshWorkspaceFiles failed: ${e instanceof Error ? e.message : String(e)}`); + this.cache.workspaceFiles = []; + } + } + + /** 通过 git rev-parse 解析仓库根,避免等待 VS Code Git 扩展初始化。 */ + private async repoRootFast(): Promise { + const ws = vscode.workspace.workspaceFolders?.[0].uri.fsPath; + if (!ws) return null; + try { + const out = await runGit(ws, ['rev-parse', '--show-toplevel']); + const root = out.trim(); + return root || null; + } catch { + return null; + } + } + + private async refreshBranches(repo: any): Promise { try { const refs = await repo.getBranches({ remote: true }); - branches = refs.map((r: any) => r.name).filter(Boolean); - } catch { /* ignore */ } + this.cache.branches = refs.map((r: any) => r.name).filter(Boolean); + } catch { + this.cache.branches = []; + } + } - let recentCommits: CommitInfo[] = []; + private async refreshRecentCommits(repo: any): Promise { try { const commits = await repo.log({ maxEntries: 20 }); - recentCommits = commits.map((c: any) => ({ + this.cache.recentCommits = commits.map((c: any) => ({ sha: c.hash.slice(0, 7), message: c.message.split('\n')[0], relativeTime: formatRelative(c.authorDate), })); - } catch { /* ignore */ } - - // 工作区变更直接走 git status --porcelain,避免依赖扩展懒填充的 state 数组。 - let workspaceFiles: FileChange[] = []; - try { - const root: string = repo.rootUri?.fsPath - ?? vscode.workspace.workspaceFolders?.[0].uri.fsPath - ?? process.cwd(); - const out = await runGit(root, ['status', '--porcelain']); - workspaceFiles = parsePorcelain(out); - this.trace(`getState(${mode}): root=${root} porcelainBytes=${out.length} files=${workspaceFiles.length}`); - } catch (e) { - this.trace(`getState(${mode}): status failed: ${e instanceof Error ? e.message : String(e)}`); + } catch { + this.cache.recentCommits = []; } - - return { branches, currentBranch, recentCommits, workspaceFiles }; } /** 分支对比:merge-base 三点 diff。 */ async getBranchDiff(from: string, to: string): Promise { const root = await this.repoRoot(); if (!root || !from || !to) return []; + + const resolvedFrom = await this.resolveGitRef(root, from); + const resolvedTo = await this.resolveGitRef(root, to); + if (!resolvedFrom || !resolvedTo) { + this.trace(`getBranchDiff: unresolved ref from=${from} to=${to}`); + return []; + } + try { - const out = await runGit(root, ['diff', '--name-status', `${from}...${to}`]); + const out = await runGit(root, ['diff', '--name-status', `${resolvedFrom}...${resolvedTo}`]); const files = parseNameStatus(out); - this.trace(`getBranchDiff(${from}...${to}): files=${files.length}`); + this.trace(`getBranchDiff(${resolvedFrom}...${resolvedTo}): files=${files.length}`); return files; } catch (e) { this.trace(`getBranchDiff failed: ${e instanceof Error ? e.message : String(e)}`); @@ -119,12 +222,22 @@ export class GitService { } } + private async resolveGitRef(root: string, ref: string): Promise { + for (const candidate of branchRefCandidates(ref)) { + try { + const out = await runGit(root, ['rev-parse', '--verify', candidate]); + if (out.trim()) return candidate; + } catch { /* try next candidate */ } + } + return null; + } + /** 单次提交:该 commit 相对父提交的改动文件。 */ async getCommitFiles(sha: string): Promise { const root = await this.repoRoot(); if (!root || !sha) return []; try { - const out = await runGit(root, ['show', '--name-status', '--format=', '--', sha]); + const out = await runGit(root, ['show', '--name-status', '--format=', sha]); const files = parseNameStatus(out); this.trace(`getCommitFiles(${sha}): files=${files.length}`); return files; @@ -136,7 +249,7 @@ export class GitService { private async repoRoot(): Promise { const repo = await this.waitForRepo(); - if (!repo) return null; + if (!repo) return this.repoRootFast(); return repo.rootUri?.fsPath ?? vscode.workspace.workspaceFolders?.[0].uri.fsPath ?? process.cwd(); @@ -153,41 +266,125 @@ export class GitService { const fileUri = vscode.Uri.file(`${root}/${opts.path}`); - // 二进制无法做文本 diff,直接打开文件本身。 if (opts.status === 'binary') { - try { await vscode.window.showTextDocument(fileUri, { preview: true }); } catch { /* ignore */ } + if (opts.mode === ReviewMode.Workspace) { + try { await vscode.window.showTextDocument(fileUri, { preview: true }); } catch { /* ignore */ } + } return; } - // toGitUri(uri, '') 返回空文档,用于新增/删除时缺失的一侧。 - const emptyRef = ''; - let left: vscode.Uri; - let right: vscode.Uri; - let label: string; - - if (opts.mode === 'workspace') { - left = api.toGitUri(fileUri, opts.status === 'added' ? emptyRef : 'HEAD'); - right = opts.status === 'deleted' ? api.toGitUri(fileUri, emptyRef) : fileUri; - label = t(resolveLocale(vscode.env.language), 'ext.git.workspaceVsHead'); - } else if (opts.mode === 'commit' && opts.commit) { - left = api.toGitUri(fileUri, opts.status === 'added' ? emptyRef : `${opts.commit}^`); - right = opts.status === 'deleted' ? api.toGitUri(fileUri, emptyRef) : api.toGitUri(fileUri, opts.commit); - label = `${opts.commit}^ ↔ ${opts.commit}`; - } else if (opts.mode === 'branch' && opts.from && opts.to) { - // 文件列表用三点 diff(merge-base),逐文件 diff 也应以 merge-base 为基准。 - const base = (await this.mergeBase(root, opts.from, opts.to)) || opts.from; - left = api.toGitUri(fileUri, opts.status === 'added' ? emptyRef : base); - right = opts.status === 'deleted' ? api.toGitUri(fileUri, emptyRef) : api.toGitUri(fileUri, opts.to); - label = `${opts.from}...${opts.to}`; - } else { + if (opts.mode === ReviewMode.Workspace) { + const emptyRef = ''; + const left = api.toGitUri(fileUri, opts.status === 'added' ? emptyRef : 'HEAD'); + const right = opts.status === 'deleted' ? api.toGitUri(fileUri, emptyRef) : fileUri; + const label = t(resolveLocale(vscode.env.language), 'ext.git.workspaceVsHead'); + await this.presentDiff(left, right, `${opts.path} (${label})`); return; } - const title = `${opts.path} (${label})`; + if (opts.mode === ReviewMode.Commit && opts.commit) { + const parent = `${opts.commit}^`; + const label = `${opts.commit}^ ↔ ${opts.commit}`; + const leftRef = opts.status === 'added' ? null : parent; + const rightRef = opts.status === 'deleted' ? null : opts.commit; + await this.presentRefDiff(api, root, opts.path, opts.status, leftRef, rightRef, `${opts.path} (${label})`, { + fallbackRange: `${parent}..${opts.commit}`, + }); + return; + } + + if (opts.mode === ReviewMode.Branch && opts.from && opts.to) { + const resolvedFrom = await this.resolveGitRef(root, opts.from); + const resolvedTo = await this.resolveGitRef(root, opts.to); + if (!resolvedFrom || !resolvedTo) return; + const base = (await this.mergeBase(root, resolvedFrom, resolvedTo)) || resolvedFrom; + const label = `${resolvedFrom}...${resolvedTo}`; + const leftRef = opts.status === 'added' ? null : base; + const rightRef = opts.status === 'deleted' ? null : resolvedTo; + await this.presentRefDiff(api, root, opts.path, opts.status, leftRef, rightRef, `${opts.path} (${label})`, { + fallbackRange: `${resolvedFrom}...${resolvedTo}`, + }); + } + } + + /** 分支/提交模式:用 git ref 或空文件构造两侧,走 VS Code 原生 diff 编辑器。 */ + private async presentRefDiff( + api: any, + root: string, + relPath: string, + status: FileChange['status'], + leftRef: string | null, + rightRef: string | null, + title: string, + opts: { fallbackRange: string }, + ): Promise { + const left = await this.resolveDiffSide(api, root, relPath, leftRef, status, 'left'); + const right = await this.resolveDiffSide(api, root, relPath, rightRef, status, 'right'); + const opened = await this.presentDiff(left, right, title); + if (!opened) { + await this.presentPatchDiff(root, opts.fallbackRange, relPath, title); + } + } + + private async resolveDiffSide( + api: any, + root: string, + relPath: string, + ref: string | null, + status: FileChange['status'], + side: 'left' | 'right', + ): Promise { + if (side === 'left' && (status === 'added' || !ref)) return this.emptySideUri(); + if (side === 'right' && (status === 'deleted' || !ref)) return this.emptySideUri(); + if (!ref || !(await this.pathExistsAtRef(root, ref, relPath))) return this.emptySideUri(); + return api.toGitUri(vscode.Uri.file(`${root}/${relPath}`), ref); + } + + /** 空侧占位(等同 /dev/null),用于新增/删除文件的单侧 diff。 */ + private emptySideUri(): vscode.Uri { + return vscode.Uri.file(process.platform === 'win32' ? '\\\\.\\NUL' : '/dev/null'); + } + + private async presentDiff( + left: vscode.Uri, + right: vscode.Uri, + title: string, + ): Promise { try { await vscode.commands.executeCommand('vscode.diff', left, right, title, { preview: true }); + return true; } catch (e) { - this.trace(`openDiff failed: ${e instanceof Error ? e.message : String(e)}`); + this.trace(`presentDiff failed: ${e instanceof Error ? e.message : String(e)}`); + return false; + } + } + + /** 最后兜底:原生 diff 失败时才展示补丁文本。 */ + private async presentPatchDiff( + root: string, + range: string, + relPath: string, + title: string, + ): Promise { + let patch = ''; + try { + patch = await runGit(root, ['diff', range, '--', relPath]); + } catch (e) { + this.trace(`presentPatchDiff failed: ${e instanceof Error ? e.message : String(e)}`); + } + const doc = await vscode.workspace.openTextDocument({ + content: patch || `# ${title}\n\n(no changes)`, + language: 'diff', + }); + await vscode.window.showTextDocument(doc, { preview: true }); + } + + private async pathExistsAtRef(root: string, ref: string, relPath: string): Promise { + try { + await runGit(root, ['cat-file', '-e', `${ref}:${relPath}`]); + return true; + } catch { + return false; } } @@ -199,11 +396,102 @@ export class GitService { return null; } } + + /** 审查开始前缓存 commit/branch 模式下的文件状态,供评论挂载查询。 */ + async prepareReviewFileStatus(ctx: ReviewContext): Promise { + this.reviewFileStatus.clear(); + if (ctx.mode === ReviewMode.Commit && ctx.commit) { + const files = await this.getCommitFiles(ctx.commit); + for (const f of files) this.reviewFileStatus.set(f.path, f.status); + } else if (ctx.mode === ReviewMode.Branch && ctx.from && ctx.to) { + const files = await this.getBranchDiff(ctx.from, ctx.to); + for (const f of files) this.reviewFileStatus.set(f.path, f.status); + } + } + + async getReviewFileStatus(path: string): Promise { + return this.reviewFileStatus.get(path) ?? null; + } + + async readFileAtRef(ref: string, relPath: string): Promise { + const root = await this.repoRoot(); + if (!root) return null; + try { + return await runGit(root, ['show', `${ref}:${relPath}`]); + } catch { + return null; + } + } + + async readWorkspaceFile(relPath: string): Promise { + const root = await this.repoRoot(); + if (!root) return null; + try { + return await readFile(`${root}/${relPath}`, 'utf8'); + } catch { + return null; + } + } + + /** 构造评论挂载用的 diff 两侧 URI 与挂载 ref。 */ + async buildCommentDiffUris( + relPath: string, + status: FileChange['status'], + ctx: ReviewContext, + ): Promise<{ + left: vscode.Uri; + right: vscode.Uri; + title: string; + mountRef: string; + mountSide: 'left' | 'right'; + leftRef: string | null; + rightRef: string | null; + } | null> { + const api = await this.ensureApi(); + const root = await this.repoRoot(); + if (!api || !root) return null; + + if (ctx.mode === ReviewMode.Commit && ctx.commit) { + const parent = `${ctx.commit}^`; + const label = `${ctx.commit}^ ↔ ${ctx.commit}`; + const leftRef = status === 'added' ? null : parent; + const rightRef = status === 'deleted' ? null : ctx.commit; + const mountRef = status === 'deleted' ? parent : ctx.commit; + const mountSide: 'left' | 'right' = status === 'deleted' ? 'left' : 'right'; + const left = await this.resolveDiffSide(api, root, relPath, leftRef, status, 'left'); + const right = await this.resolveDiffSide(api, root, relPath, rightRef, status, 'right'); + return { left, right, title: `${relPath} (${label})`, mountRef, mountSide, leftRef, rightRef }; + } + + if (ctx.mode === ReviewMode.Branch && ctx.from && ctx.to) { + const resolvedFrom = await this.resolveGitRef(root, ctx.from); + const resolvedTo = await this.resolveGitRef(root, ctx.to); + if (!resolvedFrom || !resolvedTo) return null; + const base = (await this.mergeBase(root, resolvedFrom, resolvedTo)) || resolvedFrom; + const label = `${resolvedFrom}...${resolvedTo}`; + const leftRef = status === 'added' ? null : base; + const rightRef = status === 'deleted' ? null : resolvedTo; + const mountRef = status === 'deleted' ? base : resolvedTo; + const mountSide: 'left' | 'right' = status === 'deleted' ? 'left' : 'right'; + const left = await this.resolveDiffSide(api, root, relPath, leftRef, status, 'left'); + const right = await this.resolveDiffSide(api, root, relPath, rightRef, status, 'right'); + return { left, right, title: `${relPath} (${label})`, mountRef, mountSide, leftRef, rightRef }; + } + + return null; + } + + async createGitFileUri(relPath: string, ref: string): Promise { + const api = await this.ensureApi(); + const root = await this.repoRoot(); + if (!api || !root) return null; + return api.toGitUri(vscode.Uri.file(`${root}/${relPath}`), ref); + } } function runGit(cwd: string, args: string[]): Promise { return new Promise((resolve, reject) => { - execFile('git', args, { cwd, maxBuffer: 10 * 1024 * 1024 }, (err, stdout) => { + execFile('git', ['-c', 'core.quotepath=false', ...args], { cwd, maxBuffer: 10 * 1024 * 1024 }, (err, stdout) => { if (err) reject(err); else resolve(stdout); }); diff --git a/extensions/vscode/src/extension/services/__tests__/cliParse.test.ts b/extensions/vscode/src/extension/services/__tests__/cliParse.test.ts index 25145a7..ca8fea1 100644 --- a/extensions/vscode/src/extension/services/__tests__/cliParse.test.ts +++ b/extensions/vscode/src/extension/services/__tests__/cliParse.test.ts @@ -1,28 +1,29 @@ import { buildReviewArgs, extractCliError, parseCliResult, parseLogLine } from '../cliParse'; +import { ReviewMode } from '../../../shared/types'; describe('buildReviewArgs', () => { it('workspace 模式加 --format json', () => { - expect(buildReviewArgs({ mode: 'workspace' })) + expect(buildReviewArgs({ mode: ReviewMode.Workspace })) .toEqual(['review', '--format', 'json']); }); it('branch 模式加 --from/--to', () => { - expect(buildReviewArgs({ mode: 'branch', from: 'main', to: 'dev' })) + expect(buildReviewArgs({ mode: ReviewMode.Branch, from: 'main', to: 'dev' })) .toEqual(['review', '--from', 'main', '--to', 'dev', '--format', 'json']); }); it('commit 模式加 --commit', () => { - expect(buildReviewArgs({ mode: 'commit', commit: 'abc123' })) + expect(buildReviewArgs({ mode: ReviewMode.Commit, commit: 'abc123' })) .toEqual(['review', '--commit', 'abc123', '--format', 'json']); }); it('customPrompt 追加 --background', () => { - expect(buildReviewArgs({ mode: 'workspace', customPrompt: '关注安全' })) + expect(buildReviewArgs({ mode: ReviewMode.Workspace, customPrompt: '关注安全' })) .toEqual(['review', '--format', 'json', '--background', '关注安全']); }); it('concurrency 追加 --concurrency', () => { - expect(buildReviewArgs({ mode: 'workspace', concurrency: 4 })) + expect(buildReviewArgs({ mode: ReviewMode.Workspace, concurrency: 4 })) .toEqual(['review', '--format', 'json', '--concurrency', '4']); }); }); diff --git a/extensions/vscode/src/extension/services/__tests__/gitMap.test.ts b/extensions/vscode/src/extension/services/__tests__/gitMap.test.ts index 3cf15a1..b8eb991 100644 --- a/extensions/vscode/src/extension/services/__tests__/gitMap.test.ts +++ b/extensions/vscode/src/extension/services/__tests__/gitMap.test.ts @@ -1,5 +1,18 @@ // src/extension/services/__tests__/gitMap.test.ts -import { mapStatusCode, parsePorcelain, parseNameStatus, pickRepoRoot } from '../gitMap'; +import { execFile } from 'child_process'; +import path from 'path'; +import { promisify } from 'util'; +import { + buildWorkspaceFiles, + branchRefCandidates, + mapStatusCode, + mergeWorkspaceFiles, + parsePorcelain, + parseNameStatus, + parseUntrackedList, + pickRepoRoot, + unquoteGitPath, +} from '../gitMap'; describe('mapStatusCode', () => { it('VSCode git Status 枚举映射到 FileChange.status', () => { @@ -81,6 +94,82 @@ describe('parseNameStatus', () => { }); }); +describe('buildWorkspaceFiles', () => { + it('合并 diff HEAD 与未跟踪文件', () => { + const files = buildWorkspaceFiles( + 'M\tsrc/a.ts\nA\tsrc/b.ts', + '', + 'src/c.ts\n', + ); + expect(files).toEqual([ + { path: 'src/a.ts', status: 'modified' }, + { path: 'src/b.ts', status: 'added' }, + { path: 'src/c.ts', status: 'added' }, + ]); + }); + + it('diff HEAD 为空时回退 staged', () => { + const files = buildWorkspaceFiles('', 'M\tsrc/staged.ts', ''); + expect(files).toEqual([{ path: 'src/staged.ts', status: 'modified' }]); + }); + + it('按路径去重,已跟踪优先于未跟踪', () => { + const files = buildWorkspaceFiles('M\tsrc/a.ts', '', 'src/a.ts'); + expect(files).toEqual([{ path: 'src/a.ts', status: 'modified' }]); + }); +}); + +describe('parseUntrackedList', () => { + it('解析未跟踪路径并忽略空行', () => { + expect(parseUntrackedList('src/a.ts\n\n src/b.ts \n')).toEqual(['src/a.ts', 'src/b.ts']); + }); +}); + +describe('mergeWorkspaceFiles', () => { + it('合并并去重', () => { + expect(mergeWorkspaceFiles( + [{ path: 'a.ts', status: 'modified' }], + ['b.ts', 'a.ts'], + )).toEqual([ + { path: 'a.ts', status: 'modified' }, + { path: 'b.ts', status: 'added' }, + ]); + }); +}); + +describe('unquoteGitPath', () => { + it('解码 Git quotepath 八进制转义的中文路径', () => { + const quoted = '"\\344\\273\\243\\347\\240\\201\\344\\277\\256\\346\\224\\271\\346\\234\\200\\345\\260\\217\\345\\271\\262\\351\\242\\204\\350\\247\\204\\345\\210\\231.md"'; + expect(unquoteGitPath(quoted)).toBe('代码修改最小干预规则.md'); + }); + + it('普通路径原样返回', () => { + expect(unquoteGitPath('src/a.ts')).toBe('src/a.ts'); + }); +}); + +describe('parseNameStatus', () => { + it('解析 quotepath 转义路径', () => { + expect(parseNameStatus('A\t"\\344\\273\\243\\347\\240\\201.md"')).toEqual([ + { path: '代码.md', status: 'added' }, + ]); + }); +}); + +describe('branchRefCandidates', () => { + it('本地分支名补充 origin/ 前缀', () => { + expect(branchRefCandidates('dev')).toEqual(['dev', 'origin/dev']); + }); + + it('master 回退到 main 候选', () => { + expect(branchRefCandidates('master')).toEqual(['master', 'origin/master', 'main', 'origin/main']); + }); + + it('已是远程引用时不重复拼接', () => { + expect(branchRefCandidates('origin/main')).toEqual(['origin/main']); + }); +}); + describe('pickRepoRoot', () => { const ws = '/Users/lost/tre/copilot-union/code-chat'; @@ -117,3 +206,19 @@ describe('pickRepoRoot', () => { expect(pickRepoRoot(roots, undefined)).toBe('/a/repo'); }); }); + +describe('getCommitFiles: git show revision placement', () => { + const repoRoot = path.resolve(__dirname, '../../../../../..'); + const execGit = (args: string[]) => + promisify(execFile)('git', ['-c', 'core.quotepath=false', ...args], { cwd: repoRoot }) + .then((r) => r.stdout.trim()); + + it('revision 必须在 -- 之前,否则会被当成 pathspec 导致空列表', async () => { + const good = await execGit(['show', '--name-status', '--format=', 'HEAD']); + const bad = await execGit(['show', '--name-status', '--format=', '--', 'HEAD']); + expect(good.length).toBeGreaterThan(0); + expect(bad).toBe(''); + expect(parseNameStatus(good).length).toBeGreaterThan(0); + expect(parseNameStatus(bad)).toEqual([]); + }); +}); diff --git a/extensions/vscode/src/extension/services/cliParse.ts b/extensions/vscode/src/extension/services/cliParse.ts index c219bce..383faa7 100644 --- a/extensions/vscode/src/extension/services/cliParse.ts +++ b/extensions/vscode/src/extension/services/cliParse.ts @@ -1,11 +1,11 @@ -import { CliResult, CliRunOptions, LogLine, ReviewComment } from '../../shared/types'; +import { CliResult, CliRunOptions, LogLine, ReviewComment, ReviewMode } from '../../shared/types'; export function buildReviewArgs(opts: CliRunOptions): string[] { const args: string[] = ['review']; - if (opts.mode === 'branch') { + if (opts.mode === ReviewMode.Branch) { if (opts.from) args.push('--from', opts.from); if (opts.to) args.push('--to', opts.to); - } else if (opts.mode === 'commit') { + } else if (opts.mode === ReviewMode.Commit) { if (opts.commit) args.push('--commit', opts.commit); } args.push('--format', 'json'); diff --git a/extensions/vscode/src/extension/services/gitMap.ts b/extensions/vscode/src/extension/services/gitMap.ts index 0c92b4f..68b3e21 100644 --- a/extensions/vscode/src/extension/services/gitMap.ts +++ b/extensions/vscode/src/extension/services/gitMap.ts @@ -36,6 +36,7 @@ export function parsePorcelain(output: string): FileChange[] { const c = x !== ' ' && x !== '?' ? x : y; code = c; } + path = unquoteGitPath(path); if (seen.has(path)) continue; seen.add(path); files.push({ path, status: mapStatusCode(code) }); @@ -43,6 +44,40 @@ export function parsePorcelain(output: string): FileChange[] { return files; } +/** 解析 `git ls-files --others --exclude-standard` 输出的未跟踪路径列表。 */ +export function parseUntrackedList(output: string): string[] { + return output.split('\n').map((line) => unquoteGitPath(line.trim())).filter(Boolean); +} + +/** 合并已跟踪变更与未跟踪文件,按路径去重。 */ +export function mergeWorkspaceFiles(tracked: FileChange[], untrackedPaths: string[]): FileChange[] { + const files: FileChange[] = []; + const seen = new Set(); + for (const file of tracked) { + if (seen.has(file.path)) continue; + seen.add(file.path); + files.push(file); + } + for (const path of untrackedPaths) { + if (seen.has(path)) continue; + seen.add(path); + files.push({ path, status: 'added' }); + } + return files; +} + +/** + * 从 git diff / ls-files 输出构建工作区文件列表。 + * 与 OCR CLI workspace 模式一致:先 diff HEAD,空则回退 staged,再合并未跟踪文件。 + */ +export function buildWorkspaceFiles(diffHeadOut: string, diffCachedOut: string, untrackedOut: string): FileChange[] { + let tracked = parseNameStatus(diffHeadOut); + if (tracked.length === 0) { + tracked = parseNameStatus(diffCachedOut); + } + return mergeWorkspaceFiles(tracked, parseUntrackedList(untrackedOut)); +} + /** * 从候选仓库根路径中选出与 workspace 匹配的那个。 * VSCode git 扩展异步扫描嵌套仓库,repositories 顺序不稳定,直接取 [0] 会漂移到子仓库。 @@ -63,6 +98,50 @@ export function pickRepoRoot(roots: string[], workspacePath?: string): string | return roots[0]; } +/** 生成用于 rev-parse 验证的分支引用候选列表。 */ +export function branchRefCandidates(ref: string): string[] { + const candidates = [ref]; + if (!ref.includes('/')) { + candidates.push(`origin/${ref}`); + } + if (ref === 'master') { + candidates.push('main', 'origin/main'); + } else if (ref === 'main') { + candidates.push('master', 'origin/master'); + } + return [...new Set(candidates)]; +} + +/** + * 解码 Git quotepath 转义路径(core.quotepath=true 时中文等会显示为 "\344\273...")。 + */ +export function unquoteGitPath(path: string): string { + if (!path.startsWith('"') || !path.endsWith('"')) return path; + + const bytes: number[] = []; + const inner = path.slice(1, -1); + for (let i = 0; i < inner.length; i++) { + if (inner[i] !== '\\' || i + 1 >= inner.length) { + bytes.push(inner.charCodeAt(i)); + continue; + } + if (i + 3 < inner.length && /^\d{3}$/.test(inner.slice(i + 1, i + 4))) { + bytes.push(parseInt(inner.slice(i + 1, i + 4), 8)); + i += 3; + continue; + } + i += 1; + const esc = inner[i]; + if (esc === 'n') bytes.push(0x0a); + else if (esc === 't') bytes.push(0x09); + else if (esc === 'r') bytes.push(0x0d); + else if (esc === '\\') bytes.push(0x5c); + else if (esc === '"') bytes.push(0x22); + else bytes.push(esc.charCodeAt(0)); + } + return Buffer.from(bytes).toString('utf8'); +} + /** * 解析 `git diff --name-status` / `git show --name-status` 输出。 * 每行制表符分隔:statuspath,重命名为 Roldnew(取 new)。 @@ -75,7 +154,7 @@ export function parseNameStatus(output: string): FileChange[] { const parts = rawLine.split('\t'); if (parts.length < 2) continue; const codeChar = parts[0][0]; - const path = parts.length >= 3 ? parts[parts.length - 1] : parts[1]; + const path = unquoteGitPath(parts.length >= 3 ? parts[parts.length - 1] : parts[1]); if (seen.has(path)) continue; seen.add(path); files.push({ path, status: mapStatusCode(codeChar) }); diff --git a/extensions/vscode/src/shared/i18n.ts b/extensions/vscode/src/shared/i18n.ts index 346af78..6086566 100644 --- a/extensions/vscode/src/shared/i18n.ts +++ b/extensions/vscode/src/shared/i18n.ts @@ -153,6 +153,9 @@ const messages: Record> = { 'ext.comment.statusFalsePositive': '✅ [False Positive]', 'ext.comment.jumpFailed': 'Cannot locate ', 'ext.comment.jumpNotAFile': ': is not an openable file.', + 'ext.comment.jumpLineUnresolved': 'Cannot jump to {path}: line number could not be resolved.', + 'ext.comment.jumpFileMissing': 'Cannot jump to {path}: file not found in the review snapshot.', + 'ext.comment.applyWorkspaceOnly': 'Apply is only available in Workspace review mode.', 'ext.deleteProviderConfirm': 'Delete custom provider "{name}"?', 'ext.deleteProviderConfirmBtn': 'Delete', 'ext.git.justNow': 'just now', @@ -295,6 +298,9 @@ const messages: Record> = { 'ext.comment.statusFalsePositive': '✅ [已误报]', 'ext.comment.jumpFailed': '无法定位到 ', 'ext.comment.jumpNotAFile': ':该路径不是可打开的文件。', + 'ext.comment.jumpLineUnresolved': '无法跳转到 {path}:未能解析行号。', + 'ext.comment.jumpFileMissing': '无法跳转到 {path}:在审查快照中找不到该文件。', + 'ext.comment.applyWorkspaceOnly': '仅工作区审查模式支持应用建议。', 'ext.deleteProviderConfirm': '确定删除自定义 Provider「{name}」?', 'ext.deleteProviderConfirmBtn': '删除', 'ext.git.justNow': '刚刚', diff --git a/extensions/vscode/src/shared/types.ts b/extensions/vscode/src/shared/types.ts index b6e54ac..8219bbd 100644 --- a/extensions/vscode/src/shared/types.ts +++ b/extensions/vscode/src/shared/types.ts @@ -1,4 +1,8 @@ -export type ReviewMode = 'workspace' | 'branch' | 'commit'; +export enum ReviewMode { + Workspace = 'workspace', + Branch = 'branch', + Commit = 'commit', +} export type ReviewState = | 'idle' | 'running' | 'done' | 'empty' | 'cancelled' | 'failed'; @@ -105,7 +109,11 @@ export interface CliRunOptions { concurrency?: number; } +/** 审查完成后的评论挂载上下文(与 CliRunOptions 字段一致)。 */ +export type ReviewContext = Pick; + export interface CommentSyncState { index: number; status: CommentStatus; + jumpable?: boolean; } diff --git a/extensions/vscode/src/webview/App.tsx b/extensions/vscode/src/webview/App.tsx index caebc28..2291095 100644 --- a/extensions/vscode/src/webview/App.tsx +++ b/extensions/vscode/src/webview/App.tsx @@ -55,14 +55,14 @@ export function App() {
{state.view === 'running' && bridge.post({ type: 'cancelReview' })} />} {state.view === 'done' && state.session.result && ( - bridge.post({ type: 'jumpToComment', index: i })} onAction={(i, action) => bridge.post({ type: 'commentAction', index: i, action })} /> )} {state.view === 'empty' && } {state.view === 'cancelled' && } - {state.view === 'failed' && start({ mode: 'workspace' })} />} + {state.view === 'failed' && start({ mode: ReviewMode.Workspace })} />}
)} 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/__tests__/store.test.ts b/extensions/vscode/src/webview/__tests__/store.test.ts index 85f022c..c5171e8 100644 --- a/extensions/vscode/src/webview/__tests__/store.test.ts +++ b/extensions/vscode/src/webview/__tests__/store.test.ts @@ -1,4 +1,5 @@ import { describeActiveProvider, isConfigReady } from '../../shared/configUtils'; +import { ReviewMode } from '../../shared/types'; import { initialState, reducer } from '../store'; const baseConfig = { @@ -150,7 +151,7 @@ describe('modeFiles 消息', () => { it('保存 mode 对应文件列表', () => { const next = reducer(initialState, { type: 'modeFiles', - mode: 'branch', + mode: ReviewMode.Branch, files: [{ path: 'src/a.ts', status: 'modified' }], }); expect(next.modeFiles).toEqual([{ path: 'src/a.ts', status: 'modified' }]); diff --git a/extensions/vscode/src/webview/components/CommentCard.tsx b/extensions/vscode/src/webview/components/CommentCard.tsx index 2e6b9d2..680d2a9 100644 --- a/extensions/vscode/src/webview/components/CommentCard.tsx +++ b/extensions/vscode/src/webview/components/CommentCard.tsx @@ -12,16 +12,24 @@ interface Props { export function CommentCard({ comment, index, status, canJump, onOpen, onAction }: Props) { const t = useT(); + const open = () => onOpen(index); return (
-
+
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(); } } : undefined} + role={canJump ? 'button' : undefined} + tabIndex={canJump ? 0 : undefined} + title={canJump ? t('cmp.comment.view') : undefined} + > {comment.path} L{comment.startLine}
{comment.content}
- {canJump && } - + {canJump && } +
); diff --git a/extensions/vscode/src/webview/store.ts b/extensions/vscode/src/webview/store.ts index d9e6493..46c0209 100644 --- a/extensions/vscode/src/webview/store.ts +++ b/extensions/vscode/src/webview/store.ts @@ -13,6 +13,7 @@ export interface AppState { logs: LogLine[]; session: { state: ReviewState; result: CliResult | null; error?: string }; commentStatus: Record; + commentJumpable: Record; reviewMode: ReviewMode; locale: SupportedLocale; } @@ -26,7 +27,8 @@ export const initialState: AppState = { logs: [], session: { state: 'idle', result: null }, commentStatus: {}, - reviewMode: 'workspace', + commentJumpable: {}, + reviewMode: ReviewMode.Workspace, locale: 'en', }; @@ -66,18 +68,30 @@ export function reducer(state: AppState, msg: HostToWebview | LocalAction): AppS ...state, logs: starting ? [] : state.logs, commentStatus: starting ? {} : state.commentStatus, + commentJumpable: starting ? {} : state.commentJumpable, session: { state: msg.state, result: starting ? null : state.session.result, error: msg.error }, view: STATE_TO_VIEW[msg.state], }; } case 'logLine': return { ...state, logs: [...state.logs, msg.line] }; - case 'reviewDone': - return { ...state, session: { ...state.session, result: msg.result } }; + case 'reviewDone': { + const commentJumpable: Record = {}; + msg.result.comments.forEach((_, i) => { commentJumpable[i] = true; }); + return { + ...state, + session: { ...state.session, result: msg.result }, + commentJumpable, + }; + } case 'commentSync': { const commentStatus = { ...state.commentStatus }; - for (const c of msg.comments) commentStatus[c.index] = c.status; - return { ...state, commentStatus }; + const commentJumpable = { ...state.commentJumpable }; + for (const c of msg.comments) { + commentStatus[c.index] = c.status; + if (c.jumpable !== undefined) commentJumpable[c.index] = c.jumpable; + } + return { ...state, commentStatus, commentJumpable }; } default: return state; diff --git a/extensions/vscode/src/webview/styles/global.css b/extensions/vscode/src/webview/styles/global.css index 78f8dac..06a5e67 100644 --- a/extensions/vscode/src/webview/styles/global.css +++ b/extensions/vscode/src/webview/styles/global.css @@ -824,6 +824,8 @@ body { margin-bottom: 10px; transition: opacity 0.3s, max-height 0.3s, padding 0.3s, margin 0.3s; max-height: 400px; + display: flex; + flex-direction: column; overflow: hidden; } @@ -842,6 +844,18 @@ body { flex-wrap: wrap; } +.comment-header.jumpable { + cursor: pointer; + border-radius: 6px; + margin: -4px -6px 4px; + padding: 4px 6px; + transition: background 0.15s; +} + +.comment-header.jumpable:hover { + background: var(--card-quiet); +} + .comment-file { font-family: var(--font-mono); font-size: 11.5px; @@ -886,11 +900,15 @@ body { line-height: 1.6; color: var(--ink); margin-bottom: 10px; + flex: 1; + min-height: 0; + overflow-y: auto; } .comment-actions { display: flex; gap: 6px; + flex-shrink: 0; } .comment-actions button { diff --git a/extensions/vscode/src/webview/views/ConfigView.tsx b/extensions/vscode/src/webview/views/ConfigView.tsx index 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/DoneView.tsx b/extensions/vscode/src/webview/views/DoneView.tsx index 4a408c9..c862753 100644 --- a/extensions/vscode/src/webview/views/DoneView.tsx +++ b/extensions/vscode/src/webview/views/DoneView.tsx @@ -7,13 +7,13 @@ import { useT } from '../I18nProvider'; interface Props { result: CliResult; commentStatus: Record; + commentJumpable: Record; logs: LogLine[]; - canJump: boolean; onOpen: (index: number) => void; onAction: (index: number, action: 'apply' | 'discard' | 'falsePositive') => void; } -export function DoneView({ result, commentStatus, logs, canJump, onOpen, onAction }: Props) { +export function DoneView({ result, commentStatus, commentJumpable, logs, onOpen, onAction }: Props) { const [showLogs, setShowLogs] = useState(false); const t = useT(); const s = result.summary; @@ -35,7 +35,8 @@ export function DoneView({ result, commentStatus, logs, canJump, onOpen, onActio )} {result.comments.map((c, i) => ( - ))}
diff --git a/extensions/vscode/src/webview/views/IdleView.tsx b/extensions/vscode/src/webview/views/IdleView.tsx index 7de4974..0e810f2 100644 --- a/extensions/vscode/src/webview/views/IdleView.tsx +++ b/extensions/vscode/src/webview/views/IdleView.tsx @@ -20,7 +20,7 @@ interface Props { } export function IdleView({ gitState, modeFiles, filesLoading, configured, onModeChange, onRequestModeFiles, onOpenFile, onStart, onOpenConfig, onOpenCustomProviders, running }: Props) { - const [mode, setMode] = useState('workspace'); + const [mode, setMode] = useState(ReviewMode.Workspace); const [from, setFrom] = useState(''); const [to, setTo] = useState(''); const [commit, setCommit] = useState(''); @@ -31,7 +31,7 @@ export function IdleView({ gitState, modeFiles, filesLoading, configured, onMode if (!configured) return t('view.idle.configFirst'); if (running) return t('view.idle.reviewing'); if (!selectionReady) { - return mode === 'branch' ? t('view.idle.selectBranch') : t('view.idle.selectCommit'); + return mode === ReviewMode.Branch ? t('view.idle.selectBranch') : t('view.idle.selectCommit'); } if (files.length === 0) return t('view.idle.noFiles'); return t('view.idle.reviewAll'); @@ -41,21 +41,21 @@ export function IdleView({ gitState, modeFiles, filesLoading, configured, onMode // 分支两端都选好后,拉取 diff 文件列表 useEffect(() => { - if (mode === 'branch' && from && to) onRequestModeFiles('branch', from, to); + if (mode === ReviewMode.Branch && from && to) onRequestModeFiles(ReviewMode.Branch, from, to); }, [mode, from, to]); // 选中某 commit 后,拉取该 commit 文件列表 useEffect(() => { - if (mode === 'commit' && commit) onRequestModeFiles('commit', undefined, undefined, commit); + if (mode === ReviewMode.Commit && commit) onRequestModeFiles(ReviewMode.Commit, undefined, undefined, commit); }, [mode, commit]); - const files = mode === 'workspace' ? gitState.workspaceFiles : modeFiles; + const files = mode === ReviewMode.Workspace ? gitState.workspaceFiles : modeFiles; // 仅在「确实发起了请求」时显示 loading:分支需选满两端,提交需选中 commit。 - const willRequest = mode === 'workspace' || (mode === 'branch' && !!from && !!to) || (mode === 'commit' && !!commit); + const willRequest = mode === ReviewMode.Workspace || (mode === ReviewMode.Branch && !!from && !!to) || (mode === ReviewMode.Commit && !!commit); const loading = filesLoading && willRequest; // 可发起审查的前置条件:按 tab 校验选择已就绪,且有待审查文件、不在加载/审查中。 const selectionReady = - mode === 'workspace' || (mode === 'branch' && !!from && !!to) || (mode === 'commit' && !!commit); + mode === ReviewMode.Workspace || (mode === ReviewMode.Branch && !!from && !!to) || (mode === ReviewMode.Commit && !!commit); const canReview = configured && !running && !loading && selectionReady && files.length > 0; const primaryDisabled = configured ? !canReview : running || loading; @@ -70,14 +70,14 @@ export function IdleView({ gitState, modeFiles, filesLoading, configured, onMode return (
- {(['workspace', 'branch', 'commit'] as ReviewMode[]).map((m) => ( + {([ReviewMode.Workspace, ReviewMode.Branch, ReviewMode.Commit]).map((m) => ( ))}
- {mode === 'branch' && ( + {mode === ReviewMode.Branch && (
{t('view.idle.baseRef')}
setSearchQuery(e.target.value)} + onKeyDown={handleSearchKeyDown} + placeholder={t('blog.search.placeholder')} + style={{ + flex: 1, + marginLeft: 12, + background: 'transparent', + border: 'none', + outline: 'none', + color: '#ffffff', + fontSize: 14, + fontFamily, + }} + /> +
+
+ {searchQuery && searchResults.length === 0 && ( +
+ {t('blog.search.noResults')} +
+ )} + {searchResults.map((result, idx) => ( + + ))} +
+
+
+ )} +
+ ); +}; + +export default BlogPage; diff --git a/pages/src/pages/DocsPage.tsx b/pages/src/pages/DocsPage.tsx index 7b37ca3..2a46056 100644 --- a/pages/src/pages/DocsPage.tsx +++ b/pages/src/pages/DocsPage.tsx @@ -1,4 +1,5 @@ import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; import { useTranslation } from '../i18n'; import Navbar from '../components/Navbar'; import Footer from '../components/Footer'; @@ -109,9 +110,24 @@ function buildFlatDocList(): { slug: DocSlug; labelKey: string }[] { } const flatDocList = buildFlatDocList(); +const validSlugs = new Set(flatDocList.map(d => d.slug)); + +/* Dev-time invariant: every sidebar slug maps to exactly one URL, so duplicates + * (two menu entries sharing a slug) would silently collide. Fail loudly in dev. */ +if (process.env.NODE_ENV !== 'production' && validSlugs.size !== flatDocList.length) { + const slugs = flatDocList.map(d => d.slug); + const dupes = [...new Set(slugs.filter((s, i) => slugs.indexOf(s) !== i))]; + throw new Error( + `[docs] Duplicate sidebar slug(s) detected: ${dupes.join(', ')} — each doc must have a unique slug for routing.` + ); +} const DocsPage: React.FC = () => { - const [activeSlug, setActiveSlug] = useState('quickstart'); + const { slug: slugParam } = useParams<{ slug?: string }>(); + const navigate = useNavigate(); + /* Active doc slug is derived from the URL param, falling back to quickstart */ + const activeSlug: DocSlug = + slugParam && validSlugs.has(slugParam as DocSlug) ? (slugParam as DocSlug) : 'quickstart'; const [expandedItems, setExpandedItems] = useState>({ 'sb-integrations': false }); const [activeHeadingId, setActiveHeadingId] = useState(''); const [hoveredHeadingId, setHoveredHeadingId] = useState(''); @@ -162,10 +178,10 @@ const DocsPage: React.FC = () => { }, []); const navigateToDoc = useCallback((slug: DocSlug) => { - setActiveSlug(slug); + navigate(`/docs/${slug}`); // Scroll page to top window.scrollTo(0, 0); - }, []); + }, [navigate]); /* Intercept clicks on internal doc links and convert to SPA navigation */ const handleContentClick = useCallback((e: React.MouseEvent) => { @@ -194,8 +210,7 @@ const DocsPage: React.FC = () => { const slugMap: Record = { 'ci': 'cicd' }; const slug = (slugMap[lastSegment] || lastSegment) as DocSlug; // Verify it's a valid doc slug - const validSlugs = flatDocList.map(d => d.slug); - if (validSlugs.includes(slug)) { + if (validSlugs.has(slug)) { e.preventDefault(); navigateToDoc(slug); // Handle anchor scroll after navigation with reliable retry diff --git a/pages/src/styles/blog.css b/pages/src/styles/blog.css new file mode 100644 index 0000000..6bec8c2 --- /dev/null +++ b/pages/src/styles/blog.css @@ -0,0 +1,131 @@ +/* Blog-specific styles */ + +.blog-card { + display: flex; + flex-direction: column; + gap: 12px; + padding: 24px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 12px; + background: rgba(255, 255, 255, 0.02); + cursor: pointer; + transition: background 0.2s, border-color 0.2s; +} + +.blog-card:hover { + background: rgba(255, 255, 255, 0.05); + border-color: rgba(255, 255, 255, 0.15); +} + +.blog-card-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 16px; +} + +.blog-card-title { + font-size: 18px; + font-weight: 600; + color: #ffffff; + line-height: 26px; + margin: 0; +} + +.blog-card-date { + font-size: 13px; + color: rgba(255, 255, 255, 0.4); + white-space: nowrap; + flex-shrink: 0; +} + +.blog-card-summary { + font-size: 14px; + color: rgba(255, 255, 255, 0.6); + line-height: 22px; + margin: 0; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.blog-card-footer { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; +} + +.blog-card-tags { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.blog-card-author { + font-size: 13px; + color: rgba(255, 255, 255, 0.4); + flex-shrink: 0; +} + +/* Tag pills */ +.blog-tag { + display: inline-flex; + align-items: center; + padding: 2px 10px; + border-radius: 12px; + font-size: 12px; + line-height: 20px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: transparent; + color: rgba(255, 255, 255, 0.5); + cursor: pointer; + transition: all 0.15s; +} + +.blog-tag:hover { + border-color: rgba(43, 222, 94, 0.4); + color: rgba(43, 222, 94, 0.8); +} + +.blog-tag--active { + border-color: rgba(43, 222, 94, 0.6); + background: rgba(43, 222, 94, 0.1); + color: #2BDE5E; +} + +/* Blog detail metadata */ +.blog-detail-meta { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 12px; + margin-bottom: 32px; + font-size: 14px; + color: rgba(255, 255, 255, 0.5); +} + +.blog-detail-meta-separator { + width: 3px; + height: 3px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.3); +} + +.blog-detail-tags { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.blog-detail-tag { + display: inline-flex; + align-items: center; + padding: 1px 8px; + border-radius: 10px; + font-size: 12px; + line-height: 18px; + border: 1px solid rgba(255, 255, 255, 0.12); + color: rgba(255, 255, 255, 0.5); +} diff --git a/pages/src/utils/headingId.ts b/pages/src/utils/headingId.ts index 0a87854..779ffd7 100644 --- a/pages/src/utils/headingId.ts +++ b/pages/src/utils/headingId.ts @@ -1,10 +1,16 @@ +import DOMPurify from 'dompurify'; + /** * Shared utility to generate heading IDs from text. * Used by both extractHeadings (DocsPage TOC) and MarkdownRenderer (heading renderer) * to ensure consistent anchor IDs. */ export function generateHeadingId(text: string): string { - // Strip HTML tags first (from marked output), then strip markdown formatting chars - const plain = text.replace(/<[^>]+>/g, '').replace(/[`*_\[\]()]/g, '').trim(); + // Strip HTML tags via DOMPurify (a single-pass regex is unreliable and can be + // bypassed by nested tags). Keeps text content, decodes HTML entities so the + // TOC side (raw markdown) and renderer side (marked HTML output) agree. + const plain = DOMPurify.sanitize(text, { ALLOWED_TAGS: [], ALLOWED_ATTR: [] }) + .replace(/[`*_\[\]()]/g, '') + .trim(); return plain.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fff]+/g, '-').replace(/^-|-$/g, ''); } diff --git a/pages/webpack.config.js b/pages/webpack.config.js index 2228fb2..55e8b9f 100644 --- a/pages/webpack.config.js +++ b/pages/webpack.config.js @@ -1,5 +1,6 @@ const path = require('path'); const HtmlWebpackPlugin = require('html-webpack-plugin'); +const CopyPlugin = require('copy-webpack-plugin'); module.exports = { mode: process.env.NODE_ENV === 'production' ? 'production' : 'development', @@ -50,7 +51,10 @@ module.exports = { port: 3030, host: '0.0.0.0', allowedHosts: 'all', - static: { directory: __dirname }, + static: [ + { directory: path.resolve(__dirname, 'public') }, + { directory: __dirname } + ], historyApiFallback: { index: '/index.html', rewrites: [{ from: /^\/\_p\/\d+\//, to: '/index.html' }] @@ -60,6 +64,11 @@ module.exports = { new HtmlWebpackPlugin({ template: './index.html', inject: 'body' + }), + new CopyPlugin({ + patterns: [ + { from: 'public', to: '.', noErrorOnMissing: true } + ] }) ] }; diff --git a/plugins/open-code-review/commands/review.md b/plugins/open-code-review/commands/review.md index 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);