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/deploy-pages.yml b/.github/workflows/deploy-pages.yml index e89f248..4eb0d6b 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -20,7 +20,7 @@ jobs: build: runs-on: self-hosted container: - image: node:20 + image: node:24 steps: - uses: actions/checkout@v4 @@ -51,7 +51,7 @@ jobs: url: ${{ steps.deployment.outputs.page_url }} runs-on: self-hosted container: - image: node:20 + image: node:24 needs: build steps: - name: Deploy to GitHub Pages diff --git a/.github/workflows/ocr-review.yml b/.github/workflows/ocr-review.yml index e6bee99..1bb8775 100644 --- a/.github/workflows/ocr-review.yml +++ b/.github/workflows/ocr-review.yml @@ -1,46 +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). +# 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 @@ -62,445 +35,30 @@ permissions: jobs: code-review: runs-on: self-hosted + timeout-minutes: 30 container: - image: node:20 + 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 path = '/tmp/ocr-result.json'; - - // 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) { - const body = formatComment(comment); - - // 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, body }); - continue; - } - - const reviewComment = { - path: comment.path, - body: body - }; - - // Use line range if available - 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 }); - } - - // 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); - - // Statistics tracking - let successCount = 0; - let failedCount = 0; - const failedComments = []; - - 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(({ reviewComment }) => reviewComment) - }); - 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('Falling back to posting comments individually with rate-limit-aware retry...'); - - // Fallback: post comments one by one with delay to avoid secondary rate limits. - // GitHub enforces ~80 content-generating requests per minute; spacing calls - // helps stay under that threshold. Retry/wait durations are derived from the - // rate-limit response headers per GitHub's documented strategy. - const MAX_RETRIES = parseInt(process.env.OCR_MAX_RETRIES, 10) || 3; - const SUCCESS_DELAY = parseInt(process.env.OCR_SUCCESS_DELAY, 10) || 2000; // delay after successful post - const FAILURE_DELAY = parseInt(process.env.OCR_FAILURE_DELAY, 10) || 1000; // delay after non-retryable failure - const LOW_REMAINING_THRESHOLD = parseInt(process.env.OCR_LOW_REMAINING_THRESHOLD, 10) || 3; - const LOW_REMAINING_SPACING = parseInt(process.env.OCR_LOW_REMAINING_SPACING, 10) || 10000; - - // 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 { comment, reviewComment } of reviewComments) { - 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: [reviewComment] - }); - successCount++; - posted = true; - console.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}`); - 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; - if (willRetry) { - const secs = (retryInfo.delayMs / 1000).toFixed(1); - console.log( - `Rate-limited/transient error 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 { - 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 ${reviewComment.path} (${reason}, HTTP ${innerE.status || 'n/a'}): ${innerE.message}`); - // After exhausting retries use the success-style pace delay; - // for other errors use the shorter failure pace delay. - await sleep(retryInfo == null ? FAILURE_DELAY : SUCCESS_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); - } - } - - 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)); - } - - // 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; } - } - - function formatComment(comment) { - let body = comment.content || ''; - - // Add code suggestion if available - 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 e11ee7f..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: @@ -160,7 +160,7 @@ jobs: needs: release runs-on: self-hosted container: - image: node:20 + image: node:24 permissions: contents: read steps: diff --git a/README.ja-JP.md b/README.ja-JP.md index 2e9d8c3..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 @@ -92,6 +91,10 @@ Open Code Reviewのコア哲学は、決定論的エンジニアリングとエ ## 使い方 +### 前提条件 + +- **Git >= 2.41** — Open Code Review は diff 生成、コード検索、リポジトリ操作に Git を利用します。 + ### CLI #### インストール @@ -104,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): @@ -265,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 # ディレクトリまたは特定のファイルをスキャン @@ -400,10 +419,35 @@ 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/) ディレクトリを参照してください。 ## コマンド @@ -418,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` | — | バージョン情報を表示 | @@ -431,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` を尊重するファイルシステムウォークにフォールバック)。 @@ -487,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 @@ -494,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 @@ -748,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 06302f1..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 @@ -92,6 +91,10 @@ agent의 강점은 동적 판단과 동적 context 검색이 중요한 지점에 ## 사용 방법 +### 사전 요구 사항 + +- **Git >= 2.41** — Open Code Review는 diff 생성, 코드 검색, 저장소 작업에 Git을 사용합니다. + ### CLI #### 설치 @@ -104,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): @@ -265,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 # 디렉터리 또는 특정 파일 스캔 @@ -400,10 +419,35 @@ 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 @@ -418,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 정보 표시 | @@ -431,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`를 따르는 파일 시스템 탐색으로 폴백). @@ -487,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 @@ -494,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 @@ -705,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 8910699..3ed3d78 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,6 @@

