diff --git a/.claude/commands/open-code-review.md b/.claude/commands/open-code-review.md index f7339cb..a58cb58 100644 --- a/.claude/commands/open-code-review.md +++ b/.claude/commands/open-code-review.md @@ -15,7 +15,6 @@ ocr review --audience agent [user-args] - If the user provides `--commit` or `--c`: pass through as-is. - If the user provides `--from` and `--to`: pass through as-is. - (Optional) Provide `--background "requirement context"` to review whether the requirements are correctly implemented. -- (Optional) Provide `--background-file ./requirements.md` to load the same context from a Markdown file (sanitised and limited to 8000 characters). Combined with `--background` the inline value is given first. - Capture full stdout. Set a 5-minute timeout. - If the `ocr` command is not found, install it by running `npm i -g @alibaba-group/open-code-review`. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ac1420e..31557d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: runs-on: self-hosted timeout-minutes: 15 container: - image: golang:1.26.5 + image: golang:1.26.4 steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index 4eb0d6b..e89f248 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:24 + image: node:20 steps: - uses: actions/checkout@v4 @@ -51,7 +51,7 @@ jobs: url: ${{ steps.deployment.outputs.page_url }} runs-on: self-hosted container: - image: node:24 + image: node:20 needs: build steps: - name: Deploy to GitHub Pages diff --git a/.github/workflows/ocr-review.yml b/.github/workflows/ocr-review.yml index 1bb8775..e6bee99 100644 --- a/.github/workflows/ocr-review.yml +++ b/.github/workflows/ocr-review.yml @@ -1,19 +1,46 @@ # OpenCodeReview - GitHub Actions PR Auto-Review Pipeline # -# Reviews pull requests using the reusable composite action defined in -# action.yml at the repo root. See that file for the full list of inputs, -# outputs, and implementation details (retry, idempotency, artifact upload, etc.). +# This workflow automatically reviews pull requests using OpenCodeReview +# and posts review comments directly on the PR. # # Triggers: # - PR opened (uses pull_request_target for fork secret access) +# - Comment on PR containing '/open-code-review' or '@open-code-review' # -# Required secrets (mapped to action inputs): -# OCR_LLM_URL - LLM API endpoint (e.g., https://api.openai.com/v1/chat/completions) -# OCR_LLM_AUTH_TOKEN - Authentication token for the LLM API -# OCR_LLM_MODEL - Model name -# OCR_LLM_USE_ANTHROPIC - 'true' for Anthropic Claude, 'false' for OpenAI-compatible +# Required secrets: +# OCR_LLM_URL - LLM API endpoint (e.g., https://api.openai.com/v1/chat/completions) +# OCR_LLM_AUTH_TOKEN - Authentication token for the LLM API +# +# Optional secrets: +# OCR_LLM_MODEL - Model name (default: gpt-4o) +# OCR_LLM_USE_ANTHROPIC - Set to 'true' if using Anthropic Claude models +# +# Optional variables (for retry/delay tuning): +# The retry strategy follows GitHub's documented guidance for REST API rate limits: +# https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api +# - Primary rate limit exhausted (x-ratelimit-remaining=0): wait until x-ratelimit-reset. +# - Secondary rate limit with a retry-after header: wait exactly that long. +# - Secondary rate limit with NO header: wait at least one minute, then use +# exponential backoff on continued failures. +# +# OCR_RETRY_BASE_DELAY - Base delay (ms) for exponential backoff when no retry +# header is present (default: 60000, per GitHub's +# "at least one minute" recommendation for secondary limits). +# OCR_RETRY_MAX_DELAY - Maximum delay (ms) cap applied to EVERY computed wait, +# including retry-after and x-ratelimit-reset, so a far-future +# reset cannot stall the job past its timeout (default: 300000 = 5 min). +# OCR_MAX_RETRIES - Max retry attempts per comment when rate-limited (default: 3). +# OCR_SUCCESS_DELAY - Delay (ms) between successful comment posts to pace requests (default: 2000). +# OCR_FAILURE_DELAY - Delay (ms) after a non-retryable failure to pace subsequent requests (default: 1000). +# OCR_LOW_REMAINING_THRESHOLD - When x-ratelimit-remaining is at or below this value, +# proactively increase request spacing to avoid hitting the limit +# (default: 3; GitHub best practice is to watch the header and slow down). +# OCR_LOW_REMAINING_SPACING - Request spacing (ms) used when remaining quota is low +# (default: 10000 = 10s). # # Note: GITHUB_TOKEN is automatically provided by GitHub Actions. +# Note: The workflow also configures llm.extra_body to '{"thinking": {"type": "disabled"}}' +# to disable thinking mode for compatibility with various LLM providers. name: OpenCodeReview PR Review @@ -35,30 +62,445 @@ permissions: jobs: code-review: runs-on: self-hosted - timeout-minutes: 30 container: - image: node:24 + image: node:20 if: github.event_name == 'pull_request_target' steps: - # Materialize action.yml + scripts/ into the workspace so the local - # `uses: ./` action below can be resolved and loaded. For - # pull_request_target this checks out the trusted base branch; the - # composite action performs its own full checkout (fetch-depth: 0) later. - - name: Checkout + - name: Checkout repository uses: actions/checkout@v4 + with: + fetch-depth: 0 # Full history needed for merge-base diff + ref: ${{ github.event.pull_request.head.sha }} - - name: Trust workspace + - name: Mark repository as safe directory run: git config --global --replace-all safe.directory '*' + - name: Fetch PR head ref (ensures fork commits are available) + run: git fetch origin pull/${{ github.event.pull_request.number }}/head + + - name: Install OpenCodeReview + run: npm install -g @alibaba-group/open-code-review + + - name: Configure OCR + run: | + ocr config set llm.url ${{ secrets.OCR_LLM_URL }} + ocr config set llm.auth_token ${{ secrets.OCR_LLM_AUTH_TOKEN }} + ocr config set llm.model ${{ secrets.OCR_LLM_MODEL }} + ocr config set llm.use_anthropic ${{ secrets.OCR_LLM_USE_ANTHROPIC }} + ocr config set llm.extra_body '{"enable_thinking": false}' + ocr config set language English + - name: Run OpenCodeReview - uses: ./ + id: review + run: | + BASE_REF="${{ github.event.pull_request.base.ref }}" + HEAD_SHA="${{ github.event.pull_request.head.sha }}" + + echo "Reviewing PR: ${HEAD_SHA} against origin/${BASE_REF}" + + # Run OCR in range mode with JSON output + ocr review \ + --from "origin/${BASE_REF}" \ + --to "${HEAD_SHA}" \ + --format json \ + > /tmp/ocr-result.json 2>/tmp/ocr-stderr.log || true + + echo "OCR review completed. Output:" + cat /tmp/ocr-result.json + echo "OCR review completed. Error log:" + cat /tmp/ocr-stderr.log + + - name: Post review comments to PR + uses: actions/github-script@v7 with: - llm_url: ${{ secrets.OCR_LLM_URL }} - llm_auth_token: ${{ secrets.OCR_LLM_AUTH_TOKEN }} - llm_model: ${{ secrets.OCR_LLM_MODEL }} - llm_use_anthropic: ${{ secrets.OCR_LLM_USE_ANTHROPIC }} - llm_extra_body: '{"enable_thinking": false}' - github_token: ${{ secrets.GITHUB_TOKEN }} - sticky_summary: 'true' - incremental: 'false' - upload_artifacts: 'true' + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const fs = require('fs'); + const 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)); + } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index de4de5e..e11ee7f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,7 +11,7 @@ jobs: build: runs-on: self-hosted container: - image: golang:1.26.5 + image: golang:1.26.4 strategy: matrix: include: @@ -160,7 +160,7 @@ jobs: needs: release runs-on: self-hosted container: - image: node:24 + image: node:20 permissions: contents: read steps: diff --git a/README.ja-JP.md b/README.ja-JP.md index d03931c..2e9d8c3 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -13,6 +13,7 @@

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

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

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

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