npm Build status - Go Report Card License Ask DeepWiki OpenSSF Best Practices @@ -92,6 +91,10 @@ The agent's strengths are concentrated where they matter most — dynamic decisi ## How to Use +### Prerequisites + +- **Git >= 2.41** — Open Code Review relies on Git for diff generation, code search, and repository operations. + ### CLI #### Install @@ -104,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): @@ -265,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 @@ -402,10 +421,35 @@ 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 @@ -420,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 | @@ -433,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 @@ -492,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 @@ -499,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 @@ -753,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 7034883..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 @@ -92,6 +91,10 @@ Open Code Review — это CLI-инструмент для код-ревью н ## Как использовать +### Предварительные требования + +- **Git >= 2.41** — Open Code Review использует Git для генерации diff, поиска по коду и операций с репозиторием. + ### CLI #### Установка @@ -104,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): @@ -265,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 # сканировать каталог или конкретные файлы @@ -402,10 +421,35 @@ 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/). ## Команды @@ -420,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` | — | Показать информацию о версии | @@ -433,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`). @@ -489,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 @@ -496,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 @@ -750,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 83524ea..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 @@ -92,6 +91,10 @@ Open Code Review 的核心设计理念是将确定性工程与 Agent 结合, ## 如何使用 +### 前置条件 + +- **Git >= 2.41** — Open Code Review 依赖 Git 进行 diff 生成、代码搜索和仓库操作。 + ### CLI #### 安装 @@ -104,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): @@ -265,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 # 扫描指定目录或文件 @@ -400,10 +419,35 @@ 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/) 目录。 ## 命令 @@ -418,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` | — | 显示版本信息 | @@ -431,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` 的文件系统遍历)。 @@ -487,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 @@ -494,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 @@ -738,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/README.md b/examples/README.md index 8a4caea..2ffc3ff 100644 --- a/examples/README.md +++ b/examples/README.md @@ -6,5 +6,6 @@ This directory contains examples for integrating OpenCodeReview (OCR) into vario - **[github_actions/](./github_actions/)** - GitHub Actions integration example - **[gitlab_ci/](./gitlab_ci/)** - GitLab CI integration example +- **[gitflic_ci/](./gitflic_ci/)** - GitFlic CI integration example -Each subdirectory contains its own README with detailed setup instructions. \ No newline at end of file +Each subdirectory contains its own README with detailed setup instructions. diff --git a/examples/gitflic_ci/README.md b/examples/gitflic_ci/README.md new file mode 100644 index 0000000..9f36ce7 --- /dev/null +++ b/examples/gitflic_ci/README.md @@ -0,0 +1,80 @@ +# OpenCodeReview - GitFlic CI Demo + +This demo shows how to integrate OpenCodeReview into a [GitFlic](https://gitflic.ru) CI/CD pipeline to automatically review Merge Requests and post the findings as MR discussions — inline on the changed lines where possible. + +Like the GitHub Actions and GitLab CI examples, the posting glue lives in the CI layer rather than in the `ocr` binary. Here it is a small, dependency-free Python script — [`post_review.py`](post_review.py) — that reads `ocr review --format json` and posts to the GitFlic Discussions API. The only GitFlic-specific wrinkle it handles is the **old-side line**: GitFlic requires it even for a comment on the new side of the diff, and `ocr review` reports new-side positions only, so the script recomputes it from the same merge-base diff the review ran on. + +## How It Works + +``` +MR Created/Updated → Merge Request Pipeline → ocr review → post_review.py → Discussions on MR +``` + +1. A Merge Request Pipeline triggers the `code-review` job +2. It installs OCR via npm in a `node:20` image (which also ships `python3` and `git`) +3. Runs `ocr review --from origin/ --to $CI_COMMIT_SHA --format json --audience agent` +4. Runs `python3 post_review.py`, which reads the JSON and posts: + - **Inline discussions** on the changed lines (`POST .../discussions/create` with `newLine`/`oldLine`/`newPath`/`oldPath`) + - **A fallback note** collecting comments that could not be placed inline + - **A summary note** with the totals + +The MR context (owner, project, MR id, branch refs) is picked up automatically from the predefined GitFlic CI variables (`CI_PROJECT_NAMESPACE`, `CI_PROJECT_NAME`, `CI_MERGE_REQUEST_LOCAL_ID`, `CI_MERGE_REQUEST_TARGET_BRANCH_NAME`, `CI_COMMIT_SHA`), so `post_review.py` needs no arguments in CI. Outside CI every value can be passed via flags — run `python3 post_review.py -h`. + +## Setup + +### 1. Enable Merge Request Pipelines + +Go to **Project Settings → CI/CD Settings** and enable **Merge Request Pipeline**. New merge requests will then trigger the pipeline automatically. + +### 2. Copy the pipeline files + +Copy **both** `gitflic-ci.yaml` (GitFlic expects this exact file name at the repository root) and `post_review.py` into your repository. If you keep `post_review.py` somewhere other than the repo root, adjust the `python3 post_review.py` path in `gitflic-ci.yaml` accordingly. + +### 3. Configure CI/CD Variables + +Go to **Settings → CI/CD → Variables** and add: + +| Variable | Required | Description | +|----------|----------|-------------| +| `OCR_LLM_URL` | Yes | LLM API endpoint URL | +| `OCR_LLM_AUTH_TOKEN` | Yes | LLM API authentication token | +| `GITFLIC_TOKEN` | Yes | GitFlic access token used to post discussions | +| `OCR_LLM_MODEL` | No | Model name (e.g., `gpt-4o`) | +| `GITFLIC_API_URL` | No | REST API base URL for self-hosted GitFlic (default: `https://api.gitflic.ru`) | + +> **Note:** GitFlic CI/CD does not accept variables with values shorter than 8 characters, so `use_anthropic` cannot be set as a CI variable. The pipeline sets it to `false`; to use Anthropic Claude models, edit `gitflic-ci.yaml` directly. + +### 4. Create a GitFlic Access Token + +Create a token in **User Settings → Access Tokens** (or a dedicated service account — its name becomes the bot name shown in discussions) and store it in the `GITFLIC_TOKEN` variable. The token owner must have access to the project sufficient for commenting on merge requests. + +## Notes & Limitations + +- **Inline positioning** — GitFlic requires all four of `newLine`/`oldLine`/`newPath`/`oldPath` for a code comment; if any is missing it silently creates a general comment. `post_review.py` computes the old-side position from the same merge-base diff the review ran on (`git diff merge-base(from, to)..to`), and anchors added lines to the closest preceding old line. +- **Rate limit** — the GitFlic cloud API allows 500 requests/hour per token. One review posts `comments + 2` requests at most, which fits comfortably. +- **Self-hosted GitFlic** — set `GITFLIC_API_URL` to your instance's REST API base URL. +- **Re-reviews** — every push to the MR triggers a new pipeline and a new review. To skip already-reviewed MRs, check existing discussions for the `OpenCodeReview` marker before running the review step. + +## Tests + +`post_review.py` ships with [`post_review_test.py`](post_review_test.py) — standard-library `unittest`, no network or git required: + +```bash +cd examples/gitflic_ci +python3 post_review_test.py +``` + +The line-mapping cases are ported from the upstream Go tests so the script keeps proven parity with the binary. + +## Debugging + +Test the posting step locally without touching the MR: + +```bash +ocr review --from origin/main --to HEAD --format json > /tmp/r.json +python3 post_review.py /tmp/r.json \ + --owner --project --mr \ + --from origin/main --to HEAD --dry-run +``` + +`--dry-run` prints every discussion (with the computed positions) instead of posting, and does not require `GITFLIC_TOKEN`. diff --git a/examples/gitflic_ci/gitflic-ci.yaml b/examples/gitflic_ci/gitflic-ci.yaml new file mode 100644 index 0000000..2a2b9bd --- /dev/null +++ b/examples/gitflic_ci/gitflic-ci.yaml @@ -0,0 +1,84 @@ +# OpenCodeReview - GitFlic CI Merge Request Auto-Review Demo +# +# Reviews Merge Requests with OpenCodeReview and posts the findings onto the +# MR as discussions (inline where possible). The posting glue lives in the CI +# layer, in post_review.py next to this file -- consistent with the GitHub and +# GitLab examples, which keep platform-specific publishing out of the `ocr` +# binary. +# +# Setup: +# - Commit BOTH this file and post_review.py into your repository (adjust the +# `python3 post_review.py` path below if you place the script elsewhere). +# - Enable "Merge Request Pipeline" in Project Settings -> CI/CD Settings. +# - Use a runner able to run the node:20 image (it ships node, python3 and git), +# or any shell runner with node 20+, python3 and git available. +# +# Required CI/CD Variables (Settings -> CI/CD -> Variables): +# 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 +# GITFLIC_TOKEN - GitFlic access token used to post MR discussions +# +# Optional CI/CD Variables: +# OCR_LLM_MODEL - Model name (e.g., gpt-4o) +# GITFLIC_API_URL - GitFlic REST API base URL; only needed for self-hosted +# instances (defaults to https://api.gitflic.ru) +# +# post_review.py picks up the MR context automatically from the predefined +# GitFlic CI variables: CI_PROJECT_NAMESPACE, CI_PROJECT_NAME, +# CI_MERGE_REQUEST_LOCAL_ID, CI_MERGE_REQUEST_TARGET_BRANCH_NAME, CI_COMMIT_SHA. + +stages: + - review + +code-review: + stage: review + image: node:20 + script: + # Run only in merge request pipelines + - | + if [ -z "$CI_MERGE_REQUEST_LOCAL_ID" ]; then + echo "Not a merge request pipeline, skipping review." + exit 0 + fi + + # Install OpenCodeReview + - npm install -g @alibaba-group/open-code-review + + # Configure OCR + - | + ocr config set llm.url $OCR_LLM_URL + ocr config set llm.auth_token $OCR_LLM_AUTH_TOKEN + if [ -n "$OCR_LLM_MODEL" ]; then + ocr config set llm.model "$OCR_LLM_MODEL" + fi + ocr config set llm.use_anthropic false + ocr config set llm.extra_body '{"thinking": {"type": "disabled"}}' + + # Make sure the target branch and full history are available for merge-base diff + - git fetch --unshallow 2>/dev/null || true + - git fetch origin "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME" + + # Run OCR review (CI_COMMIT_SHA as head supports forked MRs as well) + - | + ocr review \ + --from "origin/${CI_MERGE_REQUEST_TARGET_BRANCH_NAME}" \ + --to "${CI_COMMIT_SHA}" \ + --format json \ + --audience agent \ + > /tmp/ocr-result.json || true + echo "OCR review completed." + + # Post review comments onto the MR (inline discussions + summary note). + # post_review.py recomputes the old-side line for each comment from the + # merge-base diff, which the GitFlic Discussions API requires for inline + # (code) comments. + # + # The review step above ends with `|| true`, so a failed `ocr review` (bad + # token, network error) leaves an empty or partial file. Skip posting in + # that case instead of feeding invalid JSON to post_review.py. + - | + if [ ! -s /tmp/ocr-result.json ]; then + echo "OCR review produced no output, skipping post." + exit 0 + fi + python3 post_review.py /tmp/ocr-result.json diff --git a/examples/gitflic_ci/post_review.py b/examples/gitflic_ci/post_review.py new file mode 100644 index 0000000..e8b0555 --- /dev/null +++ b/examples/gitflic_ci/post_review.py @@ -0,0 +1,480 @@ +#!/usr/bin/env python3 +"""Post an OpenCodeReview result onto a GitFlic merge request. + +This is the CI-layer "glue" for GitFlic, mirroring examples/gitlab_ci: it keeps +platform-specific publishing out of the `ocr` binary and lives entirely in the +pipeline. It reads the JSON emitted by `ocr review --format json` and posts it +onto the merge request as discussions: + + - one inline discussion per comment that maps onto the diff, + - a single fallback note collecting the comments that do not, + - a final summary note. + +GitFlic's Discussions API needs an *old-side* line even for a comment on the new +side of the diff: an inline (code) discussion requires all four of +newLine/oldLine/newPath/oldPath, otherwise GitFlic silently records a plain +comment. `ocr review` only reports new-side positions, so this script computes +the old-side line itself by parsing the same merge-base diff the review ran on +(`git diff merge-base(from, to)..to`). + +Standard library only (json, urllib, subprocess) so it runs on the stock +node:20 / python image used by the pipeline. +""" + +import argparse +import json +import os +import re +import subprocess +import sys +import urllib.error +import urllib.request +from urllib.parse import quote + +# GitFlic SaaS REST API endpoint; override with --api-url / $GITFLIC_API_URL for +# self-hosted instances (e.g. http://gitflic.example/rest-api). +DEFAULT_API_URL = "https://api.gitflic.ru" + +# Context lines around each hunk; must match what `ocr review` diffs with so the +# new-side line numbers in the comments align with the hunks parsed here. +DIFF_CONTEXT_LINES = 3 + + +def log(msg): + print(msg, file=sys.stderr) + + +# --------------------------------------------------------------------------- # +# Diff parsing +# --------------------------------------------------------------------------- # + +HUNK_CONTEXT, HUNK_ADDED, HUNK_DELETED = range(3) + +_HUNK_HEADER_RE = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@") +_DIFF_HEADER_RE = re.compile(r"^diff --git a/(.+?) b/(.+)$") + + +class Hunk: + """One @@ ... @@ block of a unified diff.""" + + __slots__ = ("old_start", "old_count", "new_start", "new_count", "lines") + + def __init__(self, old_start, old_count, new_start, new_count): + self.old_start = old_start + self.old_count = old_count + self.new_start = new_start + self.new_count = new_count + self.lines = [] # list of (type, content) + + +class FileDiff: + """A single file's section of a unified diff.""" + + __slots__ = ("old_path", "new_path", "is_new", "is_deleted", "is_binary", "text") + + def __init__(self, old_path="", new_path=""): + self.old_path = old_path + self.new_path = new_path + self.is_new = False + self.is_deleted = False + self.is_binary = False + self.text = "" # raw diff body, fed to parse_hunks() on demand + + +def parse_hunks(raw): + """Parse one file's unified diff text into a list of Hunks. + + Lines before the first @@ header (diff --git, ---, +++) are ignored. + """ + hunks = [] + current = None + for line in raw.split("\n"): + m = _HUNK_HEADER_RE.match(line) + if m: + if current is not None: + hunks.append(current) + old_start = int(m.group(1)) + old_count = int(m.group(2)) if m.group(2) else 1 + new_start = int(m.group(3)) + new_count = int(m.group(4)) if m.group(4) else 1 + current = Hunk(old_start, old_count, new_start, new_count) + continue + if current is None: + continue + if line.startswith("\\ No newline at end of file"): + continue + if line.startswith("diff --git "): + break + if line.startswith("+"): + current.lines.append((HUNK_ADDED, line[1:])) + elif line.startswith("-"): + current.lines.append((HUNK_DELETED, line[1:])) + else: + content = line[1:] if line[:1] == " " else line + current.lines.append((HUNK_CONTEXT, content)) + if current is not None: + hunks.append(current) + return hunks + + +def parse_diff(diff_text): + """Split combined unified diff text into per-file FileDiff sections.""" + files = [] + current = None + buf = [] + + def flush(): + if current is not None: + current.text = "\n".join(buf) + files.append(current) + + for line in diff_text.split("\n"): + m = _DIFF_HEADER_RE.match(line) + if m: + flush() + buf = [] + current = FileDiff(old_path=m.group(1), new_path=m.group(2)) + if current is None: + continue + if line.startswith("Binary files ") or line.startswith("GIT binary patch"): + current.is_binary = True + elif line.startswith("new file mode"): + current.is_new = True + elif line.startswith("deleted file mode"): + current.is_deleted = True + elif line.startswith("--- "): + path = line[4:] + if path == "/dev/null": + current.is_new = True + current.old_path = "/dev/null" + elif path.startswith("a/"): + current.old_path = path[2:] + elif line.startswith("+++ "): + path = line[4:] + if path == "/dev/null": + current.is_deleted = True + current.new_path = "/dev/null" + elif path.startswith("b/"): + current.new_path = path[2:] + buf.append(line) + + flush() + return files + + +# --------------------------------------------------------------------------- # +# Line mapping (new file side -> old file side) +# --------------------------------------------------------------------------- # + + +def clamp_line(n): + return 1 if n < 1 else n + + +def old_line_for(hunks, new_line): + """Map a new-side line number to the corresponding old-side line. + + Lines added by the diff have no old counterpart, so they are anchored to the + closest preceding old line -- GitFlic only needs a plausible old-side + position to render the code comment next to the insertion point. The result + is always >= 1. + """ + delta = 0 # cumulative (new - old) line-count shift from preceding hunks + for h in hunks: + if new_line < h.new_start: + break + if new_line < h.new_start + h.new_count: + return _old_line_in_hunk(h, new_line) + delta += h.new_count - h.old_count + return clamp_line(new_line - delta) + + +def _old_line_in_hunk(h, new_line): + """Walk a hunk's lines tracking both counters until reaching new_line.""" + old_ln, new_ln = h.old_start, h.new_start + last_old = h.old_start - 1 # last old line seen before the current position + for line_type, _content in h.lines: + if line_type == HUNK_CONTEXT: + if new_ln == new_line: + return clamp_line(old_ln) + last_old = old_ln + old_ln += 1 + new_ln += 1 + elif line_type == HUNK_DELETED: + last_old = old_ln + old_ln += 1 + elif line_type == HUNK_ADDED: + if new_ln == new_line: + return clamp_line(last_old) + new_ln += 1 + return clamp_line(last_old) + + +# --------------------------------------------------------------------------- # +# Comment formatting +# --------------------------------------------------------------------------- # + + +def format_comment(c): + """Render an inline discussion body.""" + body = c.get("content", "") + suggestion = c.get("suggestion_code", "") + existing = c.get("existing_code", "") + if suggestion and existing: + body += "\n\n**Suggestion:**\n```\n" + suggestion + "\n```" + return body + + +def format_comment_fallback(c): + """Render a comment for the fallback (non-inline) note.""" + md = "### 📄 `%s`" % c.get("path", "") + start_line = c.get("start_line", 0) + end_line = c.get("end_line", 0) + if start_line and end_line: + md += " (L%d-L%d)" % (start_line, end_line) + md += "\n\n" + c.get("content", "") + suggestion = c.get("suggestion_code", "") + existing = c.get("existing_code", "") + if suggestion and existing: + md += "\n\n**Before:**\n```\n" + existing + "\n```\n\n**After:**\n```\n" + suggestion + "\n```" + return md + + +# --------------------------------------------------------------------------- # +# Publishing (transport-agnostic; `post` does the actual API call) +# --------------------------------------------------------------------------- # + + +def publish(result, diffs_by_path, post): + """Post the review result via the `post(discussion)` callable. + + `post` receives a discussion dict and must raise on failure. A general + comment carries only "message"; an inline comment also carries + newLine/oldLine/newPath/oldPath. Returns {"inline": int, "fallback": int}. + """ + comments = result.get("comments") or [] + if not comments: + message = result.get("message") or "No comments generated. Looks good to me." + post({"message": "✅ **OpenCodeReview**: " + message}) + return {"inline": 0, "fallback": 0} + + inline = 0 + failed = [] + hunks_cache = {} + + for c in comments: + path = c.get("path", "") + end_line = c.get("end_line", 0) or 0 + fd = diffs_by_path.get(path) + if fd is None: + log("no diff for %s; folding comment into the summary note" % path) + failed.append(c) + continue + if fd.is_binary or fd.is_deleted or end_line <= 0: + failed.append(c) + continue + + old_path = fd.old_path + old_line = 1 + if fd.is_new or old_path == "" or old_path == "/dev/null": + # GitFlic has no old side for a new file; anchor to the new path. + old_path = fd.new_path + else: + hunks = hunks_cache.get(path) + if hunks is None: + hunks = parse_hunks(fd.text) + hunks_cache[path] = hunks + old_line = old_line_for(hunks, end_line) + + discussion = { + "message": format_comment(c), + "newLine": end_line, + "oldLine": old_line, + "newPath": path, + "oldPath": old_path, + } + try: + post(discussion) + except Exception as e: # noqa: BLE001 - any transport error falls back + log("inline comment failed for %s:%d: %s" % (path, end_line, e)) + failed.append(c) + continue + inline += 1 + + if failed: + note = "🔍 **OpenCodeReview** found issues that could not be posted inline:\n\n---\n\n" + for c in failed: + note += format_comment_fallback(c) + "\n\n---\n\n" + post({"message": note}) + + summary = "🔍 **OpenCodeReview** found **%d** issue(s) in this MR." % len(comments) + summary += "\n- ✅ %d posted as inline comment(s)" % inline + summary += "\n- 📝 %d posted as summary (could not be placed inline)" % len(failed) + warnings = result.get("warnings") or [] + if warnings: + summary += "\n\n⚠️ %d warning(s) occurred during review." % len(warnings) + post({"message": summary}) + + return {"inline": inline, "fallback": len(failed)} + + +# --------------------------------------------------------------------------- # +# GitFlic REST transport +# --------------------------------------------------------------------------- # + + +def make_poster(api_url, token, owner, project, mr): + """Return a post(discussion) that POSTs to the GitFlic Discussions API.""" + endpoint = "%s/project/%s/%s/merge-request/%s/discussions/create" % ( + api_url.rstrip("/"), + quote(owner, safe=""), + quote(project, safe=""), + quote(mr, safe=""), + ) + + def post(discussion): + body = json.dumps(discussion).encode("utf-8") + req = urllib.request.Request(endpoint, data=body, method="POST") + req.add_header("Authorization", "token " + token) + req.add_header("Content-Type", "application/json") + try: + with urllib.request.urlopen(req) as resp: + resp.read() + except urllib.error.HTTPError as e: + snippet = e.read(512).decode("utf-8", "replace").strip() + # Some APIs echo request details back in error bodies; never let the + # token reach the CI log if GitFlic does that. + if token: + snippet = snippet.replace(token, "***") + raise RuntimeError("gitflic API %s %s: %s" % (e.code, e.reason, snippet)) + + return post + + +def make_dry_run_poster(): + """Return a post(discussion) that prints instead of calling the API.""" + + def post(discussion): + if discussion.get("newPath") and "newLine" in discussion and "oldLine" in discussion: + position = "%s:%d (old %s:%d)" % ( + discussion["newPath"], + discussion["newLine"], + discussion.get("oldPath", ""), + discussion["oldLine"], + ) + else: + position = "general" + print("--- dry-run discussion [%s] ---\n%s\n" % (position, discussion["message"])) + + return post + + +# --------------------------------------------------------------------------- # +# git / IO +# --------------------------------------------------------------------------- # + + +def _git(repo, *args): + return subprocess.run( + ["git", *args], cwd=repo, check=True, capture_output=True, text=True + ).stdout + + +def load_diffs_by_path(repo, from_ref, to_ref): + """Build {new_path: FileDiff} for the merge-base diff `ocr review` ran on.""" + base = _git(repo, "merge-base", from_ref, to_ref).strip() + out = _git( + repo, "diff", "--no-ext-diff", "--no-textconv", + "--src-prefix=a/", "--dst-prefix=b/", "--no-color", + "-U%d" % DIFF_CONTEXT_LINES, base, to_ref, "--", + ) + return {fd.new_path: fd for fd in parse_diff(out)} + + +def load_review_result(path): + """Read the JSON produced by `ocr review --format json` (path '-' = stdin).""" + if path == "-": + data = sys.stdin.read() + else: + with open(path, encoding="utf-8") as f: + data = f.read() + return json.loads(data) + + +# --------------------------------------------------------------------------- # +# CLI +# --------------------------------------------------------------------------- # + + +def parse_args(argv): + target = os.environ.get("CI_MERGE_REQUEST_TARGET_BRANCH_NAME", "") + default_from = "origin/" + target if target else "" + + p = argparse.ArgumentParser( + description="Post `ocr review --format json` output onto a GitFlic merge request." + ) + p.add_argument("file", nargs="?", default="-", + help="review result JSON ('-' = stdin, default)") + p.add_argument("--owner", default=os.environ.get("CI_PROJECT_NAMESPACE", ""), + help="project owner alias (default: $CI_PROJECT_NAMESPACE)") + p.add_argument("--project", default=os.environ.get("CI_PROJECT_NAME", ""), + help="project alias (default: $CI_PROJECT_NAME)") + p.add_argument("--mr", default=os.environ.get("CI_MERGE_REQUEST_LOCAL_ID", ""), + help="merge request local id (default: $CI_MERGE_REQUEST_LOCAL_ID)") + p.add_argument("--api-url", default=os.environ.get("GITFLIC_API_URL", "") or DEFAULT_API_URL, + help="GitFlic REST API base URL (default: $GITFLIC_API_URL or %s)" % DEFAULT_API_URL) + p.add_argument("--from", dest="from_ref", default=default_from, + help="base ref of the reviewed range (default: origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME)") + p.add_argument("--to", dest="to_ref", default=os.environ.get("CI_COMMIT_SHA", ""), + help="head ref of the reviewed range (default: $CI_COMMIT_SHA)") + p.add_argument("--repo", default=".", help="git repository root (default: .)") + p.add_argument("--dry-run", action="store_true", + help="print discussions instead of posting them") + return p.parse_args(argv) + + +def main(argv=None): + args = parse_args(sys.argv[1:] if argv is None else argv) + + missing = [name for name, value in ( + ("--owner", args.owner), ("--project", args.project), ("--mr", args.mr), + ("--from", args.from_ref), ("--to", args.to_ref), + ) if not value] + if missing: + log("error: %s required (set via flag or CI environment)" % ", ".join(missing)) + return 2 + + token = os.environ.get("GITFLIC_TOKEN", "") + if not token and not args.dry_run: + log("error: GITFLIC_TOKEN environment variable is required") + return 2 + + try: + result = load_review_result(args.file) + except (OSError, ValueError) as e: + log("error: cannot read review result %s: %s" % (args.file, e)) + return 1 + + try: + diffs_by_path = load_diffs_by_path(args.repo, args.from_ref, args.to_ref) + except (subprocess.CalledProcessError, OSError) as e: + # Without the diff, inline positions cannot be computed; comments still + # go out via the fallback note. + log("warning: cannot read diff %s..%s, posting all comments as fallback: %s" + % (args.from_ref, args.to_ref, e)) + diffs_by_path = {} + + if args.dry_run: + post = make_dry_run_poster() + else: + post = make_poster(args.api_url, token, args.owner, args.project, args.mr) + + stats = publish(result, diffs_by_path, post) + total = len(result.get("comments") or []) + print("Posted %d inline comment(s), %d via fallback note (%d total)." + % (stats["inline"], stats["fallback"], total)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/gitflic_ci/post_review_test.py b/examples/gitflic_ci/post_review_test.py new file mode 100644 index 0000000..be43828 --- /dev/null +++ b/examples/gitflic_ci/post_review_test.py @@ -0,0 +1,240 @@ +#!/usr/bin/env python3 +"""Tests for post_review.py. + +Standard-library unittest only (no pytest, no network, no git): run with + + python3 post_review_test.py # from examples/gitflic_ci/ + python3 -m unittest discover examples/gitflic_ci + +The line-mapping cases are ported 1:1 from the upstream Go test +internal/publish/gitflic/linemap_test.go and publisher_test.go, so the script +keeps proven parity with the binary it replaces. +""" + +import unittest + +import post_review as pr + + +# old file lines 1..10; line 3 modified, a line inserted after old line 5, +# old line 8 deleted. (from linemap_test.go) +SAMPLE_DIFF = """diff --git a/main.go b/main.go +--- a/main.go ++++ b/main.go +@@ -1,10 +1,10 @@ + line1 + line2 +-line3 old ++line3 new + line4 + line5 ++inserted after5 + line6 + line7 +-line8 + line9 + line10 +""" + +NEW_FILE_DIFF = """diff --git a/added.go b/added.go +new file mode 100644 +--- /dev/null ++++ b/added.go +@@ -0,0 +1,3 @@ ++package main ++ ++func main() {} +""" + +DELETED_FILE_DIFF = """diff --git a/gone.go b/gone.go +deleted file mode 100644 +--- a/gone.go ++++ /dev/null +@@ -1,2 +0,0 @@ +-package main +- +""" + +BINARY_DIFF = """diff --git a/logo.png b/logo.png +index 1111111..2222222 100644 +Binary files a/logo.png and b/logo.png differ +""" + + +class OldLineForTest(unittest.TestCase): + def setUp(self): + self.hunks = pr.parse_hunks(SAMPLE_DIFF) + + def test_single_hunk_positions(self): + self.assertEqual(len(self.hunks), 1) + cases = [ + ("context before changes", 1, 1), + ("modified line maps to deleted position anchor", 3, 3), + ("context after modification", 4, 4), + ("added line anchors to preceding old line", 6, 5), + ("context shifted by insertion", 7, 6), + ("context after deletion", 9, 9), + ("last context line", 10, 10), + ] + for name, new_line, want in cases: + with self.subTest(name): + self.assertEqual(pr.old_line_for(self.hunks, new_line), want) + + def test_outside_hunks(self): + # one line added and one deleted -> cumulative delta 0 + self.assertEqual(pr.old_line_for(self.hunks, 42), 42) + + def test_multiple_hunks(self): + multi = ( + "@@ -1,2 +1,4 @@\n" + " line1\n" + "+added2\n" + "+added3\n" + " line2\n" + "@@ -10,3 +12,3 @@\n" + " line10\n" + "-line11 old\n" + "+line11 new\n" + " line12\n" + ) + hunks = pr.parse_hunks(multi) + self.assertEqual(len(hunks), 2) + # between hunks: new 8 = old 6 (two lines added by hunk 1) + self.assertEqual(pr.old_line_for(hunks, 8), 6) + # inside second hunk: modified new 13 anchors to old 11 + self.assertEqual(pr.old_line_for(hunks, 13), 11) + + def test_pure_addition_at_top(self): + hunks = pr.parse_hunks("@@ -0,0 +1,2 @@\n+first\n+second\n") + self.assertEqual(pr.old_line_for(hunks, 1), 1) + + +class ParseDiffTest(unittest.TestCase): + def test_modified_file(self): + fd = pr.parse_diff(SAMPLE_DIFF)[0] + self.assertEqual((fd.old_path, fd.new_path), ("main.go", "main.go")) + self.assertFalse(fd.is_new or fd.is_deleted or fd.is_binary) + + def test_new_file(self): + fd = pr.parse_diff(NEW_FILE_DIFF)[0] + self.assertTrue(fd.is_new) + self.assertEqual(fd.new_path, "added.go") + + def test_deleted_file(self): + fd = pr.parse_diff(DELETED_FILE_DIFF)[0] + self.assertTrue(fd.is_deleted) + self.assertEqual(fd.new_path, "/dev/null") + + def test_binary_file(self): + fd = pr.parse_diff(BINARY_DIFF)[0] + self.assertTrue(fd.is_binary) + + def test_multiple_files(self): + files = pr.parse_diff(SAMPLE_DIFF + NEW_FILE_DIFF) + self.assertEqual([f.new_path for f in files], ["main.go", "added.go"]) + + +class Recorder: + """A post() that records discussions; optionally fails the first inline.""" + + def __init__(self, fail_first_inline=False): + self.calls = [] + self.fail_first_inline = fail_first_inline + self._inline_seen = 0 + + def __call__(self, discussion): + if self.fail_first_inline and "newPath" in discussion: + self._inline_seen += 1 + if self._inline_seen == 1: + raise RuntimeError("simulated 403") + self.calls.append(discussion) + + +def diffs_from(diff_text): + return {fd.new_path: fd for fd in pr.parse_diff(diff_text)} + + +class PublishTest(unittest.TestCase): + def test_inline_and_summary(self): + result = { + "comments": [{ + "path": "main.go", "content": "possible nil dereference", + "start_line": 6, "end_line": 6, + "existing_code": "x := y.Field", + "suggestion_code": "if y != nil { x = y.Field }", + }], + } + rec = Recorder() + stats = pr.publish(result, diffs_from(SAMPLE_DIFF), rec) + + self.assertEqual(stats, {"inline": 1, "fallback": 0}) + self.assertEqual(len(rec.calls), 2) # inline + summary + + inline = rec.calls[0] + self.assertEqual(inline["newLine"], 6) + self.assertEqual(inline["oldLine"], 5) + self.assertEqual((inline["newPath"], inline["oldPath"]), ("main.go", "main.go")) + self.assertIn("possible nil dereference", inline["message"]) + self.assertIn("**Suggestion:**", inline["message"]) + + summary = rec.calls[1] + self.assertNotIn("newPath", summary) + self.assertIn("**1** issue(s)", summary["message"]) + + def test_fallback_for_unmapped_comment(self): + result = { + "comments": [{ + "path": "missing.go", "content": "issue in file absent from diff", + "start_line": 1, "end_line": 1, + }], + "warnings": [{"file": "a.go", "message": "skipped", "type": "subtask_error"}], + } + rec = Recorder() + stats = pr.publish(result, {}, rec) + + self.assertEqual(stats, {"inline": 0, "fallback": 1}) + self.assertEqual(len(rec.calls), 2) # fallback + summary + self.assertIn("could not be posted inline", rec.calls[0]["message"]) + self.assertIn("`missing.go`", rec.calls[0]["message"]) + self.assertIn("1 warning(s)", rec.calls[1]["message"]) + + def test_inline_error_falls_back(self): + result = { + "comments": [{ + "path": "main.go", "content": "finding", + "start_line": 1, "end_line": 1, + }], + } + rec = Recorder(fail_first_inline=True) + stats = pr.publish(result, diffs_from(SAMPLE_DIFF), rec) + + self.assertEqual(stats, {"inline": 0, "fallback": 1}) + self.assertEqual(len(rec.calls), 2) # fallback + summary after inline failure + + def test_no_comments(self): + rec = Recorder() + stats = pr.publish({"message": "No comments generated. Looks good to me."}, {}, rec) + + self.assertEqual(stats, {"inline": 0, "fallback": 0}) + self.assertEqual(len(rec.calls), 1) + self.assertIn("Looks good to me", rec.calls[0]["message"]) + + def test_new_file_anchors_to_new_path(self): + result = { + "comments": [{ + "path": "added.go", "content": "empty main", + "start_line": 3, "end_line": 3, + }], + } + rec = Recorder() + stats = pr.publish(result, diffs_from(NEW_FILE_DIFF), rec) + + self.assertEqual(stats["inline"], 1) + inline = rec.calls[0] + self.assertEqual(inline["oldPath"], "added.go") + self.assertEqual(inline["oldLine"], 1) + self.assertEqual(inline["newLine"], 3) + + +if __name__ == "__main__": + unittest.main() diff --git a/examples/github_actions/README.md b/examples/github_actions/README.md index dccf654..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,76 +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. You can configure the retry and delay behavior via **repository variables** (Settings → Secrets and variables → Actions → Variables): +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. + +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. - -### Limit concurrency - -Adjust the `--concurrency` flag for large PRs to control the number of concurrent LLM requests: +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 -- name: Run OCR review - run: ocr review --concurrency 5 --from origin/${{ github.base_ref }} --to origin/${{ github.head_ref }} +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 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 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 action **skips retrying** to avoid risking a duplicate, and surfaces the uncertainty in the summary instead of silently producing duplicates. + +### Limit LLM concurrency + +```yaml +- 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 @@ -168,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:** @@ -210,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: @@ -218,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 360eb2e..11a57a3 100644 --- a/examples/github_actions/ocr-review.yml +++ b/examples/github_actions/ocr-review.yml @@ -1,59 +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). -# -# 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] @@ -64,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, @@ -84,469 +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 path = '/tmp/ocr-result.json'; - - // 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) { - const body = formatComment(comment); - - // 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, body }); - continue; - } - - const reviewComment = { - path: comment.path, - body: body - }; - - // Use line range if available - 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 }); - } - - // 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); - - // Statistics tracking - let successCount = 0; - let failedCount = 0; - const failedComments = []; - - 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(({ reviewComment }) => reviewComment) - }); - 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('Falling back to posting comments individually with rate-limit-aware retry...'); - - // Fallback: post comments one by one with delay to avoid secondary rate limits. - // GitHub enforces ~80 content-generating requests per minute; spacing calls - // helps stay under that threshold. Retry/wait durations are derived from the - // rate-limit response headers per GitHub's documented strategy. - const MAX_RETRIES = parseInt(process.env.OCR_MAX_RETRIES, 10) || 3; - const SUCCESS_DELAY = parseInt(process.env.OCR_SUCCESS_DELAY, 10) || 2000; // delay after successful post - const FAILURE_DELAY = parseInt(process.env.OCR_FAILURE_DELAY, 10) || 1000; // delay after non-retryable failure - const LOW_REMAINING_THRESHOLD = parseInt(process.env.OCR_LOW_REMAINING_THRESHOLD, 10) || 3; - const LOW_REMAINING_SPACING = parseInt(process.env.OCR_LOW_REMAINING_SPACING, 10) || 10000; - - // 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 { comment, reviewComment } of reviewComments) { - 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: [reviewComment] - }); - successCount++; - posted = true; - console.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}`); - 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; - if (willRetry) { - const secs = (retryInfo.delayMs / 1000).toFixed(1); - console.log( - `Rate-limited/transient error 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 { - 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 ${reviewComment.path} (${reason}, HTTP ${innerE.status || 'n/a'}): ${innerE.message}`); - // After exhausting retries use the success-style pace delay; - // for other errors use the shorter failure pace delay. - await sleep(retryInfo == null ? FAILURE_DELAY : SUCCESS_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); - } - } - - 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)); - } - - // 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; } - } - - function formatComment(comment) { - let body = comment.content || ''; - - // Add code suggestion if available - 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/providers.ts b/extensions/vscode/src/shared/providers.ts index ef03f51..684fb7a 100644 --- a/extensions/vscode/src/shared/providers.ts +++ b/extensions/vscode/src/shared/providers.ts @@ -95,6 +95,14 @@ export const PROVIDER_PRESETS: OcrProviderPreset[] = [ envVar: 'Z_AI_API_KEY', models: ['glm-5.2', 'glm-5.1', 'glm-5-turbo', 'glm-4.7'], }, + { + name: 'z-ai-coding', + displayName: 'Z.AI Coding Plan API', + protocol: 'openai', + baseUrl: 'https://open.bigmodel.cn/api/coding/paas/v4', + envVar: 'Z_AI_CODING_API_KEY', + models: ['glm-5.2', 'glm-5.1', 'glm-5-turbo', 'glm-4.7'], + }, { name: 'mimo', displayName: 'Xiaomi MiMo API', 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')}