npm Build status + Go Report Card License Ask DeepWiki OpenSSF Best Practices @@ -91,10 +92,6 @@ Open Code Review 的核心设计理念是将确定性工程与 Agent 结合, ## 如何使用 -### 前置条件 - -- **Git >= 2.41** — Open Code Review 依赖 Git 进行 diff 生成、代码搜索和仓库操作。 - ### CLI #### 安装 @@ -107,18 +104,6 @@ npm install -g @alibaba-group/open-code-review 安装后,`ocr` 命令即可全局使用。 -**更新** - -如果通过 NPM 安装,可手动更新到最新版本: - -```bash -npm install -g @alibaba-group/open-code-review@latest -``` - -通过 NPM 安装的 `ocr` 还会默认在后台检查新版本并自动升级;如需关闭自动更新,可设置 `OCR_NO_UPDATE=1`。 - -如果通过安装脚本或手动下载二进制文件安装,重新运行对应的安装/下载命令即可替换为最新 release。需要固定版本时,可继续通过 `OCR_VERSION` 指定 release tag。 - **从 GitHub Release 下载** 使用一条命令为你的操作系统/架构安装最新二进制文件(macOS / Linux): @@ -280,10 +265,6 @@ ocr review --from main --to feature-branch # 单个提交 ocr review --commit abc123 -# 恢复中断的区间或单 commit 评审 -ocr session list -ocr review --from main --to feature-branch --resume - # 全量文件扫描 —— 审查整个文件而非 diff(无需 git 历史) ocr scan # 扫描整个仓库 ocr scan --path internal/agent # 扫描指定目录或文件 @@ -419,35 +400,10 @@ 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/) 目录。 ## 命令 @@ -462,8 +418,6 @@ ocr review \ | `ocr config unset custom_providers.` | — | 删除自定义供应商 | | `ocr llm test` | — | 测试 LLM 连通性 | | `ocr llm providers` | — | 列出内置 LLM 供应商 | -| `ocr session list` | `ocr sessions list`, `ocr session ls` | 列出已保存的评审会话 | -| `ocr session show ` | `ocr sessions show ` | 查看单个会话及其逐文件检查点 | | `ocr viewer` | `ocr v` | 启动 WebUI 会话查看器,地址 `localhost:5483` | | `ocr version` | — | 显示版本信息 | @@ -477,53 +431,17 @@ ocr review \ | `--commit` | `-c` | — | 审查单个提交 | | `--exclude` | — | — | 以逗号分隔的 gitignore 风格模式,用于跳过匹配文件;与 rule.json 中的 excludes 合并 | | `--preview` | `-p` | `false` | 预览将被审查的文件列表,不调用 LLM | -| `--resume` | — | — | 从之前兼容的区间或单 commit 评审会话恢复 | | `--format` | `-f` | `text` | 输出格式:`text` 或 `json` | | `--concurrency` | — | `8` | 最大并发文件审查数 | | `--timeout` | — | `10` | 并发任务超时时间(分钟) | | `--audience` | — | `human` | `human`(显示进度)或 `agent`(仅输出摘要) | | `--background` | `-b` | — | 可选的需求/业务背景信息;使用 `--commit` 时如未指定则自动从 commit message 中提取 | -| `--background-file` | `-B` | — | 来自 Markdown 文件的可选需求/业务背景信息;与 `--background` 同时使用时,内联内容排在前面 | | `--model` | — | — | 为本次审查选择或覆盖 LLM 模型 | | `--rule` | — | — | 自定义 JSON 审查规则路径 | | `--max-tools` | — | 内置默认 | 每个文件的最大工具调用轮次;仅在大于模板默认值时生效 | | `--max-git-procs` | — | 内置默认 | 最大并发 git 子进程数 | | `--tools` | — | — | 自定义 JSON 工具配置路径 | -#### 可恢复评审与会话 - -每次 `ocr review` 都会在 `~/.opencodereview/sessions/` 下保存本地会话日志。 -正常完成的文本输出只展示评审结果,不打印 session ID;可使用 -`ocr session list/show` 查找已保存会话,或用 `--format json` 在机器可读输出中获取 -`session_id`。如果区间或单 commit 评审被中断,可列出保存的会话,并从匹配相同评审目标的会话恢复: - -```bash -ocr session list -ocr session show -ocr review --from main --to feature-branch --resume -ocr review --commit abc123 --resume -``` - -恢复逻辑是严格的:仅支持分支区间和单 commit 评审,不支持工作区评审;当前 -`--from/--to` 或 `--commit` 必须与保存的会话一致。`--preview` 不能与 `--resume` 同时使用。 - -使用 `--format json` 时,恢复运行会包含: - -- `session_id` — 当前运行的 session ID -- `resume.resumed_from` — 来源 session ID -- `resume.reused_files` — 从已保存检查点复用的文件数 -- `resume.rerun_files` — 本次重新评审的文件数 - -### `ocr session` 参数 - -| 命令 | 参数 | 默认值 | 描述 | -|------|------|--------|------| -| `ocr session list` | `--repo` | 当前目录 | 要列出会话的仓库 | -| `ocr session list` | `--json` | `false` | 以 JSON 输出会话摘要 | -| `ocr session list` | `--limit` | `20` | 限制列出的会话数量;`0` 表示不限 | -| `ocr session show ` | `--repo` | 当前目录 | 要查看会话的仓库 | -| `ocr session show ` | `--json` | `false` | 以 JSON 输出会话元数据和逐文件条目 | - ### `ocr scan` 参数 `ocr scan` 审查整个文件而非 diff —— 适用于审计不熟悉的代码库、迁移前扫描,或任何没有有意义 diff 的目录。它也可以在非 git 目录中工作(会回退到遵循 `.gitignore` 的文件系统遍历)。 @@ -569,12 +487,6 @@ ocr review --from main --to my-feature --concurrency 4 # 审查特定提交并以 JSON 格式输出详细信息 ocr review --commit abc123 --format json --audience agent -# 恢复中断的区间或单 commit 评审 -ocr session list -ocr session show -ocr review --from main --to my-feature --resume -ocr review --commit abc123 --resume - # 为本次审查选择或覆盖模型 ocr review --model claude-opus-4-6 ocr review --commit abc123 --model claude-sonnet-4-6 @@ -582,12 +494,6 @@ ocr review --commit abc123 --model claude-sonnet-4-6 # 提供需求背景以获得更有针对性的审查 ocr review --background "为登录 API 添加限流" -# 从 Markdown 文件提供需求背景 -ocr review --background-file ./docs/my_business_context.md - -# 将内联背景与本地背景文件结合使用(两者都会生效) -ocr review --background "关注鉴权" --background-file ./docs/my_business_context.md - # 使用自定义审查规则 ocr review --rule /path/to/my-rules.json @@ -832,22 +738,13 @@ ocr config set telemetry.otlp_endpoint localhost:4317 设置 `telemetry.content_logging` 可在导出数据中包含 LLM 提示词和响应。 -**协议选择:** 通过环境变量 `OTEL_EXPORTER_OTLP_PROTOCOL` 选择导出协议: - -| 值 | 传输方式 | 说明 | -|---|---|---| -| `grpc`(默认) | gRPC | 默认端口 4317 | -| `http/protobuf` | HTTP | 默认端口 4318 | - -**Endpoint 格式:** `telemetry.otlp_endpoint` 的值为 `host:port` 或 `http://host:port`,无需包含路径。SDK 会根据 [OTLP 规范](https://opentelemetry.io/docs/specs/otlp/#otlphttp-request)自动追加信号路径(如 `/v1/traces`)。 - ## 贡献 -感谢所有为本项目做出贡献的人。参见 [CONTRIBUTING.zh-CN.md](CONTRIBUTING.zh-CN.md) 了解开发环境搭建、编码规范以及如何提交 Pull Request。 +参见 [CONTRIBUTING.zh-CN.md](CONTRIBUTING.zh-CN.md) 了解开发环境搭建、编码规范以及如何提交 Pull Request。 - - - +## Star History + +[![Star History Chart](https://api.star-history.com/svg?repos=alibaba/open-code-review&type=Date)](https://star-history.com/#alibaba/open-code-review&Date) ## 许可证 diff --git a/ROADMAP.md b/ROADMAP.md index 07d0b38..4fa8ada 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -19,9 +19,6 @@ OpenCodeReview currently provides: - CI/CD integration (GitHub Actions, GitLab CI, etc.). - Multi-provider LLM support (OpenAI-compatible, Anthropic, Google Gemini, Amazon Bedrock, Azure OpenAI, etc.). -- MCP server — expose OpenCodeReview over the - [Model Context Protocol](https://modelcontextprotocol.io/) so review - capabilities can be invoked from any MCP-compatible client. - Review rules engine with per-file pattern matching. - Multi-language documentation (English, Chinese, Japanese, Korean, Russian). @@ -33,15 +30,13 @@ OpenCodeReview currently provides: PyCharm, and other JetBrains IDEs with the same capabilities as the existing VSCode extension. -### Delegate Mode +### MCP Integration -- **Subscription-friendly review** — An opt-in mode where `ocr` no longer - depends on a separately-configured LLM endpoint. Instead of calling an - LLM itself, `ocr` resolves the review scope, applies excludes, loads - review rules, injects background context, and collects the diffs, then - hands that off as a structured review task for the host coding agent - (e.g. Claude Code) to execute using its own agent loop and included - subscription usage — removing the need for a standalone API key. +- **Standard MCP server** — Expose OpenCodeReview as a + [Model Context Protocol](https://modelcontextprotocol.io/) server, + allowing users to integrate external context tools (documentation + retrieval, issue trackers, internal knowledge bases) into the review + process through the standard MCP interface. ### Ultra Mode diff --git a/action.yml b/action.yml deleted file mode 100644 index 203b2e2..0000000 --- a/action.yml +++ /dev/null @@ -1,305 +0,0 @@ -name: OpenCodeReview PR Review -description: >- - AI-powered GitHub PR review with inline comments, sticky summary, and - incremental non-destructive posting. -author: alibaba -branding: - icon: eye - color: green - -inputs: - llm_url: - description: LLM API endpoint URL (mapped to env OCR_LLM_URL). - required: true - llm_auth_token: - description: LLM auth token (mapped to env OCR_LLM_TOKEN). - required: true - llm_model: - description: Model name (mapped to env OCR_LLM_MODEL). - required: true - llm_use_anthropic: - description: "'true' for Anthropic Claude, 'false' for OpenAI-compatible APIs - (mapped to env OCR_USE_ANTHROPIC). Required to force an explicit choice." - required: true - llm_auth_header: - description: Custom auth header name (mapped to env OCR_LLM_AUTH_HEADER). - required: false - llm_extra_headers: - description: Extra headers "K=V,K=V" (mapped to env OCR_LLM_EXTRA_HEADERS). - required: false - llm_extra_body: - description: >- - extra_body JSON for LLM requests. No env var exists for this, so it is - written via `ocr config set llm.extra_body`. - required: false - default: '{"thinking": {"type": "disabled"}}' - language: - description: >- - Review output language, written via `ocr config set language` - (e.g. English, 中文). No env var exists for this. - required: false - default: 'English' - llm_timeout: - description: LLM request timeout in seconds (mapped to env OCR_LLM_TIMEOUT). - required: false - github_token: - description: GitHub token used to post review comments. - required: false - default: ${{ github.token }} - ocr_version: - description: npm version spec for @alibaba-group/open-code-review. - required: false - default: latest - review_concurrency: - description: Value passed to `ocr review --concurrency`. - required: false - background: - description: Value passed to `ocr review --background`. - required: false - rule: - description: Path to a custom rules JSON file passed to `ocr review --rule`. - required: false - upload_artifacts: - description: >- - Upload raw JSON result and stderr as workflow artifacts. Must be the - literal string 'true' or 'false' (quoted); the step gates on a string - comparison, so an unquoted YAML boolean will not match. - required: false - default: 'true' - sticky_summary: - description: >- - Summary dimension. true = update an existing summary comment in place - (sticky) instead of posting a new one each run. - required: false - default: 'true' - incremental: - description: >- - Incremental dimension. true = only append inline comments whose (path, - line range) does not overlap an existing bot review comment. History is - never deleted (non-destructive). - required: false - default: 'false' - incremental_overlap_threshold: - description: >- - IoU (intersection-over-union) threshold used by incremental mode to decide - whether a new multi-line comment overlaps an existing one. Two single-line - comments match when on the same line; single- vs multi-line never match. - Value in (0, 1]; ignored unless incremental is true. - required: false - default: '0.6' - base_ref: - description: >- - Override the base ref. Provide this (and head_sha) when invoking from a - non-PR event such as issue_comment. - required: false - head_sha: - description: Override the head commit SHA (use with base_ref for comment triggers). - required: false - node_version: - description: Node.js version for actions/setup-node. - required: false - default: '24' - -outputs: - comments_total: - description: Total number of review comments generated by OCR. - value: ${{ steps.post.outputs.comments_total }} - comments_inline: - description: Number of inline comments successfully posted. - value: ${{ steps.post.outputs.comments_inline }} - comments_skipped: - description: Number of inline comments skipped by incremental mode (overlap with history). - value: ${{ steps.post.outputs.comments_skipped }} - comments_failed: - description: Number of inline comments that failed to post. - value: ${{ steps.post.outputs.comments_failed }} - summary_comment_url: - description: URL of the posted/updated summary comment, if any. - value: ${{ steps.post.outputs.summary_comment_url }} - -runs: - using: composite - steps: - - name: Check git and Node.js - id: check_deps - shell: bash - run: | - if command -v git >/dev/null 2>&1; then - echo "git_installed=true" >> "$GITHUB_OUTPUT" - echo "git is already installed: $(git --version)" - else - echo "git_installed=false" >> "$GITHUB_OUTPUT" - echo "git is not installed" - fi - if command -v node >/dev/null 2>&1; then - echo "node_installed=true" >> "$GITHUB_OUTPUT" - echo "node is already installed: $(node --version)" - else - echo "node_installed=false" >> "$GITHUB_OUTPUT" - echo "node is not installed" - fi - - - name: Install git - if: steps.check_deps.outputs.git_installed != 'true' - shell: bash - run: | - if command -v apt-get >/dev/null 2>&1; then - sudo apt-get update - sudo apt-get install -y git - elif command -v brew >/dev/null 2>&1; then - brew install git - elif command -v yum >/dev/null 2>&1; then - sudo yum install -y git - elif command -v apk >/dev/null 2>&1; then - sudo apk add --no-cache git - else - echo "::error::Unable to install git: no supported package manager found" - exit 1 - fi - git --version - - - name: Setup Node.js - if: steps.check_deps.outputs.node_installed != 'true' - uses: actions/setup-node@v4 - with: - node-version: ${{ inputs.node_version }} - - - name: Resolve PR refs - shell: bash - env: - INPUT_BASE_REF: ${{ inputs.base_ref }} - INPUT_HEAD_SHA: ${{ inputs.head_sha }} - EVENT_BASE_REF: ${{ github.event.pull_request.base.ref }} - EVENT_HEAD_SHA: ${{ github.event.pull_request.head.sha }} - run: | - BASE_REF="${INPUT_BASE_REF:-$EVENT_BASE_REF}" - HEAD_SHA="${INPUT_HEAD_SHA:-$EVENT_HEAD_SHA}" - echo "BASE_REF=$BASE_REF" >> "$GITHUB_ENV" - echo "HEAD_SHA=$HEAD_SHA" >> "$GITHUB_ENV" - echo "PR base ref: $BASE_REF" - echo "PR head sha: $HEAD_SHA" - - - name: Checkout base - uses: actions/checkout@v4 - with: - # Checkout the trusted base, not the PR head. OCR reviews the - # base-to-head diff from git objects; the head commit's blobs are - # fetched separately so they are resolvable without materializing - # untrusted PR files into the working tree. - fetch-depth: 0 - - - name: Fetch PR head (fork-safe) - if: env.HEAD_SHA != '' - shell: bash - env: - PR_NUM: ${{ github.event.pull_request.number || github.event.issue.number }} - run: | - if [ -n "$PR_NUM" ]; then - git fetch origin "pull/${PR_NUM}/head" - fi - - - name: Compute merge-base - shell: bash - run: | - git fetch origin "${BASE_REF}" 2>/dev/null || true - MERGE_BASE=$(git merge-base "origin/${BASE_REF}" "${HEAD_SHA}" 2>/dev/null || echo "${HEAD_SHA}") - echo "MERGE_BASE=$MERGE_BASE" >> "$GITHUB_ENV" - echo "Reviewing ${HEAD_SHA} from merge-base ${MERGE_BASE} (base origin/${BASE_REF})" - - - name: Install OpenCodeReview - shell: bash - env: - OCR_VERSION: ${{ inputs.ocr_version }} - run: | - npm install -g "@alibaba-group/open-code-review@${OCR_VERSION}" - echo "OpenCodeReview installed:" - ocr version || true - - - name: Configure OCR - env: - OCR_EXTRA_BODY: ${{ inputs.llm_extra_body }} - OCR_LANGUAGE: ${{ inputs.language }} - shell: bash - run: | - ocr config set llm.extra_body "$OCR_EXTRA_BODY" - ocr config set language "$OCR_LANGUAGE" - - - name: Run OpenCodeReview - env: - OCR_LLM_URL: ${{ inputs.llm_url }} - OCR_LLM_TOKEN: ${{ inputs.llm_auth_token }} - OCR_LLM_MODEL: ${{ inputs.llm_model }} - OCR_USE_ANTHROPIC: ${{ inputs.llm_use_anthropic }} - OCR_LLM_AUTH_HEADER: ${{ inputs.llm_auth_header }} - OCR_LLM_EXTRA_HEADERS: ${{ inputs.llm_extra_headers }} - OCR_LLM_TIMEOUT: ${{ inputs.llm_timeout }} - OCR_REVIEW_CONCURRENCY: ${{ inputs.review_concurrency }} - OCR_BACKGROUND: ${{ inputs.background }} - OCR_RULE: ${{ inputs.rule }} - shell: bash - run: | - ARGS=(--from "${MERGE_BASE}" --to "${HEAD_SHA}" --format json) - [ -n "$OCR_REVIEW_CONCURRENCY" ] && ARGS+=(--concurrency "$OCR_REVIEW_CONCURRENCY") - [ -n "$OCR_BACKGROUND" ] && ARGS+=(--background "$OCR_BACKGROUND") - [ -n "$OCR_RULE" ] && ARGS+=(--rule "$OCR_RULE") - set +e - ocr review "${ARGS[@]}" > /tmp/ocr-result.json 2>/tmp/ocr-stderr.log - OCR_EXIT_CODE=$? - set -e - echo "OCR_EXIT_CODE=$OCR_EXIT_CODE" >> "$GITHUB_ENV" - echo "=== OCR result ===" - cat /tmp/ocr-result.json - echo "=== OCR stderr ===" - cat /tmp/ocr-stderr.log - - - name: Upload review artifacts - if: ${{ always() && inputs.upload_artifacts == 'true' }} - uses: actions/upload-artifact@v4 - with: - name: ocr-review-result-${{ github.run_id }}-${{ github.run_attempt }} - path: | - /tmp/ocr-result.json - /tmp/ocr-stderr.log - if-no-files-found: warn - - - name: Fail job on OCR error - if: env.OCR_EXIT_CODE != '0' - shell: bash - run: | - echo "ocr review exited with code ${OCR_EXIT_CODE}; see uploaded artifacts for details." - exit "${OCR_EXIT_CODE}" - - - name: Post review comments - if: env.OCR_EXIT_CODE == '0' - id: post - uses: actions/github-script@v7 - env: - OCR_INCREMENTAL_OVERLAP_THRESHOLD: ${{ inputs.incremental_overlap_threshold }} - with: - github-token: ${{ inputs.github_token }} - script: | - // Locate the helper shipped alongside action.yml at runtime. - // GITHUB_ACTION_PATH: correct for published (remote) actions and for - // local actions when not running in a container. - // GITHUB_WORKSPACE: correct for local `uses: ./` actions — under - // self-hosted + container setups, GITHUB_ACTION_PATH points to the - // host path (invisible inside the container), whereas GITHUB_WORKSPACE - // is correctly mapped to /__w. - const fs = require('fs'); - const path = require('path'); - const REL = 'scripts/github-actions/post-review-comments.js'; - const roots = [process.env.GITHUB_ACTION_PATH, process.env.GITHUB_WORKSPACE].filter(Boolean); - const helper = roots.map(r => path.resolve(r, REL)).find(p => fs.existsSync(p)); - if (!helper) throw new Error(`Could not locate ${REL}; searched roots: ${roots.join(', ')}`); - const { runPostReviewComments } = require(helper); - await runPostReviewComments({ - github, - context, - core, - fs, - resultPath: '/tmp/ocr-result.json', - stderrPath: '/tmp/ocr-stderr.log', - stickySummary: ${{ inputs.sticky_summary == 'true' }}, - incremental: ${{ inputs.incremental == 'true' }}, - incrementalOverlapThreshold: parseFloat(process.env.OCR_INCREMENTAL_OVERLAP_THRESHOLD), - }); diff --git a/cmd/opencodereview/background_file.go b/cmd/opencodereview/background_file.go deleted file mode 100644 index 2c14a5c..0000000 --- a/cmd/opencodereview/background_file.go +++ /dev/null @@ -1,135 +0,0 @@ -package main - -import ( - "fmt" - "os" - "regexp" - "strings" - "unicode" -) - -const ( - backgroundSoftLimit = 2000 - backgroundHardLimit = 8000 - backgroundOpenTag = "" - backgroundCloseTag = "" - maxBackgroundFileBytes = 1 << 20 // 1 MB -) - -var multiNewline = regexp.MustCompile(`\n{3,}`) - -// mergeBackground combines the inline --background value (or an auto-populated -// commit message) with the content read from --background-file, separated by a -// blank line. The inline value is sanitised the same way as the file content so -// both portions are cleaned consistently. The file content is already wrapped -// and sanitised by loadBackgroundFile. -func mergeBackground(inline, fromFile string) string { - inline = sanitizeMarkdown(inline) - switch { - case inline == "": - return fromFile - case fromFile == "": - return inline - default: - return inline + "\n\n" + fromFile - } -} - -func loadBackgroundFile(path string) (string, error) { - info, err := os.Stat(path) - if err != nil { - return "", fmt.Errorf("read background file %q: %w", path, err) - } - if info.IsDir() { - return "", fmt.Errorf("background file %q is a directory, not a file", path) - } - if info.Size() > maxBackgroundFileBytes { - return "", fmt.Errorf( - "background file %q is %d bytes, exceeding the maximum of %d bytes; please provide a smaller file", - path, info.Size(), maxBackgroundFileBytes, - ) - } - - raw, err := os.ReadFile(path) - if err != nil { - return "", fmt.Errorf("read background file %q: %w", path, err) - } - - cleaned := sanitizeMarkdown(string(raw)) - if cleaned == "" { - return "", fmt.Errorf("background file %q is empty after sanitisation", path) - } - - if strings.Contains(cleaned, backgroundOpenTag) || strings.Contains(cleaned, backgroundCloseTag) { - return "", fmt.Errorf( - "background file %q must not contain the reserved delimiters %q or %q", - path, backgroundOpenTag, backgroundCloseTag, - ) - } - - // Enforce the limits on the cleaned content only: the wrapper delimiters add - // overhead the user cannot control, so counting them would make the reported - // character count misleading. - if n := len([]rune(cleaned)); n > backgroundHardLimit { - return "", fmt.Errorf( - "background content is %d characters, exceeding the hard limit of %d (aborting)", - n, backgroundHardLimit, - ) - } else if n > backgroundSoftLimit { - fmt.Fprintf(os.Stderr, - "[ocr] --background-file content is %d characters, exceeding the recommended %d (continuing but review quality might be impacted)\n", - n, backgroundSoftLimit, - ) - } - - return backgroundOpenTag + "\n" + cleaned + "\n" + backgroundCloseTag, nil -} - -func sanitizeMarkdown(s string) string { - var b strings.Builder - b.Grow(len(s)) - - for _, r := range s { - switch r { - case '\n', '\t': - b.WriteRune(r) - continue - case '\r': - continue - } - if isForbiddenChar(r) { - continue - } - b.WriteRune(r) - } - - collapsed := multiNewline.ReplaceAllString(b.String(), "\n\n") - return strings.TrimSpace(collapsed) -} - -func isForbiddenChar(r rune) bool { - switch { - case r <= 0x1F: // C0 control characters (includes NUL) - return true - case r >= 0x7F && r <= 0x9F: // DEL and C1 control characters - return true - } - - // The runes below all belong to Unicode category Cf and are therefore - // already caught by the unicode.Is(unicode.Cf, r) check at the end. They are - // listed explicitly only as documentation of the most common invisible - // characters we strip; the switch is redundant, not a correctness necessity. - switch r { - case '\u200B', // zero-width space - '\u200C', // zero-width non-joiner - '\u200D', // zero-width joiner - '\u200E', // left-to-right mark - '\u200F', // right-to-left mark - '\u2060', // word joiner - '\u00AD', // soft hyphen - '\uFEFF': // BOM / zero-width no-break space - return true - } - - return unicode.Is(unicode.Cf, r) -} diff --git a/cmd/opencodereview/background_file_test.go b/cmd/opencodereview/background_file_test.go deleted file mode 100644 index cb18f21..0000000 --- a/cmd/opencodereview/background_file_test.go +++ /dev/null @@ -1,312 +0,0 @@ -package main - -import ( - "os" - "os/exec" - "path/filepath" - "strings" - "testing" -) - -// writeTempFile writes content to a temporary file and returns its path. -func writeTempFile(t *testing.T, content string) string { - t.Helper() - path := filepath.Join(t.TempDir(), "background.md") - if err := os.WriteFile(path, []byte(content), 0o600); err != nil { - t.Fatalf("write temp file: %v", err) - } - return path -} - -func TestLoadBackgroundFileNotFound(t *testing.T) { - _, err := loadBackgroundFile(filepath.Join(t.TempDir(), "does-not-exist.md")) - if err == nil { - t.Fatal("expected an error for a missing file, got nil") - } -} - -func TestLoadBackgroundFileEmpty(t *testing.T) { - cases := map[string]string{ - "zero bytes": "", - "whitespace only": " \n\t \n ", - "invisible only": "\u200B\u200E\u00AD\uFEFF", - } - for name, content := range cases { - t.Run(name, func(t *testing.T) { - _, err := loadBackgroundFile(writeTempFile(t, content)) - if err == nil { - t.Fatal("expected an error for empty-after-sanitisation content, got nil") - } - if !strings.Contains(err.Error(), "empty") { - t.Errorf("error = %q, want it to mention 'empty'", err) - } - }) - } -} - -func TestLoadBackgroundFileControlCharRemoval(t *testing.T) { - // Mix in NUL, bell, DEL, a C1 control char, zero-width space, BOM and an - // LTR mark around legitimate text. - content := "Hello\x00\x07world\x7f\u0085!\u200B\uFEFF\u200E" - got, err := loadBackgroundFile(writeTempFile(t, content)) - if err != nil { - t.Fatalf("loadBackgroundFile: %v", err) - } - for _, bad := range []string{"\x00", "\x07", "\x7f", "\u0085", "\u200B", "\uFEFF", "\u200E"} { - if strings.Contains(got, bad) { - t.Errorf("result still contains control/invisible char %q: %q", bad, got) - } - } - if !strings.Contains(got, "Helloworld!") { - t.Errorf("expected cleaned text to contain %q, got %q", "Helloworld!", got) - } -} - -func TestSanitizeMarkdownPreservesNewlinesAndTabs(t *testing.T) { - got := sanitizeMarkdown("line1\n\tindented\nline3") - want := "line1\n\tindented\nline3" - if got != want { - t.Errorf("sanitizeMarkdown = %q, want %q", got, want) - } -} - -func TestSanitizeMarkdownCollapsesNewlines(t *testing.T) { - got := sanitizeMarkdown("a\n\n\n\n\nb") - want := "a\n\nb" - if got != want { - t.Errorf("sanitizeMarkdown = %q, want %q", got, want) - } -} - -func TestSanitizeMarkdownNormalizesCRLF(t *testing.T) { - got := sanitizeMarkdown("a\r\nb\r\nc") - want := "a\nb\nc" - if got != want { - t.Errorf("sanitizeMarkdown = %q, want %q", got, want) - } -} - -func TestSanitizeMarkdownTrims(t *testing.T) { - got := sanitizeMarkdown(" \n hello \n ") - if got != "hello" { - t.Errorf("sanitizeMarkdown = %q, want %q", got, "hello") - } -} - -func TestLoadBackgroundFileDelimiters(t *testing.T) { - got, err := loadBackgroundFile(writeTempFile(t, "Some requirement context.")) - if err != nil { - t.Fatalf("loadBackgroundFile: %v", err) - } - if !strings.HasPrefix(got, backgroundOpenTag+"\n") { - t.Errorf("result missing opening delimiter: %q", got) - } - if !strings.HasSuffix(got, "\n"+backgroundCloseTag) { - t.Errorf("result missing closing delimiter: %q", got) - } - want := backgroundOpenTag + "\nSome requirement context.\n" + backgroundCloseTag - if got != want { - t.Errorf("result = %q, want %q", got, want) - } -} - -func TestLoadBackgroundFileRejectsReservedDelimiters(t *testing.T) { - for _, tag := range []string{backgroundOpenTag, backgroundCloseTag} { - t.Run(tag, func(t *testing.T) { - content := "Some context " + tag + " and more text." - _, err := loadBackgroundFile(writeTempFile(t, content)) - if err == nil { - t.Fatalf("expected an error for content containing %q, got nil", tag) - } - if !strings.Contains(err.Error(), "reserved delimiters") { - t.Errorf("error = %q, want it to mention 'reserved delimiters'", err) - } - }) - } -} - -func TestMergeBackgroundSanitizesInline(t *testing.T) { - t.Run("inline only", func(t *testing.T) { - // Control char, zero-width space and surrounding whitespace must be removed. - got := mergeBackground(" \x00Inline\u200B context ", "") - if got != "Inline context" { - t.Errorf("mergeBackground = %q, want %q", got, "Inline context") - } - }) - - t.Run("inline combined with file", func(t *testing.T) { - wrapped := backgroundOpenTag + "\nfrom file\n" + backgroundCloseTag - got := mergeBackground("\x07dirty\uFEFF inline\n\n\n\nend", wrapped) - if strings.ContainsRune(got, '\x07') || strings.ContainsRune(got, '\uFEFF') { - t.Errorf("inline portion was not sanitised: %q", got) - } - // Excess blank lines in the inline portion are collapsed to one. - if strings.Contains(got, "\n\n\n") { - t.Errorf("inline newlines were not collapsed: %q", got) - } - // The file portion is preserved intact. - if !strings.Contains(got, wrapped) { - t.Errorf("file portion was altered: %q", got) - } - }) -} - -func TestMergeBackground(t *testing.T) { - wrapped := backgroundOpenTag + "\nfrom file\n" + backgroundCloseTag - - t.Run("both present are combined", func(t *testing.T) { - got := mergeBackground("inline context", wrapped) - want := "inline context\n\n" + wrapped - if got != want { - t.Errorf("mergeBackground = %q, want %q", got, want) - } - // Both inputs must survive in the result. - if !strings.Contains(got, "inline context") || !strings.Contains(got, "from file") { - t.Errorf("merged background dropped one of the inputs: %q", got) - } - }) - - t.Run("inline only", func(t *testing.T) { - if got := mergeBackground("inline only", ""); got != "inline only" { - t.Errorf("mergeBackground = %q, want %q", got, "inline only") - } - }) - - t.Run("file only", func(t *testing.T) { - if got := mergeBackground("", wrapped); got != wrapped { - t.Errorf("mergeBackground = %q, want %q", got, wrapped) - } - }) -} - -func TestLoadBackgroundFileSoftLimit(t *testing.T) { - // Just above the soft limit but below the hard size limit: must succeed. - content := strings.Repeat("a", backgroundSoftLimit+100) - got, err := loadBackgroundFile(writeTempFile(t, content)) - if err != nil { - t.Fatalf("loadBackgroundFile: %v", err) - } - if !strings.Contains(got, content) { - t.Error("expected content to be preserved past the soft limit") - } -} - -func TestLoadBackgroundFileOversized(t *testing.T) { - // A file larger than maxBackgroundFileBytes must be rejected up front, - // before its content is read into memory. - content := strings.Repeat("a", maxBackgroundFileBytes+1) - _, err := loadBackgroundFile(writeTempFile(t, content)) - if err == nil { - t.Fatal("expected an error for an oversized file, got nil") - } - if !strings.Contains(err.Error(), "maximum") { - t.Errorf("error = %q, want it to mention the byte 'maximum'", err) - } -} - -func TestLoadBackgroundFileDirectory(t *testing.T) { - if _, err := loadBackgroundFile(t.TempDir()); err == nil { - t.Fatal("expected an error when the path is a directory, got nil") - } -} - -func TestLoadBackgroundFileHardLimit(t *testing.T) { - content := strings.Repeat("a", backgroundHardLimit+1) - _, err := loadBackgroundFile(writeTempFile(t, content)) - if err == nil { - t.Fatal("expected an error when exceeding the hard size limit, got nil") - } - if !strings.Contains(err.Error(), "hard limit") { - t.Errorf("error = %q, want it to mention 'hard limit'", err) - } -} - -func TestLoadBackgroundFileHardLimitExcludesWrapper(t *testing.T) { - // The wrapper delimiters must NOT count toward the limit: cleaned content of - // exactly the hard limit is accepted even though the wrapped string is longer. - content := strings.Repeat("a", backgroundHardLimit) - if _, err := loadBackgroundFile(writeTempFile(t, content)); err != nil { - t.Fatalf("cleaned content at the hard limit must be accepted, got: %v", err) - } -} - -func TestLoadBackgroundFileMultiByteRuneCount(t *testing.T) { - // Multi-byte runes must be counted as single characters, not bytes. - // A precomposed accented letter is one rune but two bytes; a string of exactly - // backgroundHardLimit runes (~2x the byte count) must still be accepted. - content := strings.Repeat("\u00E9", backgroundHardLimit) - got, err := loadBackgroundFile(writeTempFile(t, content)) - if err != nil { - t.Fatalf("loadBackgroundFile rejected content within the rune limit: %v", err) - } - if !strings.Contains(got, content) { - t.Error("expected multi-byte content to be preserved") - } -} - -// initRepoWithCommit creates a real git repository with a single commit whose -// message is `message`, and returns the repo directory and the commit hash. -func initRepoWithCommit(t *testing.T, message string) (string, string) { - t.Helper() - repo := t.TempDir() - run := func(args ...string) []byte { - cmd := exec.Command("git", args...) - cmd.Dir = repo - out, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("git %v failed: %v\n%s", args, err, out) - } - return out - } - run("init", "-q") - run("config", "user.email", "test@example.com") - run("config", "user.name", "Test") - run("config", "commit.gpgsign", "false") - if err := os.WriteFile(filepath.Join(repo, "file.txt"), []byte("hello\n"), 0o600); err != nil { - t.Fatalf("write file: %v", err) - } - run("add", ".") - run("commit", "-q", "-m", message) - hash := strings.TrimSpace(string(run("rev-parse", "HEAD"))) - return repo, hash -} - -// TestBackgroundFromCommitThenFile reproduces the resolution order used by -// runReview when --background-file is supplied but --background is not: the -// inline background is first auto-filled from the commit message, then the -// background file is appended. Both must end up in the final background. -func TestBackgroundFromCommitThenFile(t *testing.T) { - const commitMsg = "Implement rate limiting on login" - repo, hash := initRepoWithCommit(t, commitMsg) - - // Mirror runReview: --background empty + --commit set -> use commit message. - background := "" - msg, err := getCommitMessage(repo, hash) - if err != nil { - t.Fatalf("getCommitMessage: %v", err) - } - if msg != commitMsg { - t.Fatalf("commit message = %q, want %q", msg, commitMsg) - } - if background == "" { - background = msg - } - - // Then --background-file is loaded and merged in. - fileBg, err := loadBackgroundFile(writeTempFile(t, "Extra context from a file.")) - if err != nil { - t.Fatalf("loadBackgroundFile: %v", err) - } - background = mergeBackground(background, fileBg) - - // The commit message must come first, followed by the wrapped file content. - if !strings.HasPrefix(background, commitMsg+"\n\n") { - t.Errorf("expected commit message to lead the background, got %q", background) - } - if !strings.Contains(background, "Extra context from a file.") { - t.Errorf("expected file content to be appended, got %q", background) - } - if !strings.Contains(background, backgroundOpenTag) || !strings.Contains(background, backgroundCloseTag) { - t.Errorf("expected file content to keep its delimiters, got %q", background) - } -} diff --git a/cmd/opencodereview/config_cmd.go b/cmd/opencodereview/config_cmd.go index a7c1341..64b511b 100644 --- a/cmd/opencodereview/config_cmd.go +++ b/cmd/opencodereview/config_cmd.go @@ -480,13 +480,23 @@ func ensureModelInList(models []string, model string) []string { if model == "" { return models } - if llm.ModelListContains(models, model) { + if 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 f153fe2..4082ca2 100644 --- a/cmd/opencodereview/emit_run_result_test.go +++ b/cmd/opencodereview/emit_run_result_test.go @@ -7,9 +7,6 @@ import ( "testing" "time" - "go.opentelemetry.io/otel" - sdktrace "go.opentelemetry.io/otel/sdk/trace" - "github.com/open-code-review/open-code-review/internal/agent" "github.com/open-code-review/open-code-review/internal/model" ) @@ -25,8 +22,6 @@ type mockResultProvider struct { warnings []agent.AgentWarning projectSummary string toolCalls map[string]int64 - resumeInfo *agent.ResumeInfo - sessionID string } func (m *mockResultProvider) Diffs() []model.Diff { return m.diffs } @@ -39,8 +34,6 @@ func (m *mockResultProvider) TotalCacheWriteTokens() int64 { return m.cacheWri func (m *mockResultProvider) Warnings() []agent.AgentWarning { return m.warnings } func (m *mockResultProvider) ProjectSummary() string { return m.projectSummary } func (m *mockResultProvider) ToolCalls() map[string]int64 { return m.toolCalls } -func (m *mockResultProvider) ResumeInfo() *agent.ResumeInfo { return m.resumeInfo } -func (m *mockResultProvider) SessionID() string { return m.sessionID } func TestEmitRunResult_JSONNoFiles(t *testing.T) { ag := &mockResultProvider{filesReviewed: 0} @@ -87,32 +80,6 @@ func TestEmitRunResult_JSONWithComments(t *testing.T) { } } -func TestEmitRunResult_JSONWithResumeInfo(t *testing.T) { - ag := &mockResultProvider{ - filesReviewed: 2, - resumeInfo: &agent.ResumeInfo{ - ResumedFrom: "old-session", - ReusedFiles: 1, - RerunFiles: 1, - PreviousModel: "anthropic-model", - CurrentModel: "openai-model", - }, - } - got := captureStdout(t, func() { - err := emitRunResult(context.Background(), ag, nil, time.Now(), "json", "developer", nil) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - }) - var out jsonOutput - if err := json.Unmarshal([]byte(got), &out); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if out.Resume == nil || out.Resume.ResumedFrom != "old-session" || out.Resume.ReusedFiles != 1 || out.Resume.RerunFiles != 1 { - t.Fatalf("resume = %+v", out.Resume) - } -} - func TestEmitRunResult_TextNoComments(t *testing.T) { ag := &mockResultProvider{filesReviewed: 2} got := captureStdout(t, func() { @@ -126,19 +93,6 @@ func TestEmitRunResult_TextNoComments(t *testing.T) { } } -func TestEmitRunResult_TextDoesNotPrintSuccessfulSessionHint(t *testing.T) { - ag := &mockResultProvider{filesReviewed: 2, sessionID: "session-123"} - got := captureStdout(t, func() { - err := emitRunResult(context.Background(), ag, nil, time.Now(), "text", "developer", nil) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - }) - if strings.Contains(got, "session-123") || strings.Contains(got, "--resume") { - t.Fatalf("successful text output should not print session ID or resume hint, got %q", got) - } -} - func TestEmitRunResult_TextWithComments(t *testing.T) { ag := &mockResultProvider{filesReviewed: 1} comments := []model.LlmComment{{Path: "a.go", Content: "rename", StartLine: 5, EndLine: 10}} @@ -221,78 +175,3 @@ func TestEmitRunResult_NilQuietHandle(t *testing.T) { }) _ = got } - -func TestEmitRunResult_JSONTraceIDFromContext(t *testing.T) { - tp := sdktrace.NewTracerProvider() - defer func() { _ = tp.Shutdown(context.Background()) }() - otel.SetTracerProvider(tp) - - ctx, span := tp.Tracer("test").Start(context.Background(), "test-root") - wantTraceID := span.SpanContext().TraceID().String() - defer span.End() - - ag := &mockResultProvider{ - filesReviewed: 2, - inputTokens: 10, - outputTokens: 5, - totalTokens: 15, - } - got := captureStdout(t, func() { - err := emitRunResult(ctx, ag, nil, time.Now(), "json", "developer", nil) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - }) - var out jsonOutput - if err := json.Unmarshal([]byte(got), &out); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if out.TraceID != wantTraceID { - t.Errorf("trace_id = %q, want %q", out.TraceID, wantTraceID) - } -} - -func TestEmitRunResult_JSONNoFilesTraceID(t *testing.T) { - tp := sdktrace.NewTracerProvider() - defer func() { _ = tp.Shutdown(context.Background()) }() - otel.SetTracerProvider(tp) - - ctx, span := tp.Tracer("test").Start(context.Background(), "test-root") - wantTraceID := span.SpanContext().TraceID().String() - defer span.End() - - ag := &mockResultProvider{filesReviewed: 0} - got := captureStdout(t, func() { - err := emitRunResult(ctx, ag, nil, time.Now(), "json", "developer", nil) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - }) - var out jsonOutput - if err := json.Unmarshal([]byte(got), &out); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if out.Status != "skipped" { - t.Errorf("status = %q, want skipped", out.Status) - } - if out.TraceID != wantTraceID { - t.Errorf("trace_id = %q, want %q", out.TraceID, wantTraceID) - } -} - -func TestEmitRunResult_JSONIncludesSessionID(t *testing.T) { - ag := &mockResultProvider{filesReviewed: 1, sessionID: "session-99"} - got := captureStdout(t, func() { - err := emitRunResult(context.Background(), ag, nil, time.Now(), "json", "developer", nil) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - }) - var out jsonOutput - if err := json.Unmarshal([]byte(got), &out); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if out.SessionID != "session-99" { - t.Errorf("session_id = %q, want session-99", out.SessionID) - } -} diff --git a/cmd/opencodereview/flags.go b/cmd/opencodereview/flags.go index 1f72bb1..ccda515 100644 --- a/cmd/opencodereview/flags.go +++ b/cmd/opencodereview/flags.go @@ -101,12 +101,10 @@ type reviewOptions struct { from string to string commit string - resume string excludes string // --exclude: comma-separated gitignore-style patterns outputFormat string audience string // --audience: "human" (default) or "agent" background string // --background: optional requirement context - backgroundFile string // --background-file: path to a Markdown file used as background model string // --model: override resolved LLM model for this review concurrency int perFileTimeout int @@ -127,14 +125,12 @@ func parseReviewFlags(args []string) (reviewOptions, error) { a.StringVar(&opts.from, "from", "", "source ref to start diff from (e.g., 'main')") a.StringVar(&opts.to, "to", "", "target ref to end diff at (e.g., 'feature-branch')") a.StringVarP(&opts.commit, "commit", "c", "", "single commit hash or tag to review (vs its parent)") - a.StringVar(&opts.resume, "resume", "", "resume from a previous review session id") a.StringVar(&opts.excludes, "exclude", "", "comma-separated gitignore-style patterns to exclude; merged with rule.json excludes") a.StringVarP(&opts.outputFormat, "format", "f", "text", "output format: text or json") a.IntVar(&opts.concurrency, "concurrency", 8, "max concurrent file reviews") a.IntVar(&opts.perFileTimeout, "timeout", 10, "concurrent task timeout in minutes") a.StringVar(&opts.audience, "audience", "human", "output audience: human (show progress) or agent (summary only)") a.StringVarP(&opts.background, "background", "b", "", "optional requirement/business context for the review") - a.StringVarP(&opts.backgroundFile, "background-file", "B", "", "optional requirement/business context from a Markdown file (combined with --background; inline value appears first when both are set)") a.StringVar(&opts.model, "model", "", "override LLM model for this review (e.g., claude-opus-4-6)") a.IntVar(&opts.maxTools, "max-tools", 0, "max tool call rounds per file (0 = template default; min 10)") a.IntVar(&opts.maxGitProcs, "max-git-procs", 16, "max concurrent git subprocesses") @@ -166,9 +162,6 @@ func parseReviewFlags(args []string) (reviewOptions, error) { if opts.to != "" && opts.from == "" { return opts, fmt.Errorf("--from is required when --to is specified") } - if opts.preview && opts.resume != "" { - return opts, fmt.Errorf("--preview and --resume cannot be used together") - } switch opts.audience { case "human", "agent": @@ -210,9 +203,6 @@ Examples: ocr review --commit abc123 ocr review -c abc123 - # Resume a previous range review - ocr review --from master --to dev-ref --resume - # Output JSON format ocr review --format json ocr review -f json @@ -224,29 +214,22 @@ Examples: ocr review --preview ocr review -c abc123 -p - # Provide requirement/business context inline, from a Markdown file, or both - ocr review --background "Adding rate limiting to the login API" - ocr review --background-file ./docs/requirements.md - ocr review --background "Focus on auth" --background-file ./docs/requirements.md - Flags: - --audience string output audience: human (show progress) or agent (summary only) (default "human") - -b, --background string optional requirement/business context for the review - -B, --background-file string path to a Markdown file used as review background (combined with --background; inline value appears first when both are set) - -c, --commit string single commit hash or tag to review (vs its parent) - -f, --format string output format: text or json (default "text") - --concurrency int max concurrent file reviews (default 8) - --max-git-procs int max concurrent git subprocesses (default 16) - --from string source ref to start diff from (e.g., 'main') - --max-tools int max tool call rounds per file (0 = template default; min 10) - --model string override LLM model for this review (e.g., claude-opus-4-6) - -p, --preview preview which files will be reviewed without running the LLM - --repo string root directory of the git repository (default: current dir) - --resume string resume from a previous review session id - --rule string path to JSON file with system review rules - --timeout int concurrent task timeout in minutes (default 10) - --to string target ref to end diff at (e.g., 'feature-branch') - --tools string path to JSON tools config file (default: embedded)`) + --audience string output audience: human (show progress) or agent (summary only) (default "human") + -b, --background string optional requirement/business context for the review + -c, --commit string single commit hash or tag to review (vs its parent) + -f, --format string output format: text or json (default "text") + --concurrency int max concurrent file reviews (default 8) + --max-git-procs int max concurrent git subprocesses (default 16) + --from string source ref to start diff from (e.g., 'main') + --max-tools int max tool call rounds per file (0 = template default; min 10) + --model string override LLM model for this review (e.g., claude-opus-4-6) + -p, --preview preview which files will be reviewed without running the LLM + --repo string root directory of the git repository (default: current dir) + --rule string path to JSON file with system review rules + --timeout int concurrent task timeout in minutes (default 10) + --to string target ref to end diff at (e.g., 'feature-branch') + --tools string path to JSON tools config file (default: embedded)`) } // --- config subcommand --- diff --git a/cmd/opencodereview/flags_test.go b/cmd/opencodereview/flags_test.go index 8e25996..15701ca 100644 --- a/cmd/opencodereview/flags_test.go +++ b/cmd/opencodereview/flags_test.go @@ -5,20 +5,6 @@ import ( "time" ) -func TestParseReviewFlagsBackgroundFile(t *testing.T) { - for _, flag := range []string{"--background-file", "-B"} { - t.Run(flag, func(t *testing.T) { - opts, err := parseReviewFlags([]string{flag, "./docs/req.md"}) - if err != nil { - t.Fatalf("parseReviewFlags: %v", err) - } - if opts.backgroundFile != "./docs/req.md" { - t.Errorf("backgroundFile = %q, want %q", opts.backgroundFile, "./docs/req.md") - } - }) - } -} - func TestParseReviewFlagsModelOverride(t *testing.T) { opts, err := parseReviewFlags([]string{"--model", "claude-opus-4-6"}) if err != nil { @@ -36,23 +22,6 @@ func TestParseReviewFlagsModelOverride(t *testing.T) { } } -func TestParseReviewFlagsResume(t *testing.T) { - opts, err := parseReviewFlags([]string{"--from", "main", "--to", "feature", "--resume", "session-123"}) - if err != nil { - t.Fatalf("parseReviewFlags: %v", err) - } - if opts.resume != "session-123" { - t.Errorf("resume = %q, want session-123", opts.resume) - } -} - -func TestParseReviewFlags_PreviewWithResume(t *testing.T) { - _, err := parseReviewFlags([]string{"--commit", "abc123", "--preview", "--resume", "session-123"}) - if err == nil { - t.Fatal("expected error for --preview with --resume") - } -} - func TestParseReviewFlags_InvalidAudience(t *testing.T) { _, err := parseReviewFlags([]string{"--audience", "robot"}) if err == nil { diff --git a/cmd/opencodereview/git.go b/cmd/opencodereview/git.go index dba1d6f..0185b56 100644 --- a/cmd/opencodereview/git.go +++ b/cmd/opencodereview/git.go @@ -12,15 +12,6 @@ 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 4528b29..0630a4c 100644 --- a/cmd/opencodereview/git_test.go +++ b/cmd/opencodereview/git_test.go @@ -23,17 +23,11 @@ func initTestGitRepo(t *testing.T) string { } } f := filepath.Join(dir, "README.md") - if err := os.WriteFile(f, []byte("hello"), 0o644); err != nil { - t.Fatalf("write README: %v", err) - } + os.WriteFile(f, []byte("hello"), 0o644) cmd := exec.Command("git", "-C", dir, "add", ".") - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("git add: %v: %s", err, out) - } + cmd.CombinedOutput() cmd = exec.Command("git", "-C", dir, "commit", "-m", "initial commit") - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("git commit: %v: %s", err, out) - } + cmd.CombinedOutput() return dir } @@ -99,18 +93,9 @@ func TestResolveRepoDir_NotGitRepo(t *testing.T) { func TestResolveRepoDir_EmptyUsesWd(t *testing.T) { dir := initTestGitRepo(t) - origDir, err := os.Getwd() - if err != nil { - t.Fatalf("getwd: %v", err) - } - defer func() { - if err := os.Chdir(origDir); err != nil { - t.Errorf("restore chdir: %v", err) - } - }() - if err := os.Chdir(dir); err != nil { - t.Fatalf("chdir: %v", err) - } + origDir, _ := os.Getwd() + defer os.Chdir(origDir) + os.Chdir(dir) resolved, err := resolveRepoDir("") if err != nil { diff --git a/cmd/opencodereview/main.go b/cmd/opencodereview/main.go index 378e22e..fac044e 100644 --- a/cmd/opencodereview/main.go +++ b/cmd/opencodereview/main.go @@ -56,8 +56,6 @@ func dispatch() error { return runRules(args[1:]) case "viewer": return runViewer(args[1:]) - case "session", "sessions": - return runSession(args[1:]) case "-h", "--help": printTopLevelUsage() return nil @@ -79,7 +77,6 @@ Commands: config Manage configuration settings llm LLM utility commands viewer Start the WebUI session viewer - session, sessions List and inspect saved review sessions version Show version information Examples: @@ -92,7 +89,6 @@ Examples: ocr config set llm.model opus-4-6 Set a config value ocr llm test Test LLM connectivity ocr llm providers List built-in providers - ocr session list List saved review sessions ocr version Show version info Use "ocr review -h" for more information about review. @@ -100,7 +96,6 @@ Use "ocr scan -h" for more information about scan. Use "ocr rules -h" for more information about rules. Use "ocr config" for more information about config. Use "ocr llm" for more information about LLM utilities. -Use "ocr session -h" for more information about session inspection. GitHub: https://github.com/alibaba/open-code-review`) } diff --git a/cmd/opencodereview/output.go b/cmd/opencodereview/output.go index 0006164..3ec31c3 100644 --- a/cmd/opencodereview/output.go +++ b/cmd/opencodereview/output.go @@ -61,19 +61,7 @@ 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 != "" { - 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):] - } + for _, ln := range wrapByRunes(sanitizeTerminal(comment.Content), 100) { fmt.Printf("%s\n", ln) } fmt.Println() @@ -95,41 +83,6 @@ 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") @@ -240,15 +193,12 @@ type jsonToolCalls struct { type jsonOutput struct { Status string `json:"status"` - TraceID string `json:"trace_id,omitempty"` Message string `json:"message,omitempty"` Summary *jsonSummary `json:"summary,omitempty"` ToolCalls *jsonToolCalls `json:"tool_calls"` Comments []model.LlmComment `json:"comments"` Warnings []agent.AgentWarning `json:"warnings,omitempty"` ProjectSummary string `json:"project_summary,omitempty"` - Resume *agent.ResumeInfo `json:"resume,omitempty"` - SessionID string `json:"session_id,omitempty"` } func outputJSON(comments []model.LlmComment) error { @@ -266,10 +216,9 @@ func outputJSON(comments []model.LlmComment) error { func outputJSONWithWarnings(comments []model.LlmComment, warnings []agent.AgentWarning, filesReviewed, inputTokens, outputTokens, totalTokens, cacheReadTokens, cacheWriteTokens int64, - duration time.Duration, projectSummary string, toolCalls map[string]int64, traceID string, resumeInfo *agent.ResumeInfo, sessionID string) error { + duration time.Duration, projectSummary string, toolCalls map[string]int64) error { out := jsonOutput{ Status: "success", - TraceID: traceID, Comments: comments, Summary: &jsonSummary{ FilesReviewed: filesReviewed, @@ -282,8 +231,6 @@ func outputJSONWithWarnings(comments []model.LlmComment, warnings []agent.AgentW Elapsed: duration.Round(time.Second).String(), }, ProjectSummary: projectSummary, - Resume: resumeInfo, - SessionID: sessionID, } var total int64 for _, v := range toolCalls { @@ -317,10 +264,9 @@ func outputJSONWithWarnings(comments []model.LlmComment, warnings []agent.AgentW return enc.Encode(out) } -func outputJSONNoFiles(traceID string) error { +func outputJSONNoFiles() error { out := jsonOutput{ Status: "skipped", - TraceID: traceID, Message: "No supported files changed.", Comments: []model.LlmComment{}, ToolCalls: &jsonToolCalls{ diff --git a/cmd/opencodereview/output_helpers_test.go b/cmd/opencodereview/output_helpers_test.go index 80520b2..d0d25b2 100644 --- a/cmd/opencodereview/output_helpers_test.go +++ b/cmd/opencodereview/output_helpers_test.go @@ -168,8 +168,9 @@ func TestOutputJSONWithWarnings_NoCommentsSubtaskError(t *testing.T) { os.Stdout = w warnings := []agent.AgentWarning{{Type: "subtask_error", File: "x.go", Message: "fail"}} - err := outputJSONWithWarnings(nil, warnings, 1, 10, 5, 15, 0, 0, time.Second, "", nil, "abc123trace", nil, "") - _ = w.Close() + err := outputJSONWithWarnings(nil, warnings, 1, 10, 5, 15, 0, 0, time.Second, "", nil) + + w.Close() os.Stdout = old if err != nil { @@ -177,21 +178,16 @@ func TestOutputJSONWithWarnings_NoCommentsSubtaskError(t *testing.T) { } var buf bytes.Buffer - _, _ = buf.ReadFrom(r) + buf.ReadFrom(r) var out jsonOutput - if err := json.Unmarshal(buf.Bytes(), &out); err != nil { - t.Fatalf("unmarshal: %v", err) - } + json.Unmarshal(buf.Bytes(), &out) if out.Status != "completed_with_errors" { t.Errorf("status = %q, want completed_with_errors", out.Status) } if !strings.Contains(out.Message, "errors") { t.Errorf("message = %q, expected to mention errors", out.Message) } - if out.TraceID != "abc123trace" { - t.Errorf("trace_id = %q, want abc123trace", out.TraceID) - } } func TestStatusBadge(t *testing.T) { @@ -226,7 +222,7 @@ func TestOutputJSON(t *testing.T) { } err := outputJSON(comments) - _ = w.Close() + w.Close() os.Stdout = old if err != nil { @@ -234,7 +230,7 @@ func TestOutputJSON(t *testing.T) { } var buf bytes.Buffer - _, _ = buf.ReadFrom(r) + buf.ReadFrom(r) var out jsonOutput if err := json.Unmarshal(buf.Bytes(), &out); err != nil { @@ -255,7 +251,7 @@ func TestOutputJSON_NoComments(t *testing.T) { err := outputJSON(nil) - _ = w.Close() + w.Close() os.Stdout = old if err != nil { @@ -263,12 +259,10 @@ func TestOutputJSON_NoComments(t *testing.T) { } var buf bytes.Buffer - _, _ = buf.ReadFrom(r) + buf.ReadFrom(r) var out jsonOutput - if err := json.Unmarshal(buf.Bytes(), &out); err != nil { - t.Fatalf("unmarshal: %v", err) - } + json.Unmarshal(buf.Bytes(), &out) if out.Message == "" { t.Error("expected non-empty message when no comments") } @@ -281,8 +275,9 @@ func TestOutputJSONWithWarnings(t *testing.T) { comments := []model.LlmComment{{Path: "b.go", Content: "test"}} warnings := []agent.AgentWarning{{Type: "subtask_error", File: "c.go", Message: "failed"}} - err := outputJSONWithWarnings(comments, warnings, 5, 100, 50, 150, 10, 5, 3*time.Second, "summary", map[string]int64{"file_read": 3}, "trace-xyz-789", nil, "") - _ = w.Close() + err := outputJSONWithWarnings(comments, warnings, 5, 100, 50, 150, 10, 5, 3*time.Second, "summary", map[string]int64{"file_read": 3}) + + w.Close() os.Stdout = old if err != nil { @@ -290,12 +285,10 @@ func TestOutputJSONWithWarnings(t *testing.T) { } var buf bytes.Buffer - _, _ = buf.ReadFrom(r) + buf.ReadFrom(r) var out jsonOutput - if err := json.Unmarshal(buf.Bytes(), &out); err != nil { - t.Fatalf("unmarshal: %v", err) - } + json.Unmarshal(buf.Bytes(), &out) if out.Status != "completed_with_errors" { t.Errorf("status = %q, want completed_with_errors", out.Status) } @@ -308,9 +301,6 @@ func TestOutputJSONWithWarnings(t *testing.T) { if out.ToolCalls == nil || out.ToolCalls.Total != 3 { t.Errorf("ToolCalls.Total = %v", out.ToolCalls) } - if out.TraceID != "trace-xyz-789" { - t.Errorf("trace_id = %q, want trace-xyz-789", out.TraceID) - } } func TestOutputJSONWithWarnings_NoCommentsNoErrors(t *testing.T) { @@ -319,8 +309,9 @@ func TestOutputJSONWithWarnings_NoCommentsNoErrors(t *testing.T) { os.Stdout = w warnings := []agent.AgentWarning{{Type: "warning", Message: "something"}} - err := outputJSONWithWarnings(nil, warnings, 2, 50, 20, 70, 0, 0, time.Second, "", nil, "", nil, "") - _ = w.Close() + err := outputJSONWithWarnings(nil, warnings, 2, 50, 20, 70, 0, 0, time.Second, "", nil) + + w.Close() os.Stdout = old if err != nil { @@ -328,12 +319,10 @@ func TestOutputJSONWithWarnings_NoCommentsNoErrors(t *testing.T) { } var buf bytes.Buffer - _, _ = buf.ReadFrom(r) + buf.ReadFrom(r) var out jsonOutput - if err := json.Unmarshal(buf.Bytes(), &out); err != nil { - t.Fatalf("unmarshal: %v", err) - } + json.Unmarshal(buf.Bytes(), &out) if out.Status != "completed_with_warnings" { t.Errorf("status = %q, want completed_with_warnings", out.Status) } @@ -347,9 +336,9 @@ func TestOutputJSONNoFiles(t *testing.T) { r, w, _ := os.Pipe() os.Stdout = w - err := outputJSONNoFiles("test-trace-id-456") + err := outputJSONNoFiles() - _ = w.Close() + w.Close() os.Stdout = old if err != nil { @@ -357,18 +346,13 @@ func TestOutputJSONNoFiles(t *testing.T) { } var buf bytes.Buffer - _, _ = buf.ReadFrom(r) + buf.ReadFrom(r) var out jsonOutput - if err := json.Unmarshal(buf.Bytes(), &out); err != nil { - t.Fatalf("unmarshal: %v", err) - } + json.Unmarshal(buf.Bytes(), &out) if out.Status != "skipped" { t.Errorf("status = %q, want skipped", out.Status) } - if out.TraceID != "test-trace-id-456" { - t.Errorf("trace_id = %q, want test-trace-id-456", out.TraceID) - } } func captureStdout(t *testing.T, fn func()) string { @@ -380,10 +364,10 @@ func captureStdout(t *testing.T, fn func()) string { } os.Stdout = w fn() - _ = w.Close() + w.Close() os.Stdout = old var buf bytes.Buffer - _, _ = buf.ReadFrom(r) + buf.ReadFrom(r) return buf.String() } diff --git a/cmd/opencodereview/output_test.go b/cmd/opencodereview/output_test.go index 038cd84..e0cccfb 100644 --- a/cmd/opencodereview/output_test.go +++ b/cmd/opencodereview/output_test.go @@ -1,78 +1,6 @@ package main -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) - } -} +import "testing" func TestSanitizeTerminal(t *testing.T) { tests := []struct { @@ -93,8 +21,8 @@ func TestSanitizeTerminal(t *testing.T) { {"only control chars", "\x1b\x07\x00\x7f", ""}, {"unicode preserved", "代码审查 レビュー 🔍", "代码审查 レビュー 🔍"}, {"mixed safe and unsafe", "path\x1b[0m/file.go", "path[0m/file.go"}, - {"strips C1 CSI (U+009B)", "before\u009bafter", "beforeafter"}, - {"strips C1 OSC (U+009D)", "before\u009dafter", "beforeafter"}, + {"strips C1 CSI (U+009B)", "before›after", "beforeafter"}, + {"strips C1 OSC (U+009D)", "beforeafter", "beforeafter"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/cmd/opencodereview/provider_cmd.go b/cmd/opencodereview/provider_cmd.go index db66b71..995efb7 100644 --- a/cmd/opencodereview/provider_cmd.go +++ b/cmd/opencodereview/provider_cmd.go @@ -32,9 +32,13 @@ func runConfigProvider() error { final := finalModel.(providerTUIModel) if !final.confirmed { - // TUI persists changes during the session; Esc only abandons the final - // provider/API-key confirmation step. - printWizardCancelled(final.savedInSession, "Configuration changes") + // 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.") return nil } @@ -51,16 +55,6 @@ 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 { @@ -139,8 +133,7 @@ func applyCustomProviderConfig(configPath string, cfg *Config, result providerTU if result.provider == "" { return fmt.Errorf("provider name is required") } - model := result.resolvedModel() - if model == "" { + if result.model == "" { return fmt.Errorf("model is required") } @@ -149,11 +142,11 @@ func applyCustomProviderConfig(configPath string, cfg *Config, result providerTU } entry := cfg.CustomProviders[result.provider] - entry.Model = model + entry.Model = result.model if len(result.models) > 0 { entry.Models = append([]string(nil), result.models...) } - entry.Models = ensureModelInList(entry.Models, model) + entry.Models = ensureModelInList(entry.Models, result.model) if result.url != "" { entry.URL = result.url } @@ -169,16 +162,14 @@ 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 = model + cfg.Model = result.model } else if cfg.Provider == result.provider { - cfg.Model = model + cfg.Model = result.model } if err := saveConfig(configPath, cfg); err != nil { @@ -191,13 +182,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", model) + fmt.Printf("Model: %s\n", result.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", model) + fmt.Printf("Model: %s\n", result.model) fmt.Println("\nTesting connection...") if err := runLLMTest(); err != nil { @@ -211,11 +202,7 @@ func applyCustomProviderConfig(configPath string, cfg *Config, result providerTU } func applyOfficialProviderConfig(configPath string, cfg *Config, result providerTUIResult) error { - if result.provider == "" { - return fmt.Errorf("provider and model are required") - } - model := result.resolvedModel() - if model == "" { + if result.provider == "" || result.model == "" { return fmt.Errorf("provider and model are required") } @@ -236,15 +223,12 @@ func applyOfficialProviderConfig(configPath string, cfg *Config, result provider } entry := cfg.Providers[result.provider] - entry.Model = model + entry.Model = result.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 @@ -252,14 +236,14 @@ func applyOfficialProviderConfig(configPath string, cfg *Config, result provider cfg.Model = "" } cfg.Provider = result.provider - cfg.Model = model + cfg.Model = result.model if err := saveConfig(configPath, cfg); err != nil { return err } fmt.Printf("\nProvider set to: %s\n", result.provider) - fmt.Printf("Model: %s\n", model) + fmt.Printf("Model: %s\n", result.model) fmt.Println("\nTesting connection...") if err := runLLMTest(); err != nil { @@ -290,10 +274,8 @@ 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) @@ -311,15 +293,7 @@ func runConfigModel() error { provider.Models = mergeModelLists(entry.Models) } - m := newModelTUIConfig(modelTUIConfig{ - Provider: provider, - CurrentModel: currentModel, - RegistryModels: registryModels, - ExistingCfg: cfg, - ConfigPath: configPath, - ProviderName: cfg.Provider, - IsCustom: isCustom, - }) + m := newModelTUI(provider, currentModel) p := tea.NewProgram(m) finalModel, err := p.Run() if err != nil { @@ -328,7 +302,7 @@ func runConfigModel() error { final := finalModel.(modelTUIModel) if final.cancelled { - printWizardCancelled(final.savedInSession, "Model list changes") + fmt.Println("Cancelled.") return nil } @@ -351,9 +325,7 @@ func runConfigModel() error { } entry := cfg.Providers[cfg.Provider] entry.Model = 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) { + if !modelListContains(provider.Models, 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 d1f03bb..54dc906 100644 --- a/cmd/opencodereview/provider_cmd_test.go +++ b/cmd/opencodereview/provider_cmd_test.go @@ -2,7 +2,6 @@ package main import ( "encoding/json" - "io" "os" "path/filepath" "testing" @@ -204,175 +203,3 @@ 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 8e55a76..2e0b795 100644 --- a/cmd/opencodereview/provider_tui.go +++ b/cmd/opencodereview/provider_tui.go @@ -58,50 +58,17 @@ 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 - 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 + provider string + model string + models []string + apiKey string + isCustom bool + isEdit bool + editTargetName string + isManual bool + url string + protocol string + authHeader string } type providerTUIModel struct { @@ -153,9 +120,6 @@ 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 @@ -182,17 +146,6 @@ 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 @@ -334,7 +287,7 @@ func newProviderTUI(cfg *Config, configPath string) providerTUIModel { if cfg.Llm.AuthToken != "" { m.manualTokenOriginal = cfg.Llm.AuthToken m.manualTokenMasked = true - m.manualTokenInput.SetValue(maskedSecretPlaceholder()) + m.manualTokenInput.SetValue(strings.Repeat("*", 20)) } if cfg.Llm.UseAnthropic == nil || *cfg.Llm.UseAnthropic { m.manualProtocolIdx = 0 // anthropic @@ -377,97 +330,12 @@ 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: - provider := m.currentProvider() - models := registryModelsForProvider(provider.Name, provider.Models) + models := m.currentProvider().Models if m.existingCfg != nil { + provider := m.currentProvider() if entry, ok := m.existingCfg.Providers[provider.Name]; ok { models = mergeModelLists(models, entry.Models) } @@ -481,19 +349,12 @@ func (m providerTUIModel) models() []string { return nil } -func (m *providerTUIModel) prepareModelSelection(providerName, configModel string) { +func (m *providerTUIModel) prepareModelSelection(currentModel 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 @@ -509,32 +370,6 @@ func (m *providerTUIModel) prepareModelSelection(providerName, configModel strin 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 { @@ -552,9 +387,43 @@ func (m *providerTUIModel) syncSessionModelSelection() error { if model == "" { return nil } - // Remember the pick for in-wizard navigation only; persist provider/model on - // final confirm (applyOfficialProviderConfig / applyCustomProviderConfig). - m.recordSessionModelPick(model) + + 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 return nil } @@ -661,11 +530,12 @@ 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.cursorOnDeletableModel() { + if m.step == stepModel && m.activeTab == tabCustom && m.customIdx < len(m.customProviders) { models := m.models() - m.confirmingDeleteModel = true - m.deleteModelName = models[m.modelIdx] - return m, nil + if m.modelIdx < len(models) { + m.confirmingDeleteModel = true + m.deleteModelName = models[m.modelIdx] + } } return m, nil @@ -685,9 +555,6 @@ 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 @@ -714,21 +581,17 @@ func (m providerTUIModel) updateCustomModelInput(key string, msg tea.KeyPressMsg if name == "" { return m, nil } - if llm.ModelListContains(m.models(), name) { - m.formError = fmt.Sprintf("Already in list: %s", name) - return m, nil + for _, existing := range m.models() { + if existing == name { + m.formError = fmt.Sprintf("Already in list: %s", name) + return m, nil + } } m.formError = "" - persisted, err := m.persistCustomModelName(name) - if err != nil { + if err := m.addCustomModelToSession(name); 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("") @@ -744,122 +607,37 @@ func (m providerTUIModel) updateCustomModelInput(key string, msg tea.KeyPressMsg } } -// 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") - } +// 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 { if m.existingCfg == nil { - return false, nil + return nil } - 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) + cp, ok := m.selectedCustomProvider() + if !ok { + 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 + 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) } -} - -// 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.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) + } } - 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("") + m.savedInSession = true + return nil } // refreshModelSelectionForCustom moves the cursor to "Enter custom model name..." @@ -873,34 +651,6 @@ 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": @@ -909,11 +659,6 @@ 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": @@ -921,7 +666,12 @@ func (m providerTUIModel) updateAPIKeyInput(key string, msg tea.KeyPressMsg) (te return m, tea.Quit default: if m.apiKeyMasked { - m.beginAPIKeyReplace() + if len(key) == 1 { + m.apiKeyMasked = false + m.apiKeyInput.SetValue("") + } else { + return m, nil + } } var cmd tea.Cmd m.apiKeyInput, cmd = m.apiKeyInput.Update(msg) @@ -977,7 +727,12 @@ func (m providerTUIModel) updateCustomProviderForm(key string, msg tea.KeyPressM } if m.cpStep == cpStepAPIKey { if m.apiKeyMasked { - m.beginAPIKeyReplace() + if len(key) == 1 { + m.apiKeyMasked = false + m.apiKeyInput.SetValue("") + } else { + return m, nil + } } var cmd tea.Cmd m.apiKeyInput, cmd = m.apiKeyInput.Update(msg) @@ -1005,7 +760,7 @@ func (m *providerTUIModel) enterEditCustomProvider() { if entry.APIKey != "" { m.apiKeyOriginal = entry.APIKey m.apiKeyMasked = true - m.apiKeyInput.SetValue(maskedSecretPlaceholder()) + m.apiKeyInput.SetValue(strings.Repeat("*", 20)) } else { m.apiKeyInput.SetValue("") m.apiKeyMasked = false @@ -1020,8 +775,6 @@ 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: @@ -1074,14 +827,11 @@ 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(r.provider, m.customProviderEntry(r.provider, ProviderEntry{}).Model) + m.prepareModelSelection(m.customProviderEntry(r.provider, ProviderEntry{}).Model) return m, nil } if m.creatingCustom { @@ -1122,7 +872,9 @@ func (m providerTUIModel) applyCreateCustomProvider() (tea.Model, tea.Cmd) { URL: r.url, Protocol: r.protocol, AuthHeader: r.authHeader, - APIKey: strings.TrimSpace(m.apiKeyInput.Value()), + } + if r.apiKey != "" { + entry.APIKey = r.apiKey } m.existingCfg.CustomProviders[r.provider] = entry @@ -1148,7 +900,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(r.provider, "") + m.prepareModelSelection("") return m, nil } @@ -1225,8 +977,8 @@ func (m *providerTUIModel) applyEditCustomProviderSave() error { entry.URL = r.url entry.Protocol = r.protocol entry.AuthHeader = r.authHeader - if key, edited := m.customAPIKeyForSave(); edited { - entry.APIKey = key + if r.apiKey != "" { + entry.APIKey = r.apiKey } // If name changed, delete old key if r.editTargetName != "" && r.editTargetName != r.provider { @@ -1307,9 +1059,7 @@ func (m providerTUIModel) passThroughCPInput(msg tea.Msg) (tea.Model, tea.Cmd) { case cpStepBaseURL: m.cpURLInput, cmd = m.cpURLInput.Update(msg) case cpStepAPIKey: - if m.apiKeyMasked && isUserEditMsg(msg) { - m.beginAPIKeyReplace() - } + // masked unlock is handled in updateCustomProviderForm default branch m.apiKeyInput, cmd = m.apiKeyInput.Update(msg) case cpStepAuthHeader: m.cpAuthInput, cmd = m.cpAuthInput.Update(msg) @@ -1336,7 +1086,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(maskedSecretPlaceholder()) + m.manualTokenInput.SetValue(strings.Repeat("*", 20)) } else { m.manualTokenInput.SetValue("") m.manualTokenMasked = false @@ -1375,7 +1125,12 @@ func (m providerTUIModel) updateManualForm(key string, msg tea.KeyPressMsg) (tea } } if m.manualStep == manualStepAuthToken && m.manualTokenMasked { - m.beginManualTokenReplace() + if len(key) == 1 { + m.manualTokenMasked = false + m.manualTokenInput.SetValue("") + } else { + return m, nil + } } return m.passThroughManualInput(msg) } @@ -1432,14 +1187,50 @@ 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": - switch m.activeTab { - case tabCustom: - return m.confirmDeleteCustomModel() - case tabOfficial: - return m.confirmDeleteOfficialModel() - default: + 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] + 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 @@ -1451,118 +1242,6 @@ 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: @@ -1583,11 +1262,9 @@ func (m providerTUIModel) handleManualFormEnter() (tea.Model, tea.Cmd) { m.manualStep = manualStepAuthToken return m, m.manualTokenInput.Focus() case manualStepAuthToken: - if strings.TrimSpace(m.manualTokenInput.Value()) == "" && m.manualTokenOriginal == "" { - m.formError = manualAuthTokenRequiredError + if m.manualTokenInput.Value() == "" && m.manualTokenOriginal == "" { return m, nil } - m.formError = "" m.manualTokenInput.Blur() m.manualStep = manualStepAuthHeader return m, m.manualAuthHeaderInput.Focus() @@ -1645,9 +1322,6 @@ 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) @@ -1670,7 +1344,7 @@ func (m providerTUIModel) handleEnter() (tea.Model, tea.Cmd) { currentModel = activeModelForProvider(m.existingCfg, m.currentProvider().Name, entry) } } - m.prepareModelSelection(m.currentProvider().Name, currentModel) + m.prepareModelSelection(currentModel) return m, nil case tabCustom: @@ -1690,7 +1364,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(cp.name, activeModelForProvider(m.existingCfg, cp.name, entry)) + m.prepareModelSelection(activeModelForProvider(m.existingCfg, cp.name, entry)) return m, nil case tabManual: @@ -1778,7 +1452,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(maskedSecretPlaceholder()) + m.apiKeyInput.SetValue(strings.Repeat("*", 20)) } return } @@ -1789,7 +1463,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(maskedSecretPlaceholder()) + m.apiKeyInput.SetValue(strings.Repeat("*", 20)) } } @@ -1809,28 +1483,24 @@ 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 = strings.TrimSpace(m.apiKeyInput.Value()) + apiKey = m.apiKeyInput.Value() } return providerTUIResult{ - provider: p.Name, - model: model, - apiKey: apiKey, - sessionModelPick: m.sessionModelPickSnapshot(), + provider: p.Name, + model: model, + apiKey: apiKey, } case tabCustom: if m.creatingCustom || m.editingCustom { protocol := cpProtocols[m.cpProtocolIdx] - apiKey := strings.TrimSpace(m.apiKeyInput.Value()) + apiKey := m.apiKeyInput.Value() if m.apiKeyMasked { apiKey = m.apiKeyOriginal } @@ -1858,9 +1528,6 @@ 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 } @@ -1868,18 +1535,17 @@ func (m providerTUIModel) result() providerTUIResult { if m.apiKeyMasked { apiKey = m.apiKeyOriginal } else { - apiKey = strings.TrimSpace(m.apiKeyInput.Value()) + apiKey = 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, - sessionModelPick: m.sessionModelPickSnapshot(), + 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, } } return providerTUIResult{} @@ -1910,16 +1576,6 @@ 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) @@ -1927,13 +1583,6 @@ 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 { @@ -2008,9 +1657,6 @@ 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") } } @@ -2027,11 +1673,8 @@ 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(listCursorPrefixForModel(isCursor, highlight)) - s.WriteString(renderModelName(cp.name, isCursor, highlight)) + s.WriteString(listCursorPrefix(isCursor) + renderListName(cp.name, isCursor)) if activeModel != "" { s.WriteString(" " + tuiDimStyle.Render("("+activeModel+")")) } @@ -2107,9 +1750,6 @@ 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") } @@ -2187,9 +1827,6 @@ 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") } @@ -2221,16 +1858,7 @@ func (m providerTUIModel) viewModel(s *strings.Builder) { for i, model := range models { isCursor := i == m.modelIdx - 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(listCursorPrefix(isCursor) + renderListName(model, isCursor)) s.WriteString("\n") } @@ -2260,7 +1888,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.cursorOnDeletableModel() { + } else if m.activeTab == tabCustom && m.customIdx < len(m.customProviders) { s.WriteString(tuiHelpStyle.Render(" ↑/↓ Select Enter Confirm d Delete Esc Back")) } else { s.WriteString(tuiHelpStyle.Render(" ↑/↓ Select Enter Confirm Esc Back")) @@ -2282,22 +1910,11 @@ 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") - hasSavedKey := m.apiKeyMasked && m.apiKeyOriginal != "" - s.WriteString(tuiDimStyle.Render(officialAPIKeyEnvSetHintLine(provider.EnvVar, hasSavedKey))) + s.WriteString(tuiDimStyle.Render(fmt.Sprintf(" $%s is set", provider.EnvVar))) s.WriteString("\n") } else { s.WriteString("\n") @@ -2306,70 +1923,11 @@ 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 = "▸" @@ -2386,13 +1944,6 @@ 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(). @@ -2414,16 +1965,6 @@ 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 @@ -2435,231 +1976,52 @@ 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: 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, + provider: provider, + models: provider.Models, + width: 80, + height: 24, + modelInput: mi, + activeModel: currentModel, } - if cfg.IsCustom { - m.models = append([]string(nil), cfg.Provider.Models...) - } - - if cfg.CurrentModel != "" { + if currentModel != "" { found := false - models := m.displayModels() - for i, model := range models { - if model == cfg.CurrentModel { + for i, model := range m.models { + if model == currentModel { m.modelIdx = i found = true break } } if !found { - m.modelIdx = len(models) - m.modelInput.SetValue(cfg.CurrentModel) + m.modelIdx = len(m.models) + m.modelInput.SetValue(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.displayModels()) + return idx == len(m.models) } func (m modelTUIModel) itemCount() int { - return len(m.displayModels()) + 1 + return len(m.models) + 1 } func (m modelTUIModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -2672,10 +2034,6 @@ 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": @@ -2684,28 +2042,14 @@ func (m modelTUIModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.modelInput.SetValue("") return m, nil case "enter": - name := strings.TrimSpace(m.modelInput.Value()) - if name == "" { - return m, nil + if m.modelInput.Value() != "" { + m.confirmed = true + return m, tea.Quit } - 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 } } @@ -2735,13 +2079,6 @@ 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: @@ -2754,116 +2091,12 @@ 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() } - models := m.displayModels() - if m.modelIdx < len(models) { - return models[m.modelIdx] + if m.modelIdx < len(m.models) { + return m.models[m.modelIdx] } return "" } @@ -2874,22 +2107,13 @@ func (m modelTUIModel) View() tea.View { s.WriteString(tuiTitleStyle.Render(fmt.Sprintf(" Select a model (%s)", m.provider.DisplayName))) s.WriteString("\n\n") - models := m.displayModels() - for i, model := range models { + for i, model := range m.models { isCursor := i == m.modelIdx - 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(listCursorPrefix(isCursor) + renderListName(model, isCursor)) s.WriteString("\n") } - customIdx := len(models) + customIdx := len(m.models) isCursor := m.modelIdx == customIdx customLabel := "Enter custom model name..." if isCursor { @@ -2905,23 +2129,8 @@ 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") - - 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(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 932b1b0..a5f0001 100644 --- a/cmd/opencodereview/provider_tui_funcs_test.go +++ b/cmd/opencodereview/provider_tui_funcs_test.go @@ -1,8 +1,6 @@ package main import ( - "os" - "path/filepath" "strings" "testing" @@ -46,73 +44,6 @@ 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() @@ -616,12 +547,6 @@ 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) } @@ -936,447 +861,6 @@ 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) { @@ -1593,269 +1077,6 @@ 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) { @@ -1925,23 +1146,15 @@ 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", "m2"}}, + "cp": {URL: "http://cp", Models: []string{"m1"}}, }, } m := newProviderTUI(cfg, "") m.step = stepModel m.activeTab = tabCustom m.customIdx = 0 - - 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) + v := m.View() + if !strings.Contains(v.Content, "Delete") { + t.Errorf("expected delete help for custom tab model view") } } diff --git a/cmd/opencodereview/provider_tui_test.go b/cmd/opencodereview/provider_tui_test.go index 624bc50..42e9d41 100644 --- a/cmd/opencodereview/provider_tui_test.go +++ b/cmd/opencodereview/provider_tui_test.go @@ -8,8 +8,6 @@ import ( "testing" tea "charm.land/bubbletea/v2" - - "github.com/open-code-review/open-code-review/internal/llm" ) func escKey() tea.KeyPressMsg { @@ -445,75 +443,10 @@ func TestProviderTUI_ManualFormRequiresTokenOnFirstSetup(t *testing.T) { 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 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) + if m2.manualStep != manualStepAuthToken { + t.Errorf("should stay on auth token step, got %d", m2.manualStep) } } @@ -748,180 +681,6 @@ 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") @@ -1353,478 +1112,6 @@ 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") @@ -1880,692 +1167,6 @@ 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 8379156..d76951b 100644 --- a/cmd/opencodereview/review_cmd.go +++ b/cmd/opencodereview/review_cmd.go @@ -11,11 +11,8 @@ import ( "github.com/open-code-review/open-code-review/internal/agent" "github.com/open-code-review/open-code-review/internal/mcp" - "github.com/open-code-review/open-code-review/internal/session" "github.com/open-code-review/open-code-review/internal/telemetry" "github.com/open-code-review/open-code-review/internal/tool" - - "go.opentelemetry.io/otel/codes" ) func runReview(args []string) error { @@ -47,26 +44,10 @@ func runReview(args []string) error { } } - // Only touch the background when --background-file is set, so the existing - // --background behaviour (raw, unsanitised) is preserved for users who do - // not opt into the file-based context. - if opts.backgroundFile != "" { - fileBackground, err := loadBackgroundFile(opts.backgroundFile) - if err != nil { - return err - } - opts.background = mergeBackground(opts.background, fileBackground) - } - if opts.preview { return runPreview(cc, opts) } - resumeState, err := loadReviewResumeState(cc.RepoDir, opts) - if err != nil { - return err - } - rt, err := loadLLMRuntime(cc.Template, opts.toolConfigPath, opts.model) if err != nil { return err @@ -100,7 +81,6 @@ func runReview(args []string) error { From: opts.from, To: opts.to, Commit: opts.commit, - ReviewMode: reviewModeFromOptions(opts), Template: *cc.Template, SystemRule: cc.Resolver, FileFilter: cc.FileFilter, @@ -115,7 +95,6 @@ func runReview(args []string) error { Model: rt.Model, Background: opts.background, GitRunner: cc.GitRunner, - Resume: resumeState, }) // Silence progress output during execution; restored before the trace @@ -125,75 +104,34 @@ func runReview(args []string) error { ctx, span := telemetry.StartSpan(context.Background(), "review.run") defer span.End() - telemetry.SetAttr(span, "review.repo", cc.RepoDir) - telemetry.SetAttr(span, "review.from", opts.from) - telemetry.SetAttr(span, "review.to", opts.to) - telemetry.SetAttr(span, "review.model", rt.Model) - var traceID string - if telemetry.IsEnabled() { - traceID = telemetry.TraceIDFromContext(ctx) - if opts.outputFormat != "json" { - fmt.Fprintf(os.Stderr, "[ocr] TraceID: %s\n", traceID) - } - } startTime := time.Now() comments, err := ag.Run(ctx) if err != nil { - span.SetStatus(codes.Error, err.Error()) - span.RecordError(err) - if id := ag.SessionID(); id != "" { - fmt.Fprintf(os.Stderr, "[ocr] Session: %s (retry with: --resume %s)\n", id, id) - } + telemetry.SetAttr(span, "error", err.Error()) return fmt.Errorf("review failed: %w", err) } return emitRunResult(ctx, ag, comments, startTime, opts.outputFormat, opts.audience, q) } -func loadReviewResumeState(repoDir string, opts reviewOptions) (*session.ResumeState, error) { - if opts.resume == "" { - return nil, nil - } - current := session.SessionOptions{ - ReviewMode: reviewModeFromOptions(opts), - DiffFrom: opts.from, - DiffTo: opts.to, - DiffCommit: opts.commit, - } - if current.ReviewMode == session.ReviewModeWorkspace { - return nil, fmt.Errorf("resume requires --from/--to or --commit; workspace resume is not supported") - } - state, err := session.LoadResumeState(repoDir, opts.resume) - if err != nil { - return nil, fmt.Errorf("load resume session: %w (run 'ocr session list' to see available sessions)", err) - } - if err := state.ValidateOptions(current); err != nil { - return nil, fmt.Errorf("%w (run 'ocr session list' to see available sessions)", err) - } - if state.CompletedCount() == 0 { - return nil, fmt.Errorf("resume session %q has no completed review items (run 'ocr session list' to see available sessions)", opts.resume) - } - return state, nil -} - -func reviewModeFromOptions(opts reviewOptions) string { - if opts.commit != "" { - return session.ReviewModeCommit - } - if opts.from != "" && opts.to != "" { - return session.ReviewModeRange - } - return session.ReviewModeWorkspace -} - -// resolveRepoDir resolves the repo dir for `ocr rules check`. It delegates to -// resolveWorkingDir(requireGit=true) so it anchors at the git top-level just -// like the review path — keeping rule resolution consistent when run from a -// monorepo subdirectory (#287). func resolveRepoDir(input string) (string, error) { - absPath, _, err := resolveWorkingDir(input, true) - return absPath, err + if input == "" { + var err error + input, err = os.Getwd() + if err != nil { + return "", fmt.Errorf("get working directory: %w", err) + } + } + absPath, err := filepath.Abs(input) + if err != nil { + return "", fmt.Errorf("resolve absolute path: %w", 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) + } + return absPath, nil } // 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 46353ab..3757e98 100644 --- a/cmd/opencodereview/scan_cmd.go +++ b/cmd/opencodereview/scan_cmd.go @@ -3,7 +3,6 @@ package main import ( "context" "fmt" - "os" "strings" "time" @@ -12,8 +11,6 @@ import ( "github.com/open-code-review/open-code-review/internal/scan" "github.com/open-code-review/open-code-review/internal/telemetry" "github.com/open-code-review/open-code-review/internal/tool" - - "go.opentelemetry.io/otel/codes" ) // scanOptions mirrors reviewOptions for the full-scan subcommand. The two @@ -210,22 +207,11 @@ func runScan(args []string) error { ctx, span := telemetry.StartSpan(context.Background(), "scan.run") defer span.End() - var traceID string - if telemetry.IsEnabled() { - traceID = telemetry.TraceIDFromContext(ctx) - if opts.outputFormat != "json" { - fmt.Fprintf(os.Stderr, "[ocr] TraceID: %s\n", traceID) - } - } startTime := time.Now() comments, err := ag.Run(ctx) if err != nil { - span.SetStatus(codes.Error, err.Error()) - span.RecordError(err) - if id := ag.SessionID(); id != "" { - fmt.Fprintf(os.Stderr, "[ocr] Session: %s\n", id) - } + telemetry.SetAttr(span, "error", err.Error()) return fmt.Errorf("scan failed: %w", err) } diff --git a/cmd/opencodereview/session_cmd.go b/cmd/opencodereview/session_cmd.go deleted file mode 100644 index 805b8a5..0000000 --- a/cmd/opencodereview/session_cmd.go +++ /dev/null @@ -1,299 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "io" - "os" - "strings" - "text/tabwriter" - "time" - - "github.com/open-code-review/open-code-review/internal/session" -) - -func runSession(args []string) error { - if len(args) == 0 { - printSessionUsage() - return nil - } - switch args[0] { - case "list", "ls": - return runSessionList(args[1:]) - case "show": - return runSessionShow(args[1:]) - case "-h", "--help": - printSessionUsage() - return nil - default: - return fmt.Errorf("unknown session sub-command: %s\nRun 'ocr session -h' for usage", args[0]) - } -} - -func runSessionList(args []string) error { - a := newOcrFlagSet("ocr session list") - var repoDir string - var asJSON bool - var limit int - a.StringVar(&repoDir, "repo", "", "root directory of the git repository (default: current dir)") - a.BoolVar(&asJSON, "json", false, "emit JSON instead of a table") - a.IntVar(&limit, "limit", 20, "cap the number of listed sessions (0 = unlimited)") - if err := a.Parse(args); err != nil { - return err - } - if a.showHelp { - printSessionListUsage() - return nil - } - - resolvedRepo, err := resolveWorkingDirForSession(repoDir) - if err != nil { - return err - } - summaries, err := session.ListSessions(resolvedRepo) - if err != nil { - return fmt.Errorf("list sessions: %w", err) - } - if limit > 0 && len(summaries) > limit { - summaries = summaries[:limit] - } - - if asJSON { - enc := json.NewEncoder(os.Stdout) - enc.SetIndent("", " ") - return enc.Encode(summaries) - } - - if len(summaries) == 0 { - fmt.Printf("No sessions found for %s\n", resolvedRepo) - return nil - } - printSessionTable(os.Stdout, summaries) - return nil -} - -func runSessionShow(args []string) error { - a := newOcrFlagSet("ocr session show") - var repoDir string - var asJSON bool - a.StringVar(&repoDir, "repo", "", "root directory of the git repository (default: current dir)") - a.BoolVar(&asJSON, "json", false, "emit JSON instead of a table") - if err := a.Parse(args); err != nil { - return err - } - if a.showHelp { - printSessionShowUsage() - return nil - } - - rest := a.fs.Args() - if len(rest) == 0 { - printSessionShowUsage() - return fmt.Errorf("session show requires a session ID") - } - sessionID := rest[0] - - resolvedRepo, err := resolveWorkingDirForSession(repoDir) - if err != nil { - return err - } - summary, items, err := session.LoadDetail(resolvedRepo, sessionID) - if err != nil { - return fmt.Errorf("load session %q: %w", sessionID, err) - } - - if asJSON { - payload := struct { - Summary *session.Summary `json:"summary"` - Items []session.ItemDetail `json:"items"` - }{Summary: summary, Items: items} - enc := json.NewEncoder(os.Stdout) - enc.SetIndent("", " ") - return enc.Encode(payload) - } - - printSessionDetail(os.Stdout, summary, items) - return nil -} - -// resolveWorkingDirForSession accepts an explicit --repo flag value and falls -// back to the current working directory. Unlike resolveRepoDir it does not -// require the target to be a git repository, so users can inspect sessions -// even after archiving a checkout. -func resolveWorkingDirForSession(input string) (string, error) { - dir, _, err := resolveWorkingDir(input, false) - if err != nil { - return "", err - } - return dir, nil -} - -func printSessionTable(w io.Writer, summaries []session.Summary) { - tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) - fmt.Fprintln(tw, "SESSION ID\tMODE\tRANGE\tFILES\tCOMMENTS\tSTATUS\tSTARTED") - for _, s := range summaries { - fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%d\t%s\t%s\n", - s.SessionID, - displayMode(s.ReviewMode), - describeRange(s), - describeFiles(s), - s.TotalComments, - describeStatus(s), - describeStart(s), - ) - } - tw.Flush() -} - -func printSessionDetail(w io.Writer, s *session.Summary, items []session.ItemDetail) { - fmt.Fprintf(w, "Session: %s\n", s.SessionID) - fmt.Fprintf(w, " File: %s\n", s.FilePath) - fmt.Fprintf(w, " Repo: %s\n", s.RepoDir) - if s.GitBranch != "" { - fmt.Fprintf(w, " Branch: %s\n", s.GitBranch) - } - if s.Model != "" { - fmt.Fprintf(w, " Model: %s\n", s.Model) - } - fmt.Fprintf(w, " Mode: %s\n", displayMode(s.ReviewMode)) - if r := describeRange(*s); r != "" && r != "-" { - fmt.Fprintf(w, " Range: %s\n", r) - } - if s.ResumedFrom != "" { - fmt.Fprintf(w, " Resumed: from session %s\n", s.ResumedFrom) - } - fmt.Fprintf(w, " Started: %s\n", describeStart(*s)) - if !s.EndTime.IsZero() { - fmt.Fprintf(w, " Ended: %s\n", s.EndTime.Local().Format("2006-01-02 15:04:05")) - } - if s.Duration > 0 { - fmt.Fprintf(w, " Duration: %s\n", s.Duration.Round(time.Second)) - } - fmt.Fprintf(w, " Status: %s\n", describeStatus(*s)) - fmt.Fprintf(w, " Files: %d completed, %d reused, %d failed\n", - s.CompletedFiles, s.ReusedFiles, s.FailedFiles) - fmt.Fprintf(w, " Comments: %d\n", s.TotalComments) - if s.LLMFailures > 0 { - fmt.Fprintf(w, " LLM err: %d\n", s.LLMFailures) - } - - if len(items) == 0 { - return - } - fmt.Fprintln(w) - fmt.Fprintln(w, "Files:") - tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) - fmt.Fprintln(tw, " TYPE\tFILE\tCOMMENTS\tNOTE") - for _, it := range items { - note := "" - switch it.Type { - case "reused": - note = "from " + shortSessionID(it.SourceSessionID) - case "failed": - note = truncate(it.Error, 60) - } - fmt.Fprintf(tw, " %s\t%s\t%d\t%s\n", it.Type, it.FilePath, it.Comments, note) - } - tw.Flush() -} - -func displayMode(m string) string { - if m == "" { - return "-" - } - return m -} - -func describeRange(s session.Summary) string { - switch s.ReviewMode { - case session.ReviewModeRange: - if s.DiffFrom != "" || s.DiffTo != "" { - return fmt.Sprintf("%s..%s", s.DiffFrom, s.DiffTo) - } - case session.ReviewModeCommit: - if s.DiffCommit != "" { - return s.DiffCommit - } - } - return "-" -} - -func describeFiles(s session.Summary) string { - total := s.CompletedFiles + s.ReusedFiles - if s.ReusedFiles > 0 { - return fmt.Sprintf("%d (reused %d)", total, s.ReusedFiles) - } - return fmt.Sprintf("%d", total) -} - -func describeStatus(s session.Summary) string { - if s.Aborted { - return "aborted" - } - if s.FailedFiles > 0 { - return fmt.Sprintf("completed (%d fail)", s.FailedFiles) - } - return "completed" -} - -func describeStart(s session.Summary) string { - if s.StartTime.IsZero() { - return "-" - } - return s.StartTime.Local().Format("2006-01-02 15:04:05") -} - -func shortSessionID(id string) string { - if len(id) > 8 { - return id[:8] - } - return id -} - -func truncate(s string, n int) string { - s = strings.ReplaceAll(strings.ReplaceAll(s, "\n", " "), "\t", " ") - runes := []rune(s) - if len(runes) <= n { - return s - } - if n <= 1 { - return "…" - } - return string(runes[:n-1]) + "…" -} - -func printSessionUsage() { - fmt.Println(`Usage: - ocr session - -Sub-commands: - list, ls List recent review sessions for the current repo - show Show one session's metadata and per-file items - -Use "ocr session list -h" or "ocr session show -h" for details.`) -} - -func printSessionListUsage() { - fmt.Println(`Usage: - ocr session list [flags] - ocr session ls [flags] - -List review sessions previously persisted to ~/.opencodereview/sessions/. The -session id printed here can be passed to 'ocr review --resume '. - -Flags: - --repo string Root directory of the git repository (default: current dir) - --json Emit JSON instead of a table - --limit int Cap the number of listed sessions (default 20; 0 = unlimited)`) -} - -func printSessionShowUsage() { - fmt.Println(`Usage: - ocr session show [flags] - -Show metadata and per-file items for a single session. - -Flags: - --repo string Root directory of the git repository (default: current dir) - --json Emit JSON instead of a table`) -} diff --git a/cmd/opencodereview/session_cmd_test.go b/cmd/opencodereview/session_cmd_test.go deleted file mode 100644 index 1c32a71..0000000 --- a/cmd/opencodereview/session_cmd_test.go +++ /dev/null @@ -1,170 +0,0 @@ -package main - -import ( - "encoding/json" - "strings" - "testing" - - "github.com/open-code-review/open-code-review/internal/model" - "github.com/open-code-review/open-code-review/internal/session" -) - -func TestRunSessionList_TextIncludesSessionID(t *testing.T) { - tmpHome := t.TempDir() - t.Setenv("HOME", tmpHome) - repoDir := t.TempDir() - - sh := session.New(repoDir, "main", "test-model", session.SessionOptions{ - ReviewMode: session.ReviewModeCommit, - DiffCommit: "abc123", - }) - sh.RecordReviewItemDone("a.go", "a.go", "a.go", "fp-a", []model.LlmComment{{Path: "a.go", Content: "note"}}) - sh.Finalize() - - got := captureStdout(t, func() { - if err := runSessionList([]string{"--repo", repoDir}); err != nil { - t.Fatalf("runSessionList: %v", err) - } - }) - - if !strings.Contains(got, sh.SessionID) { - t.Errorf("expected list output to contain session id %s, got %q", sh.SessionID, got) - } - if !strings.Contains(got, "abc123") { - t.Errorf("expected list output to contain commit range, got %q", got) - } - if !strings.Contains(got, "SESSION ID") { - t.Errorf("expected header, got %q", got) - } -} - -func TestRunSessionList_JSON(t *testing.T) { - tmpHome := t.TempDir() - t.Setenv("HOME", tmpHome) - repoDir := t.TempDir() - - sh := session.New(repoDir, "main", "test-model", session.SessionOptions{ - ReviewMode: session.ReviewModeCommit, - DiffCommit: "abc123", - }) - sh.RecordReviewItemDone("a.go", "a.go", "a.go", "fp-a", nil) - sh.Finalize() - - got := captureStdout(t, func() { - if err := runSessionList([]string{"--repo", repoDir, "--json"}); err != nil { - t.Fatalf("runSessionList: %v", err) - } - }) - - var decoded []session.Summary - if err := json.Unmarshal([]byte(got), &decoded); err != nil { - t.Fatalf("unmarshal: %v (out=%q)", err, got) - } - if len(decoded) != 1 || decoded[0].SessionID != sh.SessionID { - t.Fatalf("decoded = %+v", decoded) - } -} - -func TestRunSessionList_EmptyRepo(t *testing.T) { - tmpHome := t.TempDir() - t.Setenv("HOME", tmpHome) - repoDir := t.TempDir() - - got := captureStdout(t, func() { - if err := runSessionList([]string{"--repo", repoDir}); err != nil { - t.Fatalf("runSessionList: %v", err) - } - }) - if !strings.Contains(got, "No sessions found") { - t.Errorf("expected empty message, got %q", got) - } -} - -func TestRunSessionShow_Text(t *testing.T) { - tmpHome := t.TempDir() - t.Setenv("HOME", tmpHome) - repoDir := t.TempDir() - - sh := session.New(repoDir, "main", "test-model", session.SessionOptions{ - ReviewMode: session.ReviewModeCommit, - DiffCommit: "abc123", - }) - sh.RecordReviewItemDone("a.go", "a.go", "a.go", "fp-a", []model.LlmComment{{Path: "a.go", Content: "note"}}) - sh.RecordReviewItemFailed("bad.go", "bad.go", "bad.go", "fp-bad", "boom") - sh.Finalize() - - got := captureStdout(t, func() { - if err := runSessionShow([]string{"--repo", repoDir, sh.SessionID}); err != nil { - t.Fatalf("runSessionShow: %v", err) - } - }) - - for _, want := range []string{sh.SessionID, "abc123", "a.go", "bad.go", "boom", "Files:"} { - if !strings.Contains(got, want) { - t.Errorf("expected output to contain %q, got %q", want, got) - } - } -} - -func TestRunSessionShow_JSON(t *testing.T) { - tmpHome := t.TempDir() - t.Setenv("HOME", tmpHome) - repoDir := t.TempDir() - - sh := session.New(repoDir, "main", "test-model", session.SessionOptions{ - ReviewMode: session.ReviewModeCommit, - DiffCommit: "abc123", - }) - sh.RecordReviewItemDone("a.go", "a.go", "a.go", "fp-a", nil) - sh.Finalize() - - got := captureStdout(t, func() { - if err := runSessionShow([]string{"--repo", repoDir, "--json", sh.SessionID}); err != nil { - t.Fatalf("runSessionShow: %v", err) - } - }) - - var payload struct { - Summary *session.Summary `json:"summary"` - Items []session.ItemDetail `json:"items"` - } - if err := json.Unmarshal([]byte(got), &payload); err != nil { - t.Fatalf("unmarshal: %v (out=%q)", err, got) - } - if payload.Summary == nil || payload.Summary.SessionID != sh.SessionID { - t.Fatalf("summary mismatch: %+v", payload.Summary) - } - if len(payload.Items) != 1 || payload.Items[0].FilePath != "a.go" { - t.Fatalf("items = %+v", payload.Items) - } -} - -func TestRunSessionShow_MissingID(t *testing.T) { - tmpHome := t.TempDir() - t.Setenv("HOME", tmpHome) - got := captureStdout(t, func() { - if err := runSessionShow([]string{}); err == nil { - t.Fatal("expected error for missing session id") - } - }) - if !strings.Contains(got, "session show") { - t.Errorf("expected usage output, got %q", got) - } -} - -func TestTruncateUnicode(t *testing.T) { - got := truncate("错误原因:超过限制", 6) - if !strings.HasSuffix(got, "…") { - t.Fatalf("expected ellipsis suffix, got %q", got) - } - if !strings.Contains(got, "错误") { - t.Fatalf("expected valid truncated unicode text, got %q", got) - } -} - -func TestRunSession_UnknownSubcommand(t *testing.T) { - err := runSession([]string{"bogus"}) - if err == nil { - t.Fatal("expected error for unknown sub-command") - } -} diff --git a/cmd/opencodereview/shared.go b/cmd/opencodereview/shared.go index c99c1b2..8d898a1 100644 --- a/cmd/opencodereview/shared.go +++ b/cmd/opencodereview/shared.go @@ -5,7 +5,6 @@ import ( "fmt" "os" "path/filepath" - "strings" "time" "github.com/open-code-review/open-code-review/internal/agent" @@ -101,25 +100,6 @@ 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 } @@ -251,14 +231,6 @@ type ResultProvider interface { // that skipped / failed the summary phase. ProjectSummary() string ToolCalls() map[string]int64 - // SessionID returns the persisted session identifier so callers can show it - // in JSON output or failure diagnostics. Returns "" when no session was - // created. - SessionID() string -} - -type resumeInfoProvider interface { - ResumeInfo() *agent.ResumeInfo } // emitRunResult is the post-LLM-run finalization shared by `ocr review` and @@ -284,10 +256,8 @@ func emitRunResult( telemetry.RecordCommentsGenerated(ctx, int64(len(comments))) } - traceID := telemetry.TraceIDFromContext(ctx) - if outputFormat == "json" && len(comments) == 0 && ag.FilesReviewed() == 0 { - return outputJSONNoFiles(traceID) + return outputJSONNoFiles() } // Agent-text audiences need stdout back before PrintTraceSummary so the @@ -303,14 +273,10 @@ func emitRunResult( } if outputFormat == "json" { - var resumeInfo *agent.ResumeInfo - if p, ok := ag.(resumeInfoProvider); ok { - resumeInfo = p.ResumeInfo() - } return outputJSONWithWarnings(comments, ag.Warnings(), ag.FilesReviewed(), ag.TotalInputTokens(), ag.TotalOutputTokens(), ag.TotalTokensUsed(), ag.TotalCacheReadTokens(), ag.TotalCacheWriteTokens(), duration, - ag.ProjectSummary(), ag.ToolCalls(), traceID, resumeInfo, ag.SessionID()) + ag.ProjectSummary(), ag.ToolCalls()) } outputTextWithWarnings(comments, ag.Warnings()) if summary := ag.ProjectSummary(); summary != "" { diff --git a/cmd/opencodereview/shared_test.go b/cmd/opencodereview/shared_test.go index 417a65f..8c9b819 100644 --- a/cmd/opencodereview/shared_test.go +++ b/cmd/opencodereview/shared_test.go @@ -2,7 +2,6 @@ package main import ( "os" - "os/exec" "path/filepath" "testing" @@ -79,18 +78,9 @@ func TestQuietHandle_IdempotentRestore(t *testing.T) { func TestResolveWorkingDir_CurrentDir(t *testing.T) { dir := t.TempDir() - origDir, err := os.Getwd() - if err != nil { - t.Fatalf("getwd: %v", err) - } - defer func() { - if err := os.Chdir(origDir); err != nil { - t.Errorf("restore chdir: %v", err) - } - }() - if err := os.Chdir(dir); err != nil { - t.Fatalf("chdir: %v", err) - } + origDir, _ := os.Getwd() + defer os.Chdir(origDir) + os.Chdir(dir) absPath, isGit, err := resolveWorkingDir("", false) if err != nil { @@ -119,89 +109,6 @@ 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 2ffc3ff..8a4caea 100644 --- a/examples/README.md +++ b/examples/README.md @@ -6,6 +6,5 @@ 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. +Each subdirectory contains its own README with detailed setup instructions. \ No newline at end of file diff --git a/examples/gitflic_ci/README.md b/examples/gitflic_ci/README.md deleted file mode 100644 index 9f36ce7..0000000 --- a/examples/gitflic_ci/README.md +++ /dev/null @@ -1,80 +0,0 @@ -# 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 deleted file mode 100644 index 2a2b9bd..0000000 --- a/examples/gitflic_ci/gitflic-ci.yaml +++ /dev/null @@ -1,84 +0,0 @@ -# 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 deleted file mode 100644 index e8b0555..0000000 --- a/examples/gitflic_ci/post_review.py +++ /dev/null @@ -1,480 +0,0 @@ -#!/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 deleted file mode 100644 index be43828..0000000 --- a/examples/gitflic_ci/post_review_test.py +++ /dev/null @@ -1,240 +0,0 @@ -#!/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 0236505..dccf654 100644 --- a/examples/github_actions/README.md +++ b/examples/github_actions/README.md @@ -1,40 +1,6 @@ -# OpenCodeReview - GitHub Actions Workflow +# OpenCodeReview - GitHub Actions Demo -This directory provides a ready-to-use GitHub Actions workflow demo that integrates OpenCodeReview into your repository to automatically review Pull Requests and post inline review comments. Copy it into `.github/workflows/` and configure the required secrets/vars. - -## Quick Start: `ocr-review.yml` - -The simplest adoption path: this demo delegates every step — checkout, OCR install, review, comment posting, artifact upload — to the official reusable composite action at [`action.yml`](../../action.yml) via a single `uses: alibaba/open-code-review@main` step. It covers both automatic PR review (`pull_request_target: opened/synchronize/reopened`) and on-demand re-review via comments (`/open-code-review` or `@open-code-review`). No inline scripts to maintain — `@main` always runs the latest action; pin to a version tag or commit SHA when reproducibility matters. - -```bash -mkdir -p .github/workflows -cp ocr-review.yml .github/workflows/ocr-review.yml -``` - -The core of the demo is a single action step: - -```yaml -- uses: alibaba/open-code-review@main - with: - llm_url: ${{ secrets.OCR_LLM_URL }} - llm_auth_token: ${{ secrets.OCR_LLM_AUTH_TOKEN }} - llm_model: ${{ vars.OCR_LLM_MODEL }} - llm_use_anthropic: ${{ vars.OCR_LLM_USE_ANTHROPIC }} -``` - -See [`action.yml`](../../action.yml) for the full list of inputs, outputs, security guidance, and the four comment-posting modes (sticky summary + incremental). - -## Running on a self-hosted runner - -The demo above runs on GitHub-hosted runners (`runs-on: ubuntu-latest`) and pulls the action from `alibaba/open-code-review@main`. If you prefer to run OCR on your own self-hosted runner — to reach private network resources, keep LLM traffic on-prem, or avoid runner-minute costs — the OCR project itself does exactly this in its own CI. - -See [`.github/workflows/ocr-review.yml`](../../.github/workflows/ocr-review.yml) for that workflow. It runs on `runs-on: self-hosted` inside a `node:24` container. One important caveat: it invokes the action with `uses: ./` only because `action.yml` lives in that same repository — that is an internal shortcut and will not resolve in your repo. As an external user, keep `uses: alibaba/open-code-review@main` (the runner fetches the action automatically); only the runner environment needs to change. What is worth borrowing from it: - -- `runs-on: self-hosted`, optionally with a `container:` image such as `node:24` (the action needs Node.js; git is installed automatically if missing). -- Marking the workspace as a trusted git `safe.directory` when running inside a container (e.g. `git config --global --replace-all safe.directory '*'`) to avoid "dubious ownership" errors. Use `--replace-all` (not `--add`) so repeated runs across multiple self-hosted actions replace rather than accumulate entries in the global git config. -- Pinning action inputs explicitly (`sticky_summary`, `incremental`, `upload_artifacts`, `llm_extra_body`, etc.). - -The action performs its own full `fetch-depth: 0` checkout of the PR internally, so no extra checkout step is needed for the review diff. Adapt the runner settings to your environment and secret layout. +This demo shows how to integrate OpenCodeReview into your GitHub Actions workflow to automatically review Pull Requests and post review comments. ## How It Works @@ -44,39 +10,40 @@ PR Created/Updated → GitHub Actions Triggered → OCR Reviews Diff → Comment Comment with trigger keyword ↗ ``` -1. When a PR is opened, the workflow triggers (uses `pull_request_target` for fork secret access). -2. Alternatively, when a comment containing `/open-code-review` or `@open-code-review` is posted on a PR, the workflow triggers. -3. The reusable action installs OCR, fetches the PR head blobs, computes `git merge-base`, and runs `ocr review --from --to --format json`. -4. It parses the JSON output and posts inline review comments on the PR via the Pull Request Review API, plus a summary comment (an issue comment on the PR). +1. When a PR is opened, the workflow triggers (uses `pull_request_target` for fork secret access) +2. Alternatively, when a comment containing `/open-code-review` or `@open-code-review` is posted on a PR, the workflow triggers +3. It installs OCR via `npm install -g @alibaba-group/open-code-review` +4. Runs `ocr review --from origin/ --to --format json` to analyze the diff (uses commit SHA to support fork PRs) +5. Parses the JSON output and posts inline review comments on the PR using GitHub's Pull Request Review API ## Setup -### Configure secrets and variables +### 1. Copy the workflow file -Go to your repository's **Settings → Secrets and variables → Actions**. +Copy `ocr-review.yml` to your repository's `.github/workflows/` directory: -**Secrets:** +```bash +mkdir -p .github/workflows +cp ocr-review.yml .github/workflows/ocr-review.yml +``` + +### 2. Configure secrets + +Go to your repository's **Settings → Secrets and variables → Actions** and add: | Secret | Required | Description | |--------|----------|-------------| | `OCR_LLM_URL` | Yes | LLM API endpoint URL (e.g., `https://api.openai.com/v1/chat/completions`) | -| `OCR_LLM_AUTH_TOKEN` | Yes | API authentication token (mapped to env `OCR_LLM_TOKEN` internally) | +| `OCR_LLM_AUTH_TOKEN` | Yes | API authentication token | +| `OCR_LLM_MODEL` | No | Model name (defaults to `gpt-4o`) | +| `OCR_LLM_USE_ANTHROPIC` | No | Set to `true` if using Anthropic Claude models | -**Variables:** - -| Variable | Required | Description | -|----------|----------|-------------| -| `OCR_LLM_MODEL` | Yes | Model name | -| `OCR_LLM_USE_ANTHROPIC` | Yes | `true` for Anthropic Claude, `false` for OpenAI-compatible | - -> **Note:** `GITHUB_TOKEN` is automatically provided by GitHub Actions with the required `pull-requests: write` permission. The action also sets `llm.extra_body` to disable thinking mode for compatibility with various LLM providers. +> **Note:** `GITHUB_TOKEN` is automatically provided by GitHub Actions with the required `pull-requests: write` permission. +> +> The workflow also configures `llm.extra_body` to disable thinking mode for compatibility with various LLM providers. ## Customization -> These knobs are action inputs — they apply to the demo workflow and any workflow calling `alibaba/open-code-review@main`. - -See [`action.yml`](../../action.yml) for the full input list. Workflow-level settings (triggers, keywords) are edited in the workflow file itself. - ### Change the trigger events Modify the `on.pull_request_target.types` array in the workflow file: @@ -89,152 +56,76 @@ on: ### Customize comment trigger keywords -By default the workflow also re-reviews on demand when a PR comment starts with `/open-code-review` or `@open-code-review`. The `if` condition is more defensive than a bare keyword check — it gates comment triggers so only authorized humans can spend LLM quota: +By default, the workflow triggers when a PR comment starts with `/open-code-review` or `@open-code-review`. You can customize these keywords by modifying the `if` condition in the workflow: ```yaml if: | - github.event_name == 'pull_request_target' - || ( - github.event_name == 'issue_comment' - && github.event.issue.pull_request - && github.event.comment.user.type != 'Bot' - && ( - github.event.comment.author_association == 'MEMBER' - || github.event.comment.author_association == 'OWNER' - || github.event.comment.author_association == 'COLLABORATOR' - ) - && ( - startsWith(github.event.comment.body, '/open-code-review') - || startsWith(github.event.comment.body, '@open-code-review') - ) - ) + github.event_name == 'pull_request_target' || + (github.event_name == 'issue_comment' && github.event.issue.pull_request && startsWith(github.event.comment.body, '/review')) || + (github.event_name == 'issue_comment' && github.event.issue.pull_request && startsWith(github.event.comment.body, '@mybot')) ``` -Each clause guards against a different abuse vector: +Or use a more flexible pattern with `contains` to trigger on any comment containing the keyword: -- `github.event.issue.pull_request` — the comment must be on a PR, not a regular issue. -- `github.event.comment.user.type != 'Bot'` — ignore bot comments. `GITHUB_TOKEN` already suppresses events from comments it posted, but a PAT or GitHub App token would not, so this is a safety net against self-triggering loops. -- `author_association == 'MEMBER' | 'OWNER' | 'COLLABORATOR'` — only repository collaborators can trigger a (billable) re-review, preventing arbitrary commenters from draining LLM quota. -- The `startsWith(...)` pair — the actual trigger keywords. +```yaml +if: | + github.event_name == 'pull_request_target' || + (github.event_name == 'issue_comment' && github.event.issue.pull_request && contains(github.event.comment.body, '/review')) +``` -To change the keywords, edit only that final pair (e.g. `/review` and `@mybot`), or swap `startsWith` for `contains` to match a substring anywhere in the comment body. Keep the preceding guards intact. - -The same predicate is mirrored in the workflow's `concurrency.group`: matching events share a per-PR group (`ocr-`) so a new review cancels any stale one, while non-matching comments land in a unique `noop-` group and are skipped instantly without disrupting a running review. If you change the keywords in `if`, mirror the change in `concurrency.group` too. +> **Note:** The condition `github.event.issue.pull_request` ensures the comment is on a PR, not a regular issue. ### Use a specific OCR version ```yaml -- uses: alibaba/open-code-review@main - with: - ocr_version: 1.0.0 +- name: Install OpenCodeReview + run: npm install -g @alibaba-group/open-code-review@1.0.0 ``` ### Add custom review rules -```yaml -- uses: alibaba/open-code-review@main - with: - rule: ./my-rules.json -``` - -> Security: do not point `rule` at a file sourced from the PR branch when secrets are in scope; use a trusted rules file from your base branch. - -### Control comment posting (sticky summary & incremental) - -The action posts a summary issue comment plus inline review comments. Two inputs select the posting mode (combined, they give the four modes referenced above); a third tunes the incremental overlap test: - -| Input | Default | Description | -|-------|---------|-------------| -| `sticky_summary` | `'true'` | Update an existing summary comment in place instead of posting a new one each run. | -| `incremental` | `'false'` | Only append inline comments whose `(path, line range)` does not overlap an existing bot review comment. History is never deleted (non-destructive). | -| `incremental_overlap_threshold` | `'0.6'` | IoU threshold `incremental` uses to decide whether a multi-line comment overlaps an existing one. Two single-line comments match on the same line; single- vs multi-line never match. Ignored unless `incremental` is `'true'`. | +Use the `--rule` flag to pass a custom rules JSON file: ```yaml -- uses: alibaba/open-code-review@main - with: - sticky_summary: 'true' - incremental: 'true' - incremental_overlap_threshold: '0.75' +- name: Run OCR review + run: ocr review --rule ./my-rules.json --from origin/${{ github.base_ref }} --to origin/${{ github.head_ref }} ``` -> `sticky_summary` and `incremental` must be quoted strings (`'true'`/`'false'`); the action compares them as strings, so an unquoted YAML boolean will not match. - ### Adjust retry and delay settings -When posting review comments individually (fallback mode), the action honors GitHub rate-limit headers (`retry-after`, `x-ratelimit-*`) with exponential backoff. The retry strategy follows GitHub's documented guidance for REST API rate limits — see [Rate limits for the REST API](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api?apiVersion=2026-03-10) for details on primary/secondary rate limits and recommended retry behavior: - -- **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): +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): | Variable | Default | Description | |----------|---------|-------------| -| `OCR_RETRY_BASE_DELAY` | `60000` | Base delay (ms) for exponential backoff when no retry header is present | -| `OCR_RETRY_MAX_DELAY` | `300000` | Maximum delay (ms) cap applied to every computed wait | +| `OCR_RETRY_BASE_DELAY` | `60000` | Base delay (ms) for exponential backoff when no retry header is present (per GitHub's "at least one minute" recommendation for secondary limits) | +| `OCR_RETRY_MAX_DELAY` | `300000` | Maximum delay (ms) cap applied to every computed wait, including retry-after and x-ratelimit-reset, so a far-future reset cannot stall the job past its timeout | | `OCR_MAX_RETRIES` | `3` | Maximum retry attempts per comment when rate-limited | -| `OCR_SUCCESS_DELAY` | `2000` | Delay (ms) after a successful comment post | -| `OCR_FAILURE_DELAY` | `1000` | Delay (ms) after a non-retryable failure | -| `OCR_LOW_REMAINING_THRESHOLD` | `3` | When x-ratelimit-remaining is at or below this value, proactively increase request spacing | +| `OCR_SUCCESS_DELAY` | `2000` | Delay (ms) after a successful comment post to pace subsequent requests | +| `OCR_FAILURE_DELAY` | `1000` | Delay (ms) after a non-rate-limit failure to pace subsequent requests | +| `OCR_LOW_REMAINING_THRESHOLD` | `3` | When x-ratelimit-remaining is at or below this value, proactively increase request spacing to avoid hitting the limit | | `OCR_LOW_REMAINING_SPACING` | `10000` | Request spacing (ms) used when remaining quota is low | -| `OCR_READ_SUCCESS_DELAY` | `500` | Delay (ms) after a successful read API call (`listReviews` / `listReviewComments` / `listIssueComments`) used for the idempotency check. Reads are cheaper than writes, so the default is shorter | -| `OCR_READ_LOW_REMAINING_SPACING` | `5000` | Request spacing (ms) for read calls when remaining quota is low | -For example, to raise the per-comment retry count to 5, set `OCR_MAX_RETRIES` on the **job's** `env:` — not on the `uses:` step. A composite action does not forward the caller's step-level `env:` into its internal steps' process environment, so a step-level value would be silently ignored; the job-level value is inherited by the action's comment-posting step and read via `process.env`: +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: ```yaml -jobs: - code-review: - runs-on: ubuntu-latest - env: - OCR_MAX_RETRIES: 5 - steps: - - uses: alibaba/open-code-review@main - with: - llm_url: ${{ secrets.OCR_LLM_URL }} - # ...other inputs -``` - -These variables are optional. See GitHub's [Rate limits for the REST API](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api). - -#### Idempotency: avoiding duplicate review comments - -When the batch `createReview` call fails with a `5xx` error, the request may still have landed on the GitHub server (the response was simply lost). Before retrying per-comment, the 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 +- name: Run OCR review + run: ocr review --concurrency 5 --from origin/${{ github.base_ref }} --to origin/${{ github.head_ref }} ``` ### Provide background context +Use the `--background` flag to pass additional context that helps OCR better understand the purpose of the changes: + ```yaml -- uses: alibaba/open-code-review@main - with: - background: ${{ github.event.pull_request.title }} +- name: Run OCR review + run: ocr review --background "${{ github.event.pull_request.title }}" --from origin/${{ github.base_ref }} --to origin/${{ github.head_ref }} ``` -Particularly useful when PR titles follow semantic conventions (e.g., `feat(auth): add OAuth2 support`). - -> Note: `github.event.pull_request.title` is only present on `pull_request_target` events, so it is empty for comment-triggered re-reviews. To cover both trigger types, have the pr-context step also output the title and fall back to it: -> -> ```yaml -> # inside the pr-context script (which only runs for issue_comment): -> core.setOutput('title', pullRequest.title); -> ``` -> ```yaml -> - uses: alibaba/open-code-review@main -> with: -> background: ${{ steps.pr-context.outputs.title || github.event.pull_request.title }} -> ``` +This is particularly useful when your PR titles follow semantic conventions (e.g., `feat(auth): add OAuth2 support`) that clearly summarize what the PR implements. The background information helps OCR provide more relevant and context-aware review comments. ### Customize the review comment author with GitHub App @@ -277,53 +168,40 @@ Add the following secrets to your repository (**Settings → Secrets and variabl |--------|-------------| | `GITHUB_APP_ID` | Your GitHub App's ID | | `GITHUB_APP_PRIVATE_KEY` | Contents of the `.pem` file (including `-----BEGIN RSA PRIVATE KEY-----` and `-----END RSA PRIVATE KEY-----`) | -| `GITHUB_APP_INSTALLATION_ID` | (Optional) The Installation ID from Step 3 — only needed for apps with multiple installations | +| `GITHUB_APP_INSTALLATION_ID` | The Installation ID from Step 3 | -#### Step 5: Pass the App token to the action +#### Step 5: Update the Workflow -Mint a token with `actions/create-github-app-token` and pass it via the `github_token` input: +Add a step to obtain a token from the GitHub App, then use it in the "Post review comments to PR" step: ```yaml - name: Get GitHub App Token id: app-token - uses: actions/create-github-app-token@main + uses: actions/create-github-app-token@v1 with: app-id: ${{ secrets.GITHUB_APP_ID }} private-key: ${{ secrets.GITHUB_APP_PRIVATE_KEY }} -- uses: alibaba/open-code-review@main +- name: Post review comments to PR + uses: actions/github-script@v7 with: - github_token: ${{ steps.app-token.outputs.token }} - llm_url: ${{ secrets.OCR_LLM_URL }} - llm_auth_token: ${{ secrets.OCR_LLM_AUTH_TOKEN }} - llm_model: ${{ vars.OCR_LLM_MODEL }} - llm_use_anthropic: ${{ vars.OCR_LLM_USE_ANTHROPIC }} + github-token: ${{ steps.app-token.outputs.token }} + script: | + # ... existing script ``` Now review comments will be posted with your custom GitHub App identity (e.g., `OpenCodeReview Bot`), providing a more professional and distinguishable appearance in your PRs. ## Example Output -The action posts two kinds of output on the PR: a **summary issue comment** (in the PR conversation) and **inline review comments** (in the "Files changed" tab). +When a PR is reviewed, comments appear directly in the PR's "Files changed" tab: -### Summary comment +- ✅ If no issues found: A comment saying "No comments generated. Looks good to me." +- 🔍 If issues found: Inline review comments with suggestions using GitHub's native suggestion syntax -A single comment — updated in place on each run when `sticky_summary` is `'true'` (the default) — carries the review outcome and posting statistics. +### Inline Comment Example -- ✅ No issues: `✅ **OpenCodeReview**: No comments generated. Looks good to me.` -- 🔍 Issues found: a header line plus per-outcome counts, for example: - -```markdown -🔍 **OpenCodeReview** found **3** issue(s) in this PR. -- ✅ Successfully posted inline: 2 comment(s) -- 📝 In summary (no line info): 1 comment(s) -``` - -The counts are mutually exclusive and sum to the total: `inline` (landed as review inline comments), `summary` (no line info, rendered in the summary body), `skipped` (suppressed by incremental overlap filtering), and `failed` (had line info but could not be posted). Any warnings are appended as a bulleted list. - -### Inline comments - -Comments with valid line info are posted as PR review comments in "Files changed". Each carries the review content plus, when a fix is available, a GitHub-native `suggestion` block so reviewers can apply it with one click: +The workflow uses GitHub's `suggestion` code block syntax, so reviewers can apply fixes with one click: ````markdown **Suggestion:** @@ -332,8 +210,6 @@ Comments with valid line info are posted as PR review comments in "Files changed ``` ```` -Comments that have no line info, or that could not be posted inline (e.g. their line fell outside the current diff), are rendered in the summary body instead — each under a `### 📄 ` heading, with a collapsible `

` "💡 Suggested Change" (Before/After) when a fix is available. - ## Supported LLM Providers OCR supports both OpenAI and Anthropic API formats: @@ -342,25 +218,22 @@ OCR supports both OpenAI and Anthropic API formats: - OpenAI (GPT-4o, GPT-4, etc.) - Azure OpenAI - Self-hosted models (vLLM, Ollama, etc.) -- **Anthropic APIs** (set variable `OCR_LLM_USE_ANTHROPIC=true`, i.e. `llm_use_anthropic: true`): +- **Anthropic APIs** (set `OCR_LLM_USE_ANTHROPIC: true`): - Anthropic Claude models ## Troubleshooting ### Common Issues -1. **Job fails / "Failed to parse OCR output"**: When `ocr review` exits non-zero the action fails the job with that exit code (the comment-posting step is skipped); a zero exit with malformed JSON surfaces as a parse error in the summary. In both cases, check that `OCR_LLM_URL` and `OCR_LLM_AUTH_TOKEN` are set correctly, then inspect the uploaded `ocr-stderr.log` artifact (also printed in the "Run OpenCodeReview" step log) for the underlying error. -2. **"Cannot find merge-base"**: The action fetches full history (`fetch-depth: 0`) and the PR head (`git fetch origin pull//head`); if this still fails, ensure `permissions: contents: read` is set and the base branch is accessible (e.g., not deleted). -3. **Review comments not on the expected lines**: Comments are attached to the PR head commit. If a comment's line falls outside the current diff (the PR was force-pushed or updated mid-review), GitHub rejects the inline post and the comment is rendered in the summary instead. The workflow's concurrency group cancels stale runs on new pushes. -4. **No summary or comments at all**: Confirm the job's `permissions` include `pull-requests: write`, and that `github_token` (defaults to `${{ github.token }}`) is not overridden with a token lacking those scopes. +1. **"Failed to parse OCR output"**: Check that `OCR_LLM_URL` and `OCR_LLM_AUTH_TOKEN` secrets are correctly set +2. **"Cannot find merge-base"**: Ensure `fetch-depth: 0` is set in the checkout step +3. **Review comments not appearing on correct lines**: This can happen when the diff has changed since the review started; the workflow handles this gracefully with a fallback to issue comments ### Debugging -The action does not use an `OCR_DEBUG` flag. To diagnose a run: +Enable debug logging by adding to the OCR review step: -- **Artifacts**: with `upload_artifacts: 'true'` (the default), the raw `ocr-result.json` and `ocr-stderr.log` are uploaded as workflow artifacts named `ocr-review-result--`. Download them from the run's **Artifacts** section. -- **Step log**: the "Run OpenCodeReview" step prints both the JSON result and stderr to the workflow log. -- **Action outputs**: the step exposes `comments_total`, `comments_inline`, `comments_skipped`, `comments_failed`, and `summary_comment_url` outputs — inspect them in the job's step outputs. -- **GitHub step debug**: for verbose Actions runner diagnostics, enable the repository secret `ACTIONS_STEP_DEBUG=true` (standard GitHub Actions mechanism). - -To stop uploading the raw artifacts, set `upload_artifacts: 'false'`. +```yaml +env: + OCR_DEBUG: "1" +``` diff --git a/examples/github_actions/ocr-review.yml b/examples/github_actions/ocr-review.yml index 11a57a3..360eb2e 100644 --- a/examples/github_actions/ocr-review.yml +++ b/examples/github_actions/ocr-review.yml @@ -1,64 +1,59 @@ # OpenCodeReview - GitHub Actions PR Auto-Review Demo # -# Demonstrates invoking the reusable action for both automatic PR review -# (pull_request_target: opened/synchronize/reopened) and on-demand re-review -# via comments starting with '/open-code-review' or '@open-code-review'. +# This workflow automatically reviews pull requests using OpenCodeReview +# and posts review comments directly on the PR. # -# Required secrets/vars (Settings -> Secrets and variables -> Actions): -# secret OCR_LLM_URL LLM API endpoint -# secret OCR_LLM_AUTH_TOKEN LLM auth token (mapped to OCR_LLM_TOKEN) -# variable OCR_LLM_MODEL model name -# variable OCR_LLM_USE_ANTHROPIC 'true' for Anthropic, 'false' for OpenAI-compatible +# Triggers: +# - PR opened (uses pull_request_target for fork secret access) +# - Comment on PR containing '/open-code-review' or '@open-code-review' # -# For the full list of action inputs/outputs and the four comment-posting modes -# (sticky / incremental), see action.yml at the repo root. +# Required secrets: +# OCR_LLM_URL - LLM API endpoint (e.g., https://api.openai.com/v1/chat/completions) +# OCR_LLM_AUTH_TOKEN - Authentication token for the LLM API +# +# Optional secrets: +# OCR_LLM_MODEL - Model name (default: gpt-4o) +# OCR_LLM_USE_ANTHROPIC - Set to 'true' if using Anthropic Claude models +# +# Optional variables (for retry/delay tuning): +# The retry strategy follows GitHub's documented guidance for REST API rate limits: +# https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api +# - Primary rate limit exhausted (x-ratelimit-remaining=0): wait until x-ratelimit-reset. +# - Secondary rate limit with a retry-after header: wait exactly that long. +# - Secondary rate limit with NO header: wait at least one minute, then use +# exponential backoff on continued failures. +# +# OCR_RETRY_BASE_DELAY - Base delay (ms) for exponential backoff when no retry +# header is present (default: 60000, per GitHub's +# "at least one minute" recommendation for secondary limits). +# OCR_RETRY_MAX_DELAY - Maximum delay (ms) cap applied to EVERY computed wait, +# including retry-after and x-ratelimit-reset, so a far-future +# reset cannot stall the job past its timeout (default: 300000 = 5 min). +# OCR_MAX_RETRIES - Max retry attempts per comment when rate-limited (default: 3). +# OCR_SUCCESS_DELAY - Delay (ms) between successful comment posts to pace requests (default: 2000). +# OCR_FAILURE_DELAY - Delay (ms) after a non-retryable failure to pace subsequent requests (default: 1000). +# OCR_LOW_REMAINING_THRESHOLD - When x-ratelimit-remaining is at or below this value, +# proactively increase request spacing to avoid hitting the limit +# (default: 3; GitHub best practice is to watch the header and slow down). +# OCR_LOW_REMAINING_SPACING - Request spacing (ms) used when remaining quota is low +# (default: 10000 = 10s). +# +# Note: GITHUB_TOKEN is automatically provided by GitHub Actions. +# Note: The workflow also configures llm.extra_body to '{"thinking": {"type": "disabled"}}' +# to disable thinking mode for compatibility with various LLM providers. name: OpenCodeReview PR Review -# Conditional concurrency group. -# -# GitHub Actions evaluates concurrency BEFORE job-level if-conditions. With a -# flat group (ocr-), every comment on the PR — even an unrelated -# conversation reply that will be skipped — enters the same group and, because -# cancel-in-progress is true, cancels any in-progress review. The result: a -# single normal comment kills a running review, and you see "two runs, one -# cancelled" in the Actions tab. -# -# Fix: matching events (PR events + /open-code-review comments) share a per-PR -# group so a new review cancels any stale one for the same PR. Non-matching -# comments land in a unique noop- group that can never collide with a -# real review, so they are skipped instantly without disrupting anything. concurrency: - group: >- - ${{ - ( - github.event_name == 'pull_request_target' - || ( - github.event_name == 'issue_comment' - && github.event.issue.pull_request - && github.event.comment.user.type != 'Bot' - && ( - github.event.comment.author_association == 'MEMBER' - || github.event.comment.author_association == 'OWNER' - || github.event.comment.author_association == 'COLLABORATOR' - ) - && ( - startsWith(github.event.comment.body, '/open-code-review') - || startsWith(github.event.comment.body, '@open-code-review') - ) - ) - ) - && format('ocr-{0}', github.event.pull_request.number || github.event.issue.number) - || format('noop-{0}', github.run_id) - }} + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true on: # Use pull_request_target instead of pull_request so that secrets are - # available even for PRs from forks. This is safe because the reusable - # action only reads the diff and does not execute any code from the PR. + # available even for PRs from forks. This is safe because OCR only reads + # the diff and does not execute any code from the PR. pull_request_target: - types: [opened, synchronize, reopened] + types: [opened] issue_comment: types: [created] @@ -69,38 +64,19 @@ permissions: jobs: code-review: runs-on: ubuntu-latest - timeout-minutes: 30 - # Run on PR events, or on human-authored comments starting with trigger - # keywords. Bot comments are excluded as a safety net: GITHUB_TOKEN already - # suppresses events from bot-posted comments, but a PAT/App token would not. - # issue_comment triggers are further gated on author_association so only - # MEMBER/OWNER/COLLABORATOR users can spend LLM quota via re-review. + # Run on PR events, or on comments starting with trigger keywords if: | - github.event_name == 'pull_request_target' - || ( - github.event_name == 'issue_comment' - && github.event.issue.pull_request - && github.event.comment.user.type != 'Bot' - && ( - github.event.comment.author_association == 'MEMBER' - || github.event.comment.author_association == 'OWNER' - || github.event.comment.author_association == 'COLLABORATOR' - ) - && ( - startsWith(github.event.comment.body, '/open-code-review') - || startsWith(github.event.comment.body, '@open-code-review') - ) - ) + github.event_name == 'pull_request_target' || + (github.event_name == 'issue_comment' && github.event.issue.pull_request && startsWith(github.event.comment.body, '/open-code-review')) || + (github.event_name == 'issue_comment' && github.event.issue.pull_request && startsWith(github.event.comment.body, '@open-code-review')) steps: - name: Get PR context id: pr-context - if: github.event_name == 'issue_comment' + if: github.event_name != 'pull_request_target' uses: actions/github-script@v7 with: script: | - // For issue_comment events, resolve PR base/head so the action - // can review the right diff (issue_comment has no top-level - // pull_request payload fields). + // For issue_comment events, get PR info const prNumber = context.issue.number; const { data: pullRequest } = await github.rest.pulls.get({ owner: context.repo.owner, @@ -108,16 +84,469 @@ jobs: pull_number: prNumber }); core.setOutput('base_ref', pullRequest.base.ref); + core.setOutput('head_ref', pullRequest.head.ref); core.setOutput('head_sha', pullRequest.head.sha); - - name: Run OpenCodeReview - uses: alibaba/open-code-review@main + - name: Checkout repository + uses: actions/checkout@v4 with: - llm_url: ${{ secrets.OCR_LLM_URL }} - llm_auth_token: ${{ secrets.OCR_LLM_AUTH_TOKEN }} - llm_model: ${{ vars.OCR_LLM_MODEL }} - llm_use_anthropic: ${{ vars.OCR_LLM_USE_ANTHROPIC }} - # For issue_comment triggers, pass the resolved refs; for - # pull_request_target the action resolves them from the event. - base_ref: ${{ steps.pr-context.outputs.base_ref }} - head_sha: ${{ steps.pr-context.outputs.head_sha }} + fetch-depth: 0 # Full history needed for merge-base diff + ref: ${{ github.event.pull_request.head.sha || steps.pr-context.outputs.head_sha }} + + - name: Fetch PR head ref (ensures fork commits are available) + run: git fetch origin pull/${{ github.event.pull_request.number || github.event.issue.number }}/head + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '24' + + - name: Install OpenCodeReview + run: | + npm install -g @alibaba-group/open-code-review + echo "OpenCodeReview installed with version:" + ocr version || true + + - name: Configure OCR + run: | + ocr config set llm.url ${{ secrets.OCR_LLM_URL }} + ocr config set llm.auth_token ${{ secrets.OCR_LLM_AUTH_TOKEN }} + ocr config set llm.model ${{ secrets.OCR_LLM_MODEL }} + ocr config set llm.use_anthropic ${{ secrets.OCR_LLM_USE_ANTHROPIC }} + ocr config set llm.extra_body '{"thinking": {"type": "disabled"}}' + + - name: Run OpenCodeReview + id: review + run: | + # Get base ref and head SHA from PR context (different for comment triggers) + # Note: We use HEAD_SHA instead of origin/${HEAD_REF} to support fork PRs, + # because fork branches don't exist on the origin remote. + if [ "${{ github.event_name }}" = "pull_request_target" ]; then + BASE_REF="${{ github.event.pull_request.base.ref }}" + HEAD_SHA="${{ github.event.pull_request.head.sha }}" + else + BASE_REF="${{ steps.pr-context.outputs.base_ref }}" + HEAD_SHA="${{ steps.pr-context.outputs.head_sha }}" + fi + + echo "Reviewing PR: ${HEAD_SHA} against origin/${BASE_REF}" + + # Run OCR in range mode with JSON output + ocr review \ + --from "origin/${BASE_REF}" \ + --to "${HEAD_SHA}" \ + --format json \ + > /tmp/ocr-result.json 2>/tmp/ocr-stderr.log || true + + echo "OCR review completed. Output:" + cat /tmp/ocr-result.json + echo "OCR review completed. Error log:" + cat /tmp/ocr-stderr.log + + - name: Post review comments to PR + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const fs = require('fs'); + const 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)); + } diff --git a/extensions/vscode/.gitignore b/extensions/vscode/.gitignore index e7377a7..ae7fae2 100644 --- a/extensions/vscode/.gitignore +++ b/extensions/vscode/.gitignore @@ -2,5 +2,4 @@ .superpowers/ node_modules/ reference -out/ -*.vsix \ No newline at end of file +out/ \ No newline at end of file diff --git a/extensions/vscode/.vscodeignore b/extensions/vscode/.vscodeignore index d64358b..213ba03 100644 --- a/extensions/vscode/.vscodeignore +++ b/extensions/vscode/.vscodeignore @@ -13,4 +13,3 @@ jest.config.js .superpowers/** prototype.html resources/icon-old.svg -__mocks__/** diff --git a/extensions/vscode/LICENSE b/extensions/vscode/LICENSE deleted file mode 100644 index a4f1fec..0000000 --- a/extensions/vscode/LICENSE +++ /dev/null @@ -1,201 +0,0 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this definition, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to the Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a complaint) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that you distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act on - Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "{}" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the same - "printed page" as the copyright notice for easier identification within - third-party archives. - - Copyright 2026 Alibaba - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. diff --git a/extensions/vscode/open-code-review-vscode-0.1.0.vsix b/extensions/vscode/open-code-review-vscode-0.1.0.vsix new file mode 100644 index 0000000..428b077 Binary files /dev/null and b/extensions/vscode/open-code-review-vscode-0.1.0.vsix differ diff --git a/extensions/vscode/package.json b/extensions/vscode/package.json index 53d9678..7644112 100644 --- a/extensions/vscode/package.json +++ b/extensions/vscode/package.json @@ -2,12 +2,12 @@ "name": "open-code-review-vscode", "displayName": "Open Code Review", "description": "%ocr.description%", - "version": "0.1.1", + "version": "0.1.0", "publisher": "open-code-review", "license": "Apache-2.0", "repository": { "type": "git", - "url": "git+https://github.com/alibaba/open-code-review.git", + "url": "git+https://github.com/nigulasikk/open-code-review.git", "directory": "extensions/vscode" }, "engines": { @@ -16,7 +16,6 @@ "categories": [ "Other" ], - "icon": "resources/icon.png", "main": "./out/extension.js", "activationEvents": [ "onStartupFinished" diff --git a/extensions/vscode/resources/icon-old.svg b/extensions/vscode/resources/icon-old.svg new file mode 100644 index 0000000..3ce9a0e --- /dev/null +++ b/extensions/vscode/resources/icon-old.svg @@ -0,0 +1 @@ + diff --git a/extensions/vscode/resources/icon.png b/extensions/vscode/resources/icon.png deleted file mode 100644 index 0c2add0..0000000 Binary files a/extensions/vscode/resources/icon.png and /dev/null differ diff --git a/extensions/vscode/resources/icon.svg b/extensions/vscode/resources/icon.svg index c4a054b..7a0e418 100644 --- a/extensions/vscode/resources/icon.svg +++ b/extensions/vscode/resources/icon.svg @@ -1 +1,21 @@ - \ No newline at end of file + + + + + diff --git a/extensions/vscode/src/extension/extension.ts b/extensions/vscode/src/extension/extension.ts index 5bc0c56..c398e6b 100644 --- a/extensions/vscode/src/extension/extension.ts +++ b/extensions/vscode/src/extension/extension.ts @@ -16,7 +16,7 @@ export function activate(context: vscode.ExtensionContext): void { const cli = new CliService('ocr'); const config = new ConfigService(cli); const git = new GitService(output); - const comments = new CommentProvider(extensionUri, git); + const comments = new CommentProvider(extensionUri); const sidebar = new SidebarProvider(extensionUri, cli, config, git, comments); const configPanel = new ConfigPanelProvider(extensionUri, cli, config, (cfg) => sidebar.pushConfig(cfg)); diff --git a/extensions/vscode/src/extension/providers/CommentProvider.ts b/extensions/vscode/src/extension/providers/CommentProvider.ts index 84742f7..02d1dba 100644 --- a/extensions/vscode/src/extension/providers/CommentProvider.ts +++ b/extensions/vscode/src/extension/providers/CommentProvider.ts @@ -1,34 +1,22 @@ import { t, resolveLocale, SupportedLocale } from '../../shared/i18n'; import * as vscode from 'vscode'; -import { ReviewComment, CommentStatus, CommentSyncState, ReviewContext, ReviewMode } from '../../shared/types'; +import { ReviewComment, CommentStatus, CommentSyncState } from '../../shared/types'; import { COMMENT_CONTROLLER_ID } from '../../shared/constants'; import { LineOffsetTracker } from './lineOffset'; -import { GitService } from '../services/GitService'; -import { - MountableCommentAnchor, - resolveCommentAnchor, - CommentAnchorDeps, - SidebarOnlyReason, -} from './commentAnchor'; export class CommentProvider { private controller: vscode.CommentController; + // 以 comment 在 result.comments 中的原始下标为 key,与 webview 共用同一索引空间。 + // 打不开的文件(如目录)没有 thread,但下标依旧保留,避免错位。 private threads = new Map(); - private mounts = new Map(); - private jumpable = new Set(); - private jumpBlockReasons = new Map(); private comments: ReviewComment[] = []; private status = new Map(); private offsets = new LineOffsetTracker(); private syncListeners: Array<(s: CommentSyncState[]) => void> = []; - private reviewContext: ReviewContext = { mode: ReviewMode.Workspace }; private locale: SupportedLocale; - constructor( - private extensionUri: vscode.Uri, - private git: GitService, - ) { + constructor(private extensionUri: vscode.Uri) { this.locale = resolveLocale(vscode.env.language); this.controller = vscode.comments.createCommentController(COMMENT_CONTROLLER_ID, t(this.locale, 'ext.commentController')); } @@ -39,92 +27,62 @@ export class CommentProvider { private emitSync(): void { const states: CommentSyncState[] = this.comments.map((_, i) => ({ - index: i, - status: this.status.get(i) ?? 'pending', - jumpable: this.jumpable.has(i), + index: i, status: this.status.get(i) ?? 'pending', })); this.syncListeners.forEach((fn) => fn(states)); } - /** 展示审查评论:能解析到 git/工作区快照的挂 thread,否则仅侧边栏。 */ - async show(comments: ReviewComment[], ctx: ReviewContext): Promise { + /** + * 展示审查评论。 + * @param inEditor 是否在编辑器内创建 CommentThread。仅工作区模式为 true; + * 分支对比/单次提交模式下被审查代码不在当前工作区,行号会错位,故只在侧边栏展示。 + */ + async show(comments: ReviewComment[], inEditor = true): Promise { this.clear(); - this.reviewContext = ctx; + // 不重排:保持与 webview(result.comments)相同的顺序与下标 this.comments = comments; - const root = vscode.workspace.workspaceFolders?.[0].uri.fsPath; if (!root) return; - await this.git.prepareReviewFileStatus(ctx); - const deps = this.buildAnchorDeps(root, ctx); + // 非工作区模式:只登记评论与状态供侧边栏同步,不在编辑器内放置 thread。 + if (!inEditor) { + for (let i = 0; i < this.comments.length; i++) this.status.set(i, 'pending'); + this.emitSync(); + return; + } let firstShown = -1; for (let i = 0; i < this.comments.length; i++) { const c = this.comments[i]; this.status.set(i, 'pending'); - - const anchor = await resolveCommentAnchor(c, ctx, deps); - if (anchor.kind === 'sidebar') { - this.jumpBlockReasons.set(i, anchor.reason); - continue; - } - try { - await vscode.workspace.openTextDocument(anchor.uri); - const body = this.renderBody(c, i, 'pending', anchor.locateNote); - const thread = this.controller.createCommentThread(anchor.uri, anchor.range, [{ - body, - mode: vscode.CommentMode.Preview, + const uri = vscode.Uri.file(`${root}/${c.path}`); + const doc = await vscode.workspace.openTextDocument(uri); + const range = new vscode.Range(Math.max(0, c.startLine - 1), 0, Math.max(0, c.endLine - 1), 0); + const body = this.renderBody(c, i, 'pending'); + const thread = this.controller.createCommentThread(doc.uri, range, [{ + body, mode: vscode.CommentMode.Preview, author: { name: t(this.locale, 'ext.comment.pending') }, }]); thread.canReply = false; thread.label = `${t(this.locale, 'ext.comment.threadLabel')} (${i + 1} / ${this.comments.length})`; - thread.contextValue = this.threadContextValue(c, ctx); + // 有代码建议 → 'pending'(显示应用+忽略);无建议 → 'pendingNoSuggestion'(仅忽略) + thread.contextValue = this.hasSuggestion(c) ? 'pending' : 'pendingNoSuggestion'; thread.collapsibleState = vscode.CommentThreadCollapsibleState.Expanded; this.threads.set(i, thread); - this.mounts.set(i, anchor); - this.jumpable.add(i); if (firstShown < 0) firstShown = i; - } catch { - this.jumpBlockReasons.set(i, 'mount-failed'); - } + } catch { /* 文件打不开(如目录)则无 thread,但保留下标 */ } } - if (firstShown >= 0) await this.jumpTo(firstShown); this.emitSync(); } - private buildAnchorDeps(root: string, ctx: ReviewContext): CommentAnchorDeps { - return { - repoRoot: root, - fileStatus: (path) => this.git.getReviewFileStatus(path), - readAtRef: (ref, path) => this.git.readFileAtRef(ref, path), - readWorkspace: (path) => this.git.readWorkspaceFile(path), - buildDiffUris: (path, status) => this.git.buildCommentDiffUris(path, status, ctx), - toGitUri: async (path, ref) => { - const uri = await this.git.createGitFileUri(path, ref); - if (!uri) throw new Error('git uri unavailable'); - return uri; - }, - }; - } - - private threadContextValue(c: ReviewComment, ctx: ReviewContext): string { - if (ctx.mode !== ReviewMode.Workspace) return 'pendingNoSuggestion'; - return this.hasSuggestion(c) ? 'pending' : 'pendingNoSuggestion'; - } - private hasSuggestion(c: ReviewComment): boolean { return !!(c.suggestionCode && c.suggestionCode.trim()); } - private renderBody( - c: ReviewComment, - _index: number, - _status: CommentStatus, - locateNote?: string, - ): vscode.MarkdownString { - let md = locateNote ? `${locateNote}\n\n${c.content}` : c.content; + private renderBody(c: ReviewComment, _index: number, _status: CommentStatus): vscode.MarkdownString { + let md = c.content; if (this.hasSuggestion(c)) { md += `\n***\n\`\`\`diff\n${c.suggestionCode}\n\`\`\``; } else { @@ -136,10 +94,6 @@ export class CommentProvider { } async apply(index: number): Promise { - if (this.reviewContext.mode !== ReviewMode.Workspace) { - vscode.window.showWarningMessage(t(this.locale, 'ext.comment.applyWorkspaceOnly')); - return; - } const c = this.comments[index]; if (!c) return; const root = vscode.workspace.workspaceFolders?.[0].uri.fsPath; @@ -156,6 +110,8 @@ export class CommentProvider { const range = new vscode.Range(start, 0, end, doc.lineAt(end).text.length); const hasSuggestion = !!(c.suggestionCode && c.suggestionCode.trim()); + // 用 WorkspaceEdit 而非 editor.edit:后者要求目标编辑器为活动编辑器, + // 从评论标题栏按钮触发时焦点在评论控件上,会静默返回 false 导致“点不动”。 const edit = new vscode.WorkspaceEdit(); if (hasSuggestion) edit.replace(uri, range, c.suggestionCode!); else edit.delete(uri, range); @@ -176,7 +132,6 @@ export class CommentProvider { private setStatus(index: number, status: CommentStatus): void { this.status.set(index, status); const thread = this.threads.get(index); - const mount = this.mounts.get(index); if (thread) { const label = { applied: t(this.locale, 'ext.comment.statusApplied'), @@ -184,11 +139,7 @@ export class CommentProvider { falsePositive: t(this.locale, 'ext.comment.statusFalsePositive'), pending: t(this.locale, 'ext.comment.pending'), }[status]; - thread.comments = [{ - ...thread.comments[0], - author: { name: label }, - body: this.renderBody(this.comments[index], index, status, mount?.locateNote), - }] as any; + thread.comments = [{ ...thread.comments[0], author: { name: label }, body: this.renderBody(this.comments[index], index, status) }] as any; thread.contextValue = status; thread.collapsibleState = vscode.CommentThreadCollapsibleState.Collapsed; } @@ -196,128 +147,27 @@ export class CommentProvider { } async jumpTo(index: number): Promise { - const mount = this.mounts.get(index); const thread = this.threads.get(index); - if (!mount || !thread) { - this.showJumpFailed(index); + if (!thread) { + const c = this.comments[index]; + if (c) vscode.window.showWarningMessage(`${t(this.locale, 'ext.comment.jumpFailed')}${c.path}${t(this.locale, 'ext.comment.jumpNotAFile')}`); return; } - - if (mount.diff) { - const opened = await this.openDiffEditor(mount); - if (opened) { - await this.revealInDiffSide(mount.uri, mount.range, mount.side); - } else { - await vscode.window.showTextDocument(mount.uri, { selection: mount.range, preview: false }); - } - } else { - await vscode.window.showTextDocument(mount.uri, { selection: mount.range, preview: false }); - } + await vscode.window.showTextDocument(thread.uri, { selection: thread.range, preview: false }); thread.collapsibleState = vscode.CommentThreadCollapsibleState.Expanded; } - private showJumpFailed(index: number): void { - const c = this.comments[index]; - if (!c) return; - - const reason = this.jumpBlockReasons.get(index) ?? this.inferJumpBlockReason(c); - const key = reason === 'missing-file' || reason === 'mount-failed' - ? 'ext.comment.jumpFileMissing' - : 'ext.comment.jumpLineUnresolved'; - vscode.window.showWarningMessage( - t(this.locale, key).replace('{path}', c.path), - ); - } - - /** 未记录原因时根据评论元数据推断(如 L0 多为行号未解析)。 */ - private inferJumpBlockReason(c: ReviewComment): SidebarOnlyReason | 'mount-failed' { - if (c.startLine <= 0 && c.endLine <= 0) return 'unresolved'; - return 'missing-file'; - } - - private async openDiffEditor(mount: MountableCommentAnchor): Promise { - if (!mount.diff) return false; - try { - await vscode.commands.executeCommand( - 'vscode.diff', - mount.diff.left, - mount.diff.right, - mount.diff.title, - { preview: false }, - ); - return true; - } catch { - return false; - } - } - - /** 在已打开的 diff 编辑器中定位到挂载侧行,不再额外打开单文件 tab。 */ - private async revealInDiffSide( - uri: vscode.Uri, - range: vscode.Range, - side: MountableCommentAnchor['side'], - ): Promise { - for (let attempt = 0; attempt < 8; attempt++) { - const editor = this.findEditorForUri(uri, side); - if (editor) { - editor.selection = new vscode.Selection(range.start, range.end); - editor.revealRange(range, vscode.TextEditorRevealType.InCenter); - return; - } - await new Promise((r) => setTimeout(r, 40)); - } - } - - private findEditorForUri( - uri: vscode.Uri, - side?: MountableCommentAnchor['side'], - ): vscode.TextEditor | undefined { - for (const editor of vscode.window.visibleTextEditors) { - if (this.urisMatch(editor.document.uri, uri)) return editor; - } - - const tab = vscode.window.tabGroups.activeTabGroup.activeTab; - if (tab?.input instanceof vscode.TabInputTextDiff) { - const { original, modified } = tab.input; - const prefer = side === 'left' ? original : side === 'right' ? modified : undefined; - const candidates = prefer ? [prefer, original, modified] : [original, modified]; - for (const candidate of candidates) { - if (!this.urisMatch(candidate, uri)) continue; - const editor = vscode.window.visibleTextEditors.find( - (ed) => this.urisMatch(ed.document.uri, candidate), - ); - if (editor) return editor; - } - } - return undefined; - } - - private urisMatch(a: vscode.Uri, b: vscode.Uri): boolean { - if (a.toString() === b.toString()) return true; - if (a.scheme !== b.scheme) return false; - if (a.scheme === 'git') { - const refA = new URLSearchParams(a.query).get('ref'); - const refB = new URLSearchParams(b.query).get('ref'); - return a.path === b.path && refA === refB; - } - return a.fsPath === b.fsPath; - } - indexOfThread(thread: vscode.CommentThread): number { - for (const [i, th] of this.threads) if (th === thread) return i; + for (const [i, t] of this.threads) if (t === thread) return i; return -1; } clear(): void { - this.threads.forEach((th) => th.dispose()); + this.threads.forEach((t) => t.dispose()); this.threads.clear(); - this.mounts.clear(); - this.jumpable.clear(); - this.jumpBlockReasons.clear(); this.comments = []; this.status.clear(); this.offsets.clear(); - this.reviewContext = { mode: ReviewMode.Workspace }; } dispose(): void { diff --git a/extensions/vscode/src/extension/providers/SidebarProvider.ts b/extensions/vscode/src/extension/providers/SidebarProvider.ts index f3beebd..f63ff58 100644 --- a/extensions/vscode/src/extension/providers/SidebarProvider.ts +++ b/extensions/vscode/src/extension/providers/SidebarProvider.ts @@ -2,7 +2,7 @@ import { resolveLocale, toHtmlLang } from '../../shared/i18n'; import * as vscode from 'vscode'; import { ConfigPanelFocus } from '../../shared/configUtils'; import { HostToWebview, WebviewToHost } from '../../shared/messages'; -import { FileChange, ReviewMode } from '../../shared/types'; +import { FileChange } from '../../shared/types'; import { CliService } from '../services/CliService'; import { ConfigService } from '../services/ConfigService'; import { GitService } from '../services/GitService'; @@ -13,7 +13,6 @@ export class SidebarProvider implements vscode.WebviewViewProvider { private view?: vscode.WebviewView; private session?: ReviewSession; private openConfigPanel?: (focus?: ConfigPanelFocus) => void; - private gitWatchDisposable?: vscode.Disposable; constructor( private extensionUri: vscode.Uri, @@ -38,16 +37,6 @@ export class SidebarProvider implements vscode.WebviewViewProvider { view.webview.options = { enableScripts: true, localResourceRoots: [this.extensionUri] }; view.webview.html = this.html(view.webview); view.webview.onDidReceiveMessage((msg: WebviewToHost) => this.handle(msg)); - - this.gitWatchDisposable?.dispose(); - this.gitWatchDisposable = this.git.watchWorkspaceChanges((gitState) => { - this.post({ type: 'gitState', gitState }); - }); - view.onDidDispose(() => { - this.gitWatchDisposable?.dispose(); - this.gitWatchDisposable = undefined; - this.view = undefined; - }); } private post(msg: HostToWebview): void { @@ -59,7 +48,7 @@ export class SidebarProvider implements vscode.WebviewViewProvider { switch (msg.type) { case 'ready': { const config = this.config.read(); - const gitState = await this.git.getState(ReviewMode.Workspace); + const gitState = await this.git.getState('workspace'); const locale = resolveLocale(vscode.env.language); this.post({ type: 'init', config, gitState, locale }); break; @@ -70,9 +59,9 @@ export class SidebarProvider implements vscode.WebviewViewProvider { } case 'getModeFiles': { let files: FileChange[] = []; - if (msg.mode === ReviewMode.Branch && msg.from && msg.to) { + if (msg.mode === 'branch' && msg.from && msg.to) { files = await this.git.getBranchDiff(msg.from, msg.to); - } else if (msg.mode === ReviewMode.Commit && msg.commit) { + } else if (msg.mode === 'commit' && msg.commit) { files = await this.git.getCommitFiles(msg.commit); } this.post({ type: 'modeFiles', mode: msg.mode, files }); @@ -86,21 +75,14 @@ export class SidebarProvider implements vscode.WebviewViewProvider { break; case 'startReview': { this.session = new ReviewSession(this.cli, cwd); + // 仅工作区模式在编辑器内放置评论 thread;分支/提交模式代码不在工作区,会错位。 + const inEditor = msg.options.mode === 'workspace'; await this.session.run(msg.options, { onState: (state, error) => this.post({ type: 'stateChange', state, error }), onLog: (line) => this.post({ type: 'logLine', line }), onDone: (result) => { - void (async () => { - if (result.comments.length) { - await this.comments.show(result.comments, { - mode: msg.options.mode, - from: msg.options.from, - to: msg.options.to, - commit: msg.options.commit, - }); - } - this.post({ type: 'reviewDone', result }); - })(); + this.post({ type: 'reviewDone', result }); + if (result.comments.length) this.comments.show(result.comments, inEditor); }, }); break; diff --git a/extensions/vscode/src/extension/providers/__tests__/commentAnchor.test.ts b/extensions/vscode/src/extension/providers/__tests__/commentAnchor.test.ts deleted file mode 100644 index 22a14dd..0000000 --- a/extensions/vscode/src/extension/providers/__tests__/commentAnchor.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { - findLinesByExistingCode, - normalizeLine, - resolveLinesInContent, - splitAndNormalize, -} from '../commentAnchor'; - -describe('commentAnchor line resolution', () => { - const content = ['line1', 'for (let i = 0; i <= 30, i++) {', ' console.log(i);', '}', 'line5'].join('\n'); - - it('normalizeLine strips diff markers', () => { - expect(normalizeLine('+added')).toBe('added'); - expect(normalizeLine('-removed')).toBe('removed'); - }); - - it('splitAndNormalize skips blank lines', () => { - expect(splitAndNormalize('a\n\n b ')).toEqual(['a', 'b']); - }); - - it('resolveLinesInContent uses explicit line numbers when in range', () => { - expect(resolveLinesInContent(content, 2, 2)).toEqual({ start: 2, end: 2, relocated: false }); - }); - - it('resolveLinesInContent falls back to existingCode when line out of range', () => { - const code = 'for (let i = 0; i <= 30, i++) {'; - const result = resolveLinesInContent(content, 99, 99, code); - expect(result).toEqual({ start: 2, end: 2, relocated: true }); - }); - - it('findLinesByExistingCode matches consecutive non-blank lines', () => { - const found = findLinesByExistingCode(content, 'console.log(i);'); - expect(found).toEqual({ start: 3, end: 3 }); - }); - - it('returns null when neither line nor existingCode resolves', () => { - expect(resolveLinesInContent(content, 99, 99)).toBeNull(); - expect(resolveLinesInContent(content, 0, 0)).toBeNull(); - }); -}); diff --git a/extensions/vscode/src/extension/providers/commentAnchor.ts b/extensions/vscode/src/extension/providers/commentAnchor.ts deleted file mode 100644 index b18bc2a..0000000 --- a/extensions/vscode/src/extension/providers/commentAnchor.ts +++ /dev/null @@ -1,197 +0,0 @@ -import * as vscode from 'vscode'; -import { ReviewComment, ReviewContext, ReviewMode } from '../../shared/types'; - -export type CommentMountSide = 'left' | 'right' | 'workspace'; - -/** 可挂载到编辑器 / diff 的定位结果。 */ -export interface MountableCommentAnchor { - kind: 'mountable'; - uri: vscode.Uri; - range: vscode.Range; - side: CommentMountSide; - /** commit/branch 模式跳转时打开原生 diff。 */ - diff?: { - left: vscode.Uri; - right: vscode.Uri; - title: string; - }; - /** 行号经 existingCode 重新定位时附带说明。 */ - locateNote?: string; -} - -/** 无法在快照中定位,仅侧边栏展示。 */ -export interface SidebarOnlyCommentAnchor { - kind: 'sidebar'; - reason: 'binary' | 'unresolved' | 'missing-file'; -} - -export type SidebarOnlyReason = SidebarOnlyCommentAnchor['reason']; - -export type CommentAnchorResult = MountableCommentAnchor | SidebarOnlyCommentAnchor; - -export interface CommentAnchorDeps { - repoRoot: string; - fileStatus: (path: string) => Promise<'added' | 'modified' | 'deleted' | 'renamed' | 'binary' | null>; - readAtRef: (ref: string, path: string) => Promise; - readWorkspace: (path: string) => Promise; - buildDiffUris: ( - path: string, - status: 'added' | 'modified' | 'deleted' | 'renamed' | 'binary', - ) => Promise<{ - left: vscode.Uri; - right: vscode.Uri; - title: string; - mountRef: string; - mountSide: 'left' | 'right'; - leftRef: string | null; - rightRef: string | null; - } | null>; - toGitUri: (path: string, ref: string) => Promise; -} - -export function normalizeLine(s: string): string { - let line = s.trim(); - if (line.startsWith('+') || line.startsWith('-')) line = line.slice(1).trim(); - return line; -} - -export function splitAndNormalize(code: string): string[] { - const result: string[] = []; - for (const raw of code.split('\n')) { - const n = normalizeLine(raw); - if (n) result.push(n); - } - return result; -} - -/** 在文件内容中滑动匹配 existingCode,返回 1-based 行号。 */ -export function findLinesByExistingCode(content: string, existingCode: string): { start: number; end: number } | null { - const targetLines = splitAndNormalize(existingCode); - if (targetLines.length === 0) return null; - - const fileLines = content.split('\n'); - const normalized: string[] = []; - const lineNums: number[] = []; - for (let i = 0; i < fileLines.length; i++) { - const n = normalizeLine(fileLines[i].replace(/\r$/, '')); - if (!n) continue; - normalized.push(n); - lineNums.push(i + 1); - } - if (normalized.length < targetLines.length) return null; - - for (let i = 0; i <= normalized.length - targetLines.length; i++) { - let matched = true; - for (let j = 0; j < targetLines.length; j++) { - if (normalized[i + j] !== targetLines[j]) { - matched = false; - break; - } - } - if (matched) { - return { start: lineNums[i], end: lineNums[i + targetLines.length - 1] }; - } - } - return null; -} - -export function resolveLinesInContent( - content: string, - startLine: number, - endLine: number, - existingCode?: string, -): { start: number; end: number; relocated: boolean } | null { - const lineCount = content.split('\n').length; - const start = startLine > 0 ? startLine : 0; - const end = endLine > 0 ? endLine : start; - - if (start > 0 && end > 0 && start <= lineCount && end <= lineCount && start <= end) { - return { start, end, relocated: false }; - } - - if (existingCode?.trim()) { - const found = findLinesByExistingCode(content, existingCode); - if (found) return { ...found, relocated: true }; - } - return null; -} - -function toRange(start: number, end: number): vscode.Range { - return new vscode.Range(Math.max(0, start - 1), 0, Math.max(0, end - 1), 0); -} - -export async function resolveCommentAnchor( - comment: ReviewComment, - ctx: ReviewContext, - deps: CommentAnchorDeps, -): Promise { - const status = await deps.fileStatus(comment.path); - if (status === 'binary') return { kind: 'sidebar', reason: 'binary' }; - if (status === null && ctx.mode !== ReviewMode.Workspace) { - return { kind: 'sidebar', reason: 'missing-file' }; - } - - const effectiveStatus = status ?? 'modified'; - - if (ctx.mode === ReviewMode.Workspace) { - const content = await deps.readWorkspace(comment.path); - if (content === null) return { kind: 'sidebar', reason: 'missing-file' }; - const lines = resolveLinesInContent(content, comment.startLine, comment.endLine, comment.existingCode); - if (!lines) return { kind: 'sidebar', reason: 'unresolved' }; - const uri = vscode.Uri.file(`${deps.repoRoot}/${comment.path}`); - return { - kind: 'mountable', - uri, - range: toRange(lines.start, lines.end), - side: 'workspace', - locateNote: lines.relocated ? formatLocateNote(comment.startLine, lines.start) : undefined, - }; - } - - const diff = await deps.buildDiffUris(comment.path, effectiveStatus); - if (!diff) return { kind: 'sidebar', reason: 'missing-file' }; - - const primaryRef = diff.mountRef; - const primaryContent = await deps.readAtRef(primaryRef, comment.path); - let lines = primaryContent - ? resolveLinesInContent(primaryContent, comment.startLine, comment.endLine, comment.existingCode) - : null; - - let mountRef = primaryRef; - let mountSide = diff.mountSide; - - if (!lines && effectiveStatus !== 'added') { - const altRef = diff.mountSide === 'right' ? diff.leftRef : diff.rightRef; - const altSide: CommentMountSide = diff.mountSide === 'right' ? 'left' : 'right'; - if (altRef) { - const altContent = await deps.readAtRef(altRef, comment.path); - const altLines = altContent - ? resolveLinesInContent(altContent, comment.startLine, comment.endLine, comment.existingCode) - : null; - if (altLines) { - lines = altLines; - mountRef = altRef; - mountSide = altSide; - } - } - } - - if (!lines) return { kind: 'sidebar', reason: 'unresolved' }; - - const uri = await deps.toGitUri(comment.path, mountRef); - return { - kind: 'mountable', - uri, - range: toRange(lines.start, lines.end), - side: mountSide, - diff: { left: diff.left, right: diff.right, title: diff.title }, - locateNote: lines.relocated ? formatLocateNote(comment.startLine, lines.start) : undefined, - }; -} - -function formatLocateNote(originalLine: number, resolvedLine: number): string { - if (originalLine > 0 && originalLine !== resolvedLine) { - return `⚠ Original line L${originalLine} could not be matched; showing L${resolvedLine} instead.`; - } - return '⚠ Line number was re-located from code content.'; -} diff --git a/extensions/vscode/src/extension/services/GitService.ts b/extensions/vscode/src/extension/services/GitService.ts index 8d4eaef..a3b29f0 100644 --- a/extensions/vscode/src/extension/services/GitService.ts +++ b/extensions/vscode/src/extension/services/GitService.ts @@ -1,16 +1,11 @@ import { t, resolveLocale } from '../../shared/i18n'; import * as vscode from 'vscode'; -import { readFile } from 'fs/promises'; import { execFile } from 'child_process'; -import { GitState, FileChange, ReviewMode, ReviewContext } from '../../shared/types'; -import { buildWorkspaceFiles, branchRefCandidates, parseNameStatus, pickRepoRoot } from './gitMap'; - -const WORKSPACE_REFRESH_DEBOUNCE_MS = 300; +import { GitState, CommitInfo, FileChange, ReviewMode } from '../../shared/types'; +import { parsePorcelain, parseNameStatus, pickRepoRoot } from './gitMap'; export class GitService { private api: any | null = null; - private cache: GitState = { branches: [], currentBranch: '', recentCommits: [], workspaceFiles: [] }; - private reviewFileStatus = new Map(); constructor(private log?: vscode.OutputChannel) {} @@ -66,155 +61,57 @@ export class GitService { async getState(mode: ReviewMode): Promise { const empty: GitState = { branches: [], currentBranch: '', recentCommits: [], workspaceFiles: [] }; - - if (mode === ReviewMode.Workspace) { - await this.refreshWorkspaceFiles(); - return { ...this.cache }; - } - const repo = await this.waitForRepo(); if (!repo) { this.trace(`getState(${mode}): no repo`); return empty; } + let currentBranch = ''; try { - this.cache.currentBranch = repo.state.HEAD?.name || ''; + currentBranch = repo.state.HEAD?.name || ''; } catch { /* ignore */ } - if (mode === ReviewMode.Branch) { - await this.refreshBranches(repo); - } else if (mode === ReviewMode.Commit) { - await this.refreshRecentCommits(repo); - } - - return { ...this.cache }; - } - - /** - * 订阅 VS Code Git 扩展的仓库状态变化,debounce 后刷新工作区文件列表。 - * 用于侧边栏工作区模式实时反映暂存/工作区/未跟踪变更。 - */ - watchWorkspaceChanges(onUpdate: (state: GitState) => void): vscode.Disposable { - const cleanups: vscode.Disposable[] = []; - let debounceTimer: ReturnType | undefined; - let repoDisposable: vscode.Disposable | undefined; - let cancelled = false; - - const scheduleRefresh = () => { - if (debounceTimer) clearTimeout(debounceTimer); - debounceTimer = setTimeout(() => { - debounceTimer = undefined; - void this.refreshWorkspaceFiles().then(() => { - if (!cancelled) onUpdate({ ...this.cache }); - }); - }, WORKSPACE_REFRESH_DEBOUNCE_MS); - }; - - const attachRepo = (repo: any) => { - repoDisposable?.dispose(); - repoDisposable = repo?.state?.onDidChange?.(scheduleRefresh); - }; - - void this.ensureApi().then(async (api) => { - if (!api || cancelled) return; - - const repo = await this.waitForRepo(); - if (!cancelled && repo) attachRepo(repo); - - if (api.onDidOpenRepository) { - cleanups.push(api.onDidOpenRepository(() => { - if (cancelled) return; - const selected = this.selectRepo(api); - if (selected) attachRepo(selected); - })); - } - }); - - return new vscode.Disposable(() => { - cancelled = true; - if (debounceTimer) clearTimeout(debounceTimer); - repoDisposable?.dispose(); - for (const d of cleanups) d.dispose(); - }); - } - - /** 工作区模式仅刷新变更文件,不等待 VS Code Git 扩展,也不拉分支/提交历史。 */ - private async refreshWorkspaceFiles(): Promise { - const root = await this.repoRootFast(); - if (!root) { - this.cache.workspaceFiles = []; - return; - } - - try { - const [diffHeadOut, untrackedOut] = await Promise.all([ - runGit(root, ['diff', '--name-status', 'HEAD']), - runGit(root, ['ls-files', '--others', '--exclude-standard']), - ]); - let diffCachedOut = ''; - if (!diffHeadOut.trim()) { - diffCachedOut = await runGit(root, ['diff', '--cached', '--name-status']); - } - this.cache.workspaceFiles = buildWorkspaceFiles(diffHeadOut, diffCachedOut, untrackedOut); - this.trace(`refreshWorkspaceFiles: root=${root} files=${this.cache.workspaceFiles.length}`); - } catch (e) { - this.trace(`refreshWorkspaceFiles failed: ${e instanceof Error ? e.message : String(e)}`); - this.cache.workspaceFiles = []; - } - } - - /** 通过 git rev-parse 解析仓库根,避免等待 VS Code Git 扩展初始化。 */ - private async repoRootFast(): Promise { - const ws = vscode.workspace.workspaceFolders?.[0].uri.fsPath; - if (!ws) return null; - try { - const out = await runGit(ws, ['rev-parse', '--show-toplevel']); - const root = out.trim(); - return root || null; - } catch { - return null; - } - } - - private async refreshBranches(repo: any): Promise { + let branches: string[] = []; try { const refs = await repo.getBranches({ remote: true }); - this.cache.branches = refs.map((r: any) => r.name).filter(Boolean); - } catch { - this.cache.branches = []; - } - } + branches = refs.map((r: any) => r.name).filter(Boolean); + } catch { /* ignore */ } - private async refreshRecentCommits(repo: any): Promise { + let recentCommits: CommitInfo[] = []; try { const commits = await repo.log({ maxEntries: 20 }); - this.cache.recentCommits = commits.map((c: any) => ({ + recentCommits = commits.map((c: any) => ({ sha: c.hash.slice(0, 7), message: c.message.split('\n')[0], relativeTime: formatRelative(c.authorDate), })); - } catch { - this.cache.recentCommits = []; + } catch { /* ignore */ } + + // 工作区变更直接走 git status --porcelain,避免依赖扩展懒填充的 state 数组。 + let workspaceFiles: FileChange[] = []; + try { + const root: string = repo.rootUri?.fsPath + ?? vscode.workspace.workspaceFolders?.[0].uri.fsPath + ?? process.cwd(); + const out = await runGit(root, ['status', '--porcelain']); + workspaceFiles = parsePorcelain(out); + this.trace(`getState(${mode}): root=${root} porcelainBytes=${out.length} files=${workspaceFiles.length}`); + } catch (e) { + this.trace(`getState(${mode}): status failed: ${e instanceof Error ? e.message : String(e)}`); } + + return { branches, currentBranch, recentCommits, workspaceFiles }; } /** 分支对比:merge-base 三点 diff。 */ async getBranchDiff(from: string, to: string): Promise { const root = await this.repoRoot(); if (!root || !from || !to) return []; - - const resolvedFrom = await this.resolveGitRef(root, from); - const resolvedTo = await this.resolveGitRef(root, to); - if (!resolvedFrom || !resolvedTo) { - this.trace(`getBranchDiff: unresolved ref from=${from} to=${to}`); - return []; - } - try { - const out = await runGit(root, ['diff', '--name-status', `${resolvedFrom}...${resolvedTo}`]); + const out = await runGit(root, ['diff', '--name-status', `${from}...${to}`]); const files = parseNameStatus(out); - this.trace(`getBranchDiff(${resolvedFrom}...${resolvedTo}): files=${files.length}`); + this.trace(`getBranchDiff(${from}...${to}): files=${files.length}`); return files; } catch (e) { this.trace(`getBranchDiff failed: ${e instanceof Error ? e.message : String(e)}`); @@ -222,22 +119,12 @@ export class GitService { } } - private async resolveGitRef(root: string, ref: string): Promise { - for (const candidate of branchRefCandidates(ref)) { - try { - const out = await runGit(root, ['rev-parse', '--verify', candidate]); - if (out.trim()) return candidate; - } catch { /* try next candidate */ } - } - return null; - } - /** 单次提交:该 commit 相对父提交的改动文件。 */ async getCommitFiles(sha: string): Promise { const root = await this.repoRoot(); if (!root || !sha) return []; try { - const out = await runGit(root, ['show', '--name-status', '--format=', sha]); + const out = await runGit(root, ['show', '--name-status', '--format=', '--', sha]); const files = parseNameStatus(out); this.trace(`getCommitFiles(${sha}): files=${files.length}`); return files; @@ -249,7 +136,7 @@ export class GitService { private async repoRoot(): Promise { const repo = await this.waitForRepo(); - if (!repo) return this.repoRootFast(); + if (!repo) return null; return repo.rootUri?.fsPath ?? vscode.workspace.workspaceFolders?.[0].uri.fsPath ?? process.cwd(); @@ -266,125 +153,41 @@ export class GitService { const fileUri = vscode.Uri.file(`${root}/${opts.path}`); + // 二进制无法做文本 diff,直接打开文件本身。 if (opts.status === 'binary') { - if (opts.mode === ReviewMode.Workspace) { - try { await vscode.window.showTextDocument(fileUri, { preview: true }); } catch { /* ignore */ } - } + try { await vscode.window.showTextDocument(fileUri, { preview: true }); } catch { /* ignore */ } return; } - if (opts.mode === ReviewMode.Workspace) { - const emptyRef = ''; - const left = api.toGitUri(fileUri, opts.status === 'added' ? emptyRef : 'HEAD'); - const right = opts.status === 'deleted' ? api.toGitUri(fileUri, emptyRef) : fileUri; - const label = t(resolveLocale(vscode.env.language), 'ext.git.workspaceVsHead'); - await this.presentDiff(left, right, `${opts.path} (${label})`); + // toGitUri(uri, '') 返回空文档,用于新增/删除时缺失的一侧。 + const emptyRef = ''; + let left: vscode.Uri; + let right: vscode.Uri; + let label: string; + + if (opts.mode === 'workspace') { + left = api.toGitUri(fileUri, opts.status === 'added' ? emptyRef : 'HEAD'); + right = opts.status === 'deleted' ? api.toGitUri(fileUri, emptyRef) : fileUri; + label = t(resolveLocale(vscode.env.language), 'ext.git.workspaceVsHead'); + } else if (opts.mode === 'commit' && opts.commit) { + left = api.toGitUri(fileUri, opts.status === 'added' ? emptyRef : `${opts.commit}^`); + right = opts.status === 'deleted' ? api.toGitUri(fileUri, emptyRef) : api.toGitUri(fileUri, opts.commit); + label = `${opts.commit}^ ↔ ${opts.commit}`; + } else if (opts.mode === 'branch' && opts.from && opts.to) { + // 文件列表用三点 diff(merge-base),逐文件 diff 也应以 merge-base 为基准。 + const base = (await this.mergeBase(root, opts.from, opts.to)) || opts.from; + left = api.toGitUri(fileUri, opts.status === 'added' ? emptyRef : base); + right = opts.status === 'deleted' ? api.toGitUri(fileUri, emptyRef) : api.toGitUri(fileUri, opts.to); + label = `${opts.from}...${opts.to}`; + } else { return; } - if (opts.mode === ReviewMode.Commit && opts.commit) { - const parent = `${opts.commit}^`; - const label = `${opts.commit}^ ↔ ${opts.commit}`; - const leftRef = opts.status === 'added' ? null : parent; - const rightRef = opts.status === 'deleted' ? null : opts.commit; - await this.presentRefDiff(api, root, opts.path, opts.status, leftRef, rightRef, `${opts.path} (${label})`, { - fallbackRange: `${parent}..${opts.commit}`, - }); - return; - } - - if (opts.mode === ReviewMode.Branch && opts.from && opts.to) { - const resolvedFrom = await this.resolveGitRef(root, opts.from); - const resolvedTo = await this.resolveGitRef(root, opts.to); - if (!resolvedFrom || !resolvedTo) return; - const base = (await this.mergeBase(root, resolvedFrom, resolvedTo)) || resolvedFrom; - const label = `${resolvedFrom}...${resolvedTo}`; - const leftRef = opts.status === 'added' ? null : base; - const rightRef = opts.status === 'deleted' ? null : resolvedTo; - await this.presentRefDiff(api, root, opts.path, opts.status, leftRef, rightRef, `${opts.path} (${label})`, { - fallbackRange: `${resolvedFrom}...${resolvedTo}`, - }); - } - } - - /** 分支/提交模式:用 git ref 或空文件构造两侧,走 VS Code 原生 diff 编辑器。 */ - private async presentRefDiff( - api: any, - root: string, - relPath: string, - status: FileChange['status'], - leftRef: string | null, - rightRef: string | null, - title: string, - opts: { fallbackRange: string }, - ): Promise { - const left = await this.resolveDiffSide(api, root, relPath, leftRef, status, 'left'); - const right = await this.resolveDiffSide(api, root, relPath, rightRef, status, 'right'); - const opened = await this.presentDiff(left, right, title); - if (!opened) { - await this.presentPatchDiff(root, opts.fallbackRange, relPath, title); - } - } - - private async resolveDiffSide( - api: any, - root: string, - relPath: string, - ref: string | null, - status: FileChange['status'], - side: 'left' | 'right', - ): Promise { - if (side === 'left' && (status === 'added' || !ref)) return this.emptySideUri(); - if (side === 'right' && (status === 'deleted' || !ref)) return this.emptySideUri(); - if (!ref || !(await this.pathExistsAtRef(root, ref, relPath))) return this.emptySideUri(); - return api.toGitUri(vscode.Uri.file(`${root}/${relPath}`), ref); - } - - /** 空侧占位(等同 /dev/null),用于新增/删除文件的单侧 diff。 */ - private emptySideUri(): vscode.Uri { - return vscode.Uri.file(process.platform === 'win32' ? '\\\\.\\NUL' : '/dev/null'); - } - - private async presentDiff( - left: vscode.Uri, - right: vscode.Uri, - title: string, - ): Promise { + const title = `${opts.path} (${label})`; try { await vscode.commands.executeCommand('vscode.diff', left, right, title, { preview: true }); - return true; } catch (e) { - this.trace(`presentDiff failed: ${e instanceof Error ? e.message : String(e)}`); - return false; - } - } - - /** 最后兜底:原生 diff 失败时才展示补丁文本。 */ - private async presentPatchDiff( - root: string, - range: string, - relPath: string, - title: string, - ): Promise { - let patch = ''; - try { - patch = await runGit(root, ['diff', range, '--', relPath]); - } catch (e) { - this.trace(`presentPatchDiff failed: ${e instanceof Error ? e.message : String(e)}`); - } - const doc = await vscode.workspace.openTextDocument({ - content: patch || `# ${title}\n\n(no changes)`, - language: 'diff', - }); - await vscode.window.showTextDocument(doc, { preview: true }); - } - - private async pathExistsAtRef(root: string, ref: string, relPath: string): Promise { - try { - await runGit(root, ['cat-file', '-e', `${ref}:${relPath}`]); - return true; - } catch { - return false; + this.trace(`openDiff failed: ${e instanceof Error ? e.message : String(e)}`); } } @@ -396,102 +199,11 @@ export class GitService { return null; } } - - /** 审查开始前缓存 commit/branch 模式下的文件状态,供评论挂载查询。 */ - async prepareReviewFileStatus(ctx: ReviewContext): Promise { - this.reviewFileStatus.clear(); - if (ctx.mode === ReviewMode.Commit && ctx.commit) { - const files = await this.getCommitFiles(ctx.commit); - for (const f of files) this.reviewFileStatus.set(f.path, f.status); - } else if (ctx.mode === ReviewMode.Branch && ctx.from && ctx.to) { - const files = await this.getBranchDiff(ctx.from, ctx.to); - for (const f of files) this.reviewFileStatus.set(f.path, f.status); - } - } - - async getReviewFileStatus(path: string): Promise { - return this.reviewFileStatus.get(path) ?? null; - } - - async readFileAtRef(ref: string, relPath: string): Promise { - const root = await this.repoRoot(); - if (!root) return null; - try { - return await runGit(root, ['show', `${ref}:${relPath}`]); - } catch { - return null; - } - } - - async readWorkspaceFile(relPath: string): Promise { - const root = await this.repoRoot(); - if (!root) return null; - try { - return await readFile(`${root}/${relPath}`, 'utf8'); - } catch { - return null; - } - } - - /** 构造评论挂载用的 diff 两侧 URI 与挂载 ref。 */ - async buildCommentDiffUris( - relPath: string, - status: FileChange['status'], - ctx: ReviewContext, - ): Promise<{ - left: vscode.Uri; - right: vscode.Uri; - title: string; - mountRef: string; - mountSide: 'left' | 'right'; - leftRef: string | null; - rightRef: string | null; - } | null> { - const api = await this.ensureApi(); - const root = await this.repoRoot(); - if (!api || !root) return null; - - if (ctx.mode === ReviewMode.Commit && ctx.commit) { - const parent = `${ctx.commit}^`; - const label = `${ctx.commit}^ ↔ ${ctx.commit}`; - const leftRef = status === 'added' ? null : parent; - const rightRef = status === 'deleted' ? null : ctx.commit; - const mountRef = status === 'deleted' ? parent : ctx.commit; - const mountSide: 'left' | 'right' = status === 'deleted' ? 'left' : 'right'; - const left = await this.resolveDiffSide(api, root, relPath, leftRef, status, 'left'); - const right = await this.resolveDiffSide(api, root, relPath, rightRef, status, 'right'); - return { left, right, title: `${relPath} (${label})`, mountRef, mountSide, leftRef, rightRef }; - } - - if (ctx.mode === ReviewMode.Branch && ctx.from && ctx.to) { - const resolvedFrom = await this.resolveGitRef(root, ctx.from); - const resolvedTo = await this.resolveGitRef(root, ctx.to); - if (!resolvedFrom || !resolvedTo) return null; - const base = (await this.mergeBase(root, resolvedFrom, resolvedTo)) || resolvedFrom; - const label = `${resolvedFrom}...${resolvedTo}`; - const leftRef = status === 'added' ? null : base; - const rightRef = status === 'deleted' ? null : resolvedTo; - const mountRef = status === 'deleted' ? base : resolvedTo; - const mountSide: 'left' | 'right' = status === 'deleted' ? 'left' : 'right'; - const left = await this.resolveDiffSide(api, root, relPath, leftRef, status, 'left'); - const right = await this.resolveDiffSide(api, root, relPath, rightRef, status, 'right'); - return { left, right, title: `${relPath} (${label})`, mountRef, mountSide, leftRef, rightRef }; - } - - return null; - } - - async createGitFileUri(relPath: string, ref: string): Promise { - const api = await this.ensureApi(); - const root = await this.repoRoot(); - if (!api || !root) return null; - return api.toGitUri(vscode.Uri.file(`${root}/${relPath}`), ref); - } } function runGit(cwd: string, args: string[]): Promise { return new Promise((resolve, reject) => { - execFile('git', ['-c', 'core.quotepath=false', ...args], { cwd, maxBuffer: 10 * 1024 * 1024 }, (err, stdout) => { + execFile('git', args, { cwd, maxBuffer: 10 * 1024 * 1024 }, (err, stdout) => { if (err) reject(err); else resolve(stdout); }); diff --git a/extensions/vscode/src/extension/services/__tests__/cliParse.test.ts b/extensions/vscode/src/extension/services/__tests__/cliParse.test.ts index ca8fea1..25145a7 100644 --- a/extensions/vscode/src/extension/services/__tests__/cliParse.test.ts +++ b/extensions/vscode/src/extension/services/__tests__/cliParse.test.ts @@ -1,29 +1,28 @@ import { buildReviewArgs, extractCliError, parseCliResult, parseLogLine } from '../cliParse'; -import { ReviewMode } from '../../../shared/types'; describe('buildReviewArgs', () => { it('workspace 模式加 --format json', () => { - expect(buildReviewArgs({ mode: ReviewMode.Workspace })) + expect(buildReviewArgs({ mode: 'workspace' })) .toEqual(['review', '--format', 'json']); }); it('branch 模式加 --from/--to', () => { - expect(buildReviewArgs({ mode: ReviewMode.Branch, from: 'main', to: 'dev' })) + expect(buildReviewArgs({ mode: 'branch', from: 'main', to: 'dev' })) .toEqual(['review', '--from', 'main', '--to', 'dev', '--format', 'json']); }); it('commit 模式加 --commit', () => { - expect(buildReviewArgs({ mode: ReviewMode.Commit, commit: 'abc123' })) + expect(buildReviewArgs({ mode: 'commit', commit: 'abc123' })) .toEqual(['review', '--commit', 'abc123', '--format', 'json']); }); it('customPrompt 追加 --background', () => { - expect(buildReviewArgs({ mode: ReviewMode.Workspace, customPrompt: '关注安全' })) + expect(buildReviewArgs({ mode: 'workspace', customPrompt: '关注安全' })) .toEqual(['review', '--format', 'json', '--background', '关注安全']); }); it('concurrency 追加 --concurrency', () => { - expect(buildReviewArgs({ mode: ReviewMode.Workspace, concurrency: 4 })) + expect(buildReviewArgs({ mode: 'workspace', concurrency: 4 })) .toEqual(['review', '--format', 'json', '--concurrency', '4']); }); }); diff --git a/extensions/vscode/src/extension/services/__tests__/gitMap.test.ts b/extensions/vscode/src/extension/services/__tests__/gitMap.test.ts index b8eb991..3cf15a1 100644 --- a/extensions/vscode/src/extension/services/__tests__/gitMap.test.ts +++ b/extensions/vscode/src/extension/services/__tests__/gitMap.test.ts @@ -1,18 +1,5 @@ // src/extension/services/__tests__/gitMap.test.ts -import { execFile } from 'child_process'; -import path from 'path'; -import { promisify } from 'util'; -import { - buildWorkspaceFiles, - branchRefCandidates, - mapStatusCode, - mergeWorkspaceFiles, - parsePorcelain, - parseNameStatus, - parseUntrackedList, - pickRepoRoot, - unquoteGitPath, -} from '../gitMap'; +import { mapStatusCode, parsePorcelain, parseNameStatus, pickRepoRoot } from '../gitMap'; describe('mapStatusCode', () => { it('VSCode git Status 枚举映射到 FileChange.status', () => { @@ -94,82 +81,6 @@ describe('parseNameStatus', () => { }); }); -describe('buildWorkspaceFiles', () => { - it('合并 diff HEAD 与未跟踪文件', () => { - const files = buildWorkspaceFiles( - 'M\tsrc/a.ts\nA\tsrc/b.ts', - '', - 'src/c.ts\n', - ); - expect(files).toEqual([ - { path: 'src/a.ts', status: 'modified' }, - { path: 'src/b.ts', status: 'added' }, - { path: 'src/c.ts', status: 'added' }, - ]); - }); - - it('diff HEAD 为空时回退 staged', () => { - const files = buildWorkspaceFiles('', 'M\tsrc/staged.ts', ''); - expect(files).toEqual([{ path: 'src/staged.ts', status: 'modified' }]); - }); - - it('按路径去重,已跟踪优先于未跟踪', () => { - const files = buildWorkspaceFiles('M\tsrc/a.ts', '', 'src/a.ts'); - expect(files).toEqual([{ path: 'src/a.ts', status: 'modified' }]); - }); -}); - -describe('parseUntrackedList', () => { - it('解析未跟踪路径并忽略空行', () => { - expect(parseUntrackedList('src/a.ts\n\n src/b.ts \n')).toEqual(['src/a.ts', 'src/b.ts']); - }); -}); - -describe('mergeWorkspaceFiles', () => { - it('合并并去重', () => { - expect(mergeWorkspaceFiles( - [{ path: 'a.ts', status: 'modified' }], - ['b.ts', 'a.ts'], - )).toEqual([ - { path: 'a.ts', status: 'modified' }, - { path: 'b.ts', status: 'added' }, - ]); - }); -}); - -describe('unquoteGitPath', () => { - it('解码 Git quotepath 八进制转义的中文路径', () => { - const quoted = '"\\344\\273\\243\\347\\240\\201\\344\\277\\256\\346\\224\\271\\346\\234\\200\\345\\260\\217\\345\\271\\262\\351\\242\\204\\350\\247\\204\\345\\210\\231.md"'; - expect(unquoteGitPath(quoted)).toBe('代码修改最小干预规则.md'); - }); - - it('普通路径原样返回', () => { - expect(unquoteGitPath('src/a.ts')).toBe('src/a.ts'); - }); -}); - -describe('parseNameStatus', () => { - it('解析 quotepath 转义路径', () => { - expect(parseNameStatus('A\t"\\344\\273\\243\\347\\240\\201.md"')).toEqual([ - { path: '代码.md', status: 'added' }, - ]); - }); -}); - -describe('branchRefCandidates', () => { - it('本地分支名补充 origin/ 前缀', () => { - expect(branchRefCandidates('dev')).toEqual(['dev', 'origin/dev']); - }); - - it('master 回退到 main 候选', () => { - expect(branchRefCandidates('master')).toEqual(['master', 'origin/master', 'main', 'origin/main']); - }); - - it('已是远程引用时不重复拼接', () => { - expect(branchRefCandidates('origin/main')).toEqual(['origin/main']); - }); -}); - describe('pickRepoRoot', () => { const ws = '/Users/lost/tre/copilot-union/code-chat'; @@ -206,19 +117,3 @@ describe('pickRepoRoot', () => { expect(pickRepoRoot(roots, undefined)).toBe('/a/repo'); }); }); - -describe('getCommitFiles: git show revision placement', () => { - const repoRoot = path.resolve(__dirname, '../../../../../..'); - const execGit = (args: string[]) => - promisify(execFile)('git', ['-c', 'core.quotepath=false', ...args], { cwd: repoRoot }) - .then((r) => r.stdout.trim()); - - it('revision 必须在 -- 之前,否则会被当成 pathspec 导致空列表', async () => { - const good = await execGit(['show', '--name-status', '--format=', 'HEAD']); - const bad = await execGit(['show', '--name-status', '--format=', '--', 'HEAD']); - expect(good.length).toBeGreaterThan(0); - expect(bad).toBe(''); - expect(parseNameStatus(good).length).toBeGreaterThan(0); - expect(parseNameStatus(bad)).toEqual([]); - }); -}); diff --git a/extensions/vscode/src/extension/services/cliParse.ts b/extensions/vscode/src/extension/services/cliParse.ts index 383faa7..c219bce 100644 --- a/extensions/vscode/src/extension/services/cliParse.ts +++ b/extensions/vscode/src/extension/services/cliParse.ts @@ -1,11 +1,11 @@ -import { CliResult, CliRunOptions, LogLine, ReviewComment, ReviewMode } from '../../shared/types'; +import { CliResult, CliRunOptions, LogLine, ReviewComment } from '../../shared/types'; export function buildReviewArgs(opts: CliRunOptions): string[] { const args: string[] = ['review']; - if (opts.mode === ReviewMode.Branch) { + if (opts.mode === 'branch') { if (opts.from) args.push('--from', opts.from); if (opts.to) args.push('--to', opts.to); - } else if (opts.mode === ReviewMode.Commit) { + } else if (opts.mode === 'commit') { if (opts.commit) args.push('--commit', opts.commit); } args.push('--format', 'json'); diff --git a/extensions/vscode/src/extension/services/gitMap.ts b/extensions/vscode/src/extension/services/gitMap.ts index 68b3e21..0c92b4f 100644 --- a/extensions/vscode/src/extension/services/gitMap.ts +++ b/extensions/vscode/src/extension/services/gitMap.ts @@ -36,7 +36,6 @@ export function parsePorcelain(output: string): FileChange[] { const c = x !== ' ' && x !== '?' ? x : y; code = c; } - path = unquoteGitPath(path); if (seen.has(path)) continue; seen.add(path); files.push({ path, status: mapStatusCode(code) }); @@ -44,40 +43,6 @@ export function parsePorcelain(output: string): FileChange[] { return files; } -/** 解析 `git ls-files --others --exclude-standard` 输出的未跟踪路径列表。 */ -export function parseUntrackedList(output: string): string[] { - return output.split('\n').map((line) => unquoteGitPath(line.trim())).filter(Boolean); -} - -/** 合并已跟踪变更与未跟踪文件,按路径去重。 */ -export function mergeWorkspaceFiles(tracked: FileChange[], untrackedPaths: string[]): FileChange[] { - const files: FileChange[] = []; - const seen = new Set(); - for (const file of tracked) { - if (seen.has(file.path)) continue; - seen.add(file.path); - files.push(file); - } - for (const path of untrackedPaths) { - if (seen.has(path)) continue; - seen.add(path); - files.push({ path, status: 'added' }); - } - return files; -} - -/** - * 从 git diff / ls-files 输出构建工作区文件列表。 - * 与 OCR CLI workspace 模式一致:先 diff HEAD,空则回退 staged,再合并未跟踪文件。 - */ -export function buildWorkspaceFiles(diffHeadOut: string, diffCachedOut: string, untrackedOut: string): FileChange[] { - let tracked = parseNameStatus(diffHeadOut); - if (tracked.length === 0) { - tracked = parseNameStatus(diffCachedOut); - } - return mergeWorkspaceFiles(tracked, parseUntrackedList(untrackedOut)); -} - /** * 从候选仓库根路径中选出与 workspace 匹配的那个。 * VSCode git 扩展异步扫描嵌套仓库,repositories 顺序不稳定,直接取 [0] 会漂移到子仓库。 @@ -98,50 +63,6 @@ export function pickRepoRoot(roots: string[], workspacePath?: string): string | return roots[0]; } -/** 生成用于 rev-parse 验证的分支引用候选列表。 */ -export function branchRefCandidates(ref: string): string[] { - const candidates = [ref]; - if (!ref.includes('/')) { - candidates.push(`origin/${ref}`); - } - if (ref === 'master') { - candidates.push('main', 'origin/main'); - } else if (ref === 'main') { - candidates.push('master', 'origin/master'); - } - return [...new Set(candidates)]; -} - -/** - * 解码 Git quotepath 转义路径(core.quotepath=true 时中文等会显示为 "\344\273...")。 - */ -export function unquoteGitPath(path: string): string { - if (!path.startsWith('"') || !path.endsWith('"')) return path; - - const bytes: number[] = []; - const inner = path.slice(1, -1); - for (let i = 0; i < inner.length; i++) { - if (inner[i] !== '\\' || i + 1 >= inner.length) { - bytes.push(inner.charCodeAt(i)); - continue; - } - if (i + 3 < inner.length && /^\d{3}$/.test(inner.slice(i + 1, i + 4))) { - bytes.push(parseInt(inner.slice(i + 1, i + 4), 8)); - i += 3; - continue; - } - i += 1; - const esc = inner[i]; - if (esc === 'n') bytes.push(0x0a); - else if (esc === 't') bytes.push(0x09); - else if (esc === 'r') bytes.push(0x0d); - else if (esc === '\\') bytes.push(0x5c); - else if (esc === '"') bytes.push(0x22); - else bytes.push(esc.charCodeAt(0)); - } - return Buffer.from(bytes).toString('utf8'); -} - /** * 解析 `git diff --name-status` / `git show --name-status` 输出。 * 每行制表符分隔:statuspath,重命名为 Roldnew(取 new)。 @@ -154,7 +75,7 @@ export function parseNameStatus(output: string): FileChange[] { const parts = rawLine.split('\t'); if (parts.length < 2) continue; const codeChar = parts[0][0]; - const path = unquoteGitPath(parts.length >= 3 ? parts[parts.length - 1] : parts[1]); + const path = parts.length >= 3 ? parts[parts.length - 1] : parts[1]; if (seen.has(path)) continue; seen.add(path); files.push({ path, status: mapStatusCode(codeChar) }); diff --git a/extensions/vscode/src/shared/i18n.ts b/extensions/vscode/src/shared/i18n.ts index 6086566..346af78 100644 --- a/extensions/vscode/src/shared/i18n.ts +++ b/extensions/vscode/src/shared/i18n.ts @@ -153,9 +153,6 @@ const messages: Record> = { 'ext.comment.statusFalsePositive': '✅ [False Positive]', 'ext.comment.jumpFailed': 'Cannot locate ', 'ext.comment.jumpNotAFile': ': is not an openable file.', - 'ext.comment.jumpLineUnresolved': 'Cannot jump to {path}: line number could not be resolved.', - 'ext.comment.jumpFileMissing': 'Cannot jump to {path}: file not found in the review snapshot.', - 'ext.comment.applyWorkspaceOnly': 'Apply is only available in Workspace review mode.', 'ext.deleteProviderConfirm': 'Delete custom provider "{name}"?', 'ext.deleteProviderConfirmBtn': 'Delete', 'ext.git.justNow': 'just now', @@ -298,9 +295,6 @@ const messages: Record> = { 'ext.comment.statusFalsePositive': '✅ [已误报]', 'ext.comment.jumpFailed': '无法定位到 ', 'ext.comment.jumpNotAFile': ':该路径不是可打开的文件。', - 'ext.comment.jumpLineUnresolved': '无法跳转到 {path}:未能解析行号。', - 'ext.comment.jumpFileMissing': '无法跳转到 {path}:在审查快照中找不到该文件。', - 'ext.comment.applyWorkspaceOnly': '仅工作区审查模式支持应用建议。', 'ext.deleteProviderConfirm': '确定删除自定义 Provider「{name}」?', 'ext.deleteProviderConfirmBtn': '删除', 'ext.git.justNow': '刚刚', diff --git a/extensions/vscode/src/shared/providers.ts b/extensions/vscode/src/shared/providers.ts index 684fb7a..ef03f51 100644 --- a/extensions/vscode/src/shared/providers.ts +++ b/extensions/vscode/src/shared/providers.ts @@ -95,14 +95,6 @@ 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 8219bbd..b6e54ac 100644 --- a/extensions/vscode/src/shared/types.ts +++ b/extensions/vscode/src/shared/types.ts @@ -1,8 +1,4 @@ -export enum ReviewMode { - Workspace = 'workspace', - Branch = 'branch', - Commit = 'commit', -} +export type ReviewMode = 'workspace' | 'branch' | 'commit'; export type ReviewState = | 'idle' | 'running' | 'done' | 'empty' | 'cancelled' | 'failed'; @@ -109,11 +105,7 @@ export interface CliRunOptions { concurrency?: number; } -/** 审查完成后的评论挂载上下文(与 CliRunOptions 字段一致)。 */ -export type ReviewContext = Pick; - export interface CommentSyncState { index: number; status: CommentStatus; - jumpable?: boolean; } diff --git a/extensions/vscode/src/webview/App.tsx b/extensions/vscode/src/webview/App.tsx index 2291095..caebc28 100644 --- a/extensions/vscode/src/webview/App.tsx +++ b/extensions/vscode/src/webview/App.tsx @@ -55,14 +55,14 @@ export function App() {
{state.view === 'running' && bridge.post({ type: 'cancelReview' })} />} {state.view === 'done' && state.session.result && ( - bridge.post({ type: 'jumpToComment', index: i })} onAction={(i, action) => bridge.post({ type: 'commentAction', index: i, action })} /> )} {state.view === 'empty' && } {state.view === 'cancelled' && } - {state.view === 'failed' && start({ mode: ReviewMode.Workspace })} />} + {state.view === 'failed' && start({ mode: 'workspace' })} />}
)} diff --git a/extensions/vscode/src/webview/ConfigPanelApp.tsx b/extensions/vscode/src/webview/ConfigPanelApp.tsx index 24e9836..1439a12 100644 --- a/extensions/vscode/src/webview/ConfigPanelApp.tsx +++ b/extensions/vscode/src/webview/ConfigPanelApp.tsx @@ -1,5 +1,5 @@ import { I18nContext, resolveLocale } from './I18nProvider'; -import { useCallback, useEffect, useReducer } from 'preact/hooks'; +import { useEffect, useReducer } from 'preact/hooks'; import { bridge } from './bridge'; import { ConfigView } from './views/ConfigView'; import { configPanelInitialState, configPanelReducer } from './configStore'; @@ -12,7 +12,6 @@ function runEnvCheck(dispatch: (action: { type: 'checkingEnv' }) => void): void export function ConfigPanelApp() { const [state, dispatch] = useReducer(configPanelReducer, configPanelInitialState); - const clearConnTest = useCallback(() => dispatch({ type: 'clearConnTest' }), []); useEffect(() => { const unsub = bridge.onMessage((msg) => dispatch(msg)); @@ -60,7 +59,7 @@ export function ConfigPanelApp() { onCopy={(text) => bridge.post({ type: 'copyToClipboard', text })} onTest={(entries) => { dispatch({ type: 'testingConn' }); bridge.post({ type: 'testConnection', entries }); }} onSave={(entries) => bridge.post({ type: 'setConfigBatch', entries })} - onClearConnTest={clearConnTest} + onClearConnTest={() => dispatch({ type: 'clearConnTest' })} onDeleteCustomProvider={(name) => bridge.post({ type: 'deleteCustomProvider', name })} onActivateCustomProvider={(name) => bridge.post({ type: 'activateCustomProvider', name })} onClose={() => bridge.post({ type: 'closeConfigPanel' })} diff --git a/extensions/vscode/src/webview/__tests__/store.test.ts b/extensions/vscode/src/webview/__tests__/store.test.ts index c5171e8..85f022c 100644 --- a/extensions/vscode/src/webview/__tests__/store.test.ts +++ b/extensions/vscode/src/webview/__tests__/store.test.ts @@ -1,5 +1,4 @@ import { describeActiveProvider, isConfigReady } from '../../shared/configUtils'; -import { ReviewMode } from '../../shared/types'; import { initialState, reducer } from '../store'; const baseConfig = { @@ -151,7 +150,7 @@ describe('modeFiles 消息', () => { it('保存 mode 对应文件列表', () => { const next = reducer(initialState, { type: 'modeFiles', - mode: ReviewMode.Branch, + mode: 'branch', files: [{ path: 'src/a.ts', status: 'modified' }], }); expect(next.modeFiles).toEqual([{ path: 'src/a.ts', status: 'modified' }]); diff --git a/extensions/vscode/src/webview/components/CommentCard.tsx b/extensions/vscode/src/webview/components/CommentCard.tsx index 680d2a9..2e6b9d2 100644 --- a/extensions/vscode/src/webview/components/CommentCard.tsx +++ b/extensions/vscode/src/webview/components/CommentCard.tsx @@ -12,24 +12,16 @@ interface Props { export function CommentCard({ comment, index, status, canJump, onOpen, onAction }: Props) { const t = useT(); - const open = () => onOpen(index); return (
-
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(); } } : undefined} - role={canJump ? 'button' : undefined} - tabIndex={canJump ? 0 : undefined} - title={canJump ? t('cmp.comment.view') : undefined} - > +
{comment.path} L{comment.startLine}
{comment.content}
- {canJump && } - + {canJump && } +
); diff --git a/extensions/vscode/src/webview/store.ts b/extensions/vscode/src/webview/store.ts index 46c0209..d9e6493 100644 --- a/extensions/vscode/src/webview/store.ts +++ b/extensions/vscode/src/webview/store.ts @@ -13,7 +13,6 @@ export interface AppState { logs: LogLine[]; session: { state: ReviewState; result: CliResult | null; error?: string }; commentStatus: Record; - commentJumpable: Record; reviewMode: ReviewMode; locale: SupportedLocale; } @@ -27,8 +26,7 @@ export const initialState: AppState = { logs: [], session: { state: 'idle', result: null }, commentStatus: {}, - commentJumpable: {}, - reviewMode: ReviewMode.Workspace, + reviewMode: 'workspace', locale: 'en', }; @@ -68,30 +66,18 @@ export function reducer(state: AppState, msg: HostToWebview | LocalAction): AppS ...state, logs: starting ? [] : state.logs, commentStatus: starting ? {} : state.commentStatus, - commentJumpable: starting ? {} : state.commentJumpable, session: { state: msg.state, result: starting ? null : state.session.result, error: msg.error }, view: STATE_TO_VIEW[msg.state], }; } case 'logLine': return { ...state, logs: [...state.logs, msg.line] }; - case 'reviewDone': { - const commentJumpable: Record = {}; - msg.result.comments.forEach((_, i) => { commentJumpable[i] = true; }); - return { - ...state, - session: { ...state.session, result: msg.result }, - commentJumpable, - }; - } + case 'reviewDone': + return { ...state, session: { ...state.session, result: msg.result } }; case 'commentSync': { const commentStatus = { ...state.commentStatus }; - const commentJumpable = { ...state.commentJumpable }; - for (const c of msg.comments) { - commentStatus[c.index] = c.status; - if (c.jumpable !== undefined) commentJumpable[c.index] = c.jumpable; - } - return { ...state, commentStatus, commentJumpable }; + for (const c of msg.comments) commentStatus[c.index] = c.status; + return { ...state, commentStatus }; } default: return state; diff --git a/extensions/vscode/src/webview/styles/global.css b/extensions/vscode/src/webview/styles/global.css index 06a5e67..78f8dac 100644 --- a/extensions/vscode/src/webview/styles/global.css +++ b/extensions/vscode/src/webview/styles/global.css @@ -824,8 +824,6 @@ body { margin-bottom: 10px; transition: opacity 0.3s, max-height 0.3s, padding 0.3s, margin 0.3s; max-height: 400px; - display: flex; - flex-direction: column; overflow: hidden; } @@ -844,18 +842,6 @@ body { flex-wrap: wrap; } -.comment-header.jumpable { - cursor: pointer; - border-radius: 6px; - margin: -4px -6px 4px; - padding: 4px 6px; - transition: background 0.15s; -} - -.comment-header.jumpable:hover { - background: var(--card-quiet); -} - .comment-file { font-family: var(--font-mono); font-size: 11.5px; @@ -900,15 +886,11 @@ body { line-height: 1.6; color: var(--ink); margin-bottom: 10px; - flex: 1; - min-height: 0; - overflow-y: auto; } .comment-actions { display: flex; gap: 6px; - flex-shrink: 0; } .comment-actions button { diff --git a/extensions/vscode/src/webview/views/ConfigView.tsx b/extensions/vscode/src/webview/views/ConfigView.tsx index 587405b..1e75ef1 100644 --- a/extensions/vscode/src/webview/views/ConfigView.tsx +++ b/extensions/vscode/src/webview/views/ConfigView.tsx @@ -68,17 +68,17 @@ export function ConfigView({ setTab(next.tab); setCustomView(next.customView); setCustomSelection(next.customSelection); - }, [panelFocus, config]); + }, [panelFocus, config, onClearConnTest]); const wide = layout === 'panel'; const t = useT(); const stepper = (
-
+
1 {t('view.config.step1')}
-
+
2 {t('view.config.step2')}
diff --git a/extensions/vscode/src/webview/views/DoneView.tsx b/extensions/vscode/src/webview/views/DoneView.tsx index c862753..4a408c9 100644 --- a/extensions/vscode/src/webview/views/DoneView.tsx +++ b/extensions/vscode/src/webview/views/DoneView.tsx @@ -7,13 +7,13 @@ import { useT } from '../I18nProvider'; interface Props { result: CliResult; commentStatus: Record; - commentJumpable: Record; logs: LogLine[]; + canJump: boolean; onOpen: (index: number) => void; onAction: (index: number, action: 'apply' | 'discard' | 'falsePositive') => void; } -export function DoneView({ result, commentStatus, commentJumpable, logs, onOpen, onAction }: Props) { +export function DoneView({ result, commentStatus, logs, canJump, onOpen, onAction }: Props) { const [showLogs, setShowLogs] = useState(false); const t = useT(); const s = result.summary; @@ -35,8 +35,7 @@ export function DoneView({ result, commentStatus, commentJumpable, logs, onOpen, )} {result.comments.map((c, i) => ( - ))}
diff --git a/extensions/vscode/src/webview/views/IdleView.tsx b/extensions/vscode/src/webview/views/IdleView.tsx index 0e810f2..7de4974 100644 --- a/extensions/vscode/src/webview/views/IdleView.tsx +++ b/extensions/vscode/src/webview/views/IdleView.tsx @@ -20,7 +20,7 @@ interface Props { } export function IdleView({ gitState, modeFiles, filesLoading, configured, onModeChange, onRequestModeFiles, onOpenFile, onStart, onOpenConfig, onOpenCustomProviders, running }: Props) { - const [mode, setMode] = useState(ReviewMode.Workspace); + const [mode, setMode] = useState('workspace'); const [from, setFrom] = useState(''); const [to, setTo] = useState(''); const [commit, setCommit] = useState(''); @@ -31,7 +31,7 @@ export function IdleView({ gitState, modeFiles, filesLoading, configured, onMode if (!configured) return t('view.idle.configFirst'); if (running) return t('view.idle.reviewing'); if (!selectionReady) { - return mode === ReviewMode.Branch ? t('view.idle.selectBranch') : t('view.idle.selectCommit'); + return mode === 'branch' ? t('view.idle.selectBranch') : t('view.idle.selectCommit'); } if (files.length === 0) return t('view.idle.noFiles'); return t('view.idle.reviewAll'); @@ -41,21 +41,21 @@ export function IdleView({ gitState, modeFiles, filesLoading, configured, onMode // 分支两端都选好后,拉取 diff 文件列表 useEffect(() => { - if (mode === ReviewMode.Branch && from && to) onRequestModeFiles(ReviewMode.Branch, from, to); + if (mode === 'branch' && from && to) onRequestModeFiles('branch', from, to); }, [mode, from, to]); // 选中某 commit 后,拉取该 commit 文件列表 useEffect(() => { - if (mode === ReviewMode.Commit && commit) onRequestModeFiles(ReviewMode.Commit, undefined, undefined, commit); + if (mode === 'commit' && commit) onRequestModeFiles('commit', undefined, undefined, commit); }, [mode, commit]); - const files = mode === ReviewMode.Workspace ? gitState.workspaceFiles : modeFiles; + const files = mode === 'workspace' ? gitState.workspaceFiles : modeFiles; // 仅在「确实发起了请求」时显示 loading:分支需选满两端,提交需选中 commit。 - const willRequest = mode === ReviewMode.Workspace || (mode === ReviewMode.Branch && !!from && !!to) || (mode === ReviewMode.Commit && !!commit); + const willRequest = mode === 'workspace' || (mode === 'branch' && !!from && !!to) || (mode === 'commit' && !!commit); const loading = filesLoading && willRequest; // 可发起审查的前置条件:按 tab 校验选择已就绪,且有待审查文件、不在加载/审查中。 const selectionReady = - mode === ReviewMode.Workspace || (mode === ReviewMode.Branch && !!from && !!to) || (mode === ReviewMode.Commit && !!commit); + mode === 'workspace' || (mode === 'branch' && !!from && !!to) || (mode === 'commit' && !!commit); const canReview = configured && !running && !loading && selectionReady && files.length > 0; const primaryDisabled = configured ? !canReview : running || loading; @@ -70,14 +70,14 @@ export function IdleView({ gitState, modeFiles, filesLoading, configured, onMode return (
- {([ReviewMode.Workspace, ReviewMode.Branch, ReviewMode.Commit]).map((m) => ( + {(['workspace', 'branch', 'commit'] as ReviewMode[]).map((m) => ( ))}
- {mode === ReviewMode.Branch && ( + {mode === 'branch' && (
{t('view.idle.baseRef')}