mirror of
https://github.com/alibaba/open-code-review.git
synced 2026-07-10 17:59:07 +00:00
Compare commits
56 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4cc2fe8568 | ||
|
|
802af6b461 | ||
|
|
d191862b95 | ||
|
|
88faa5775e | ||
|
|
37ac8658dc | ||
|
|
ed168d9882 | ||
|
|
783835ddad | ||
|
|
fbc11045bd | ||
|
|
8ef497c619 | ||
|
|
daf6365cd3 | ||
|
|
d9159276af | ||
|
|
9c1121691a | ||
|
|
bf0af144fd | ||
|
|
44ef6d7fc7 | ||
|
|
efe2b2e50b | ||
|
|
07b41bad79 | ||
|
|
f35437671c | ||
|
|
bd05c2b071 | ||
|
|
3d2886ea78 | ||
|
|
e6e5da0930 | ||
|
|
e2d75b732a | ||
|
|
ac1eff5bae | ||
|
|
38efeff30e | ||
|
|
eb680d9645 | ||
|
|
46dde274d8 | ||
|
|
14f1c22a60 | ||
|
|
c973e581ec | ||
|
|
a2e08b77a1 | ||
|
|
39eb0f3984 | ||
|
|
964f2166d0 | ||
|
|
9dfcffda07 | ||
|
|
10f587594b | ||
|
|
1587630604 | ||
|
|
b82fcc31f2 | ||
|
|
44e4867637 | ||
|
|
4596e2ce2e | ||
|
|
5e8099f9e3 | ||
|
|
c9acbcf91b | ||
|
|
d1ef549e93 | ||
|
|
0aa6ac2cbf | ||
|
|
8c9778860d | ||
|
|
6ded4f3624 | ||
|
|
db254dd9c8 | ||
|
|
64398465d4 | ||
|
|
022ed75682 | ||
|
|
589a7249f5 | ||
|
|
74a2ac1c1c | ||
|
|
0d635537e3 | ||
|
|
d83a758a6d | ||
|
|
90a926e964 | ||
|
|
d70dbfa02b | ||
|
|
7497d5ac6e | ||
|
|
64e008fcb4 | ||
|
|
fef4314d46 | ||
|
|
44fabbaa68 | ||
|
|
0dac8ac376 |
207 changed files with 27471 additions and 2604 deletions
|
|
@ -15,6 +15,7 @@ ocr review --audience agent [user-args]
|
|||
- If the user provides `--commit` or `--c`: pass through as-is.
|
||||
- If the user provides `--from` and `--to`: pass through as-is.
|
||||
- (Optional) Provide `--background "requirement context"` to review whether the requirements are correctly implemented.
|
||||
- (Optional) Provide `--background-file ./requirements.md` to load the same context from a Markdown file (sanitised and limited to 8000 characters). Combined with `--background` the inline value is given first.
|
||||
- Capture full stdout. Set a 5-minute timeout.
|
||||
- If the `ocr` command is not found, install it by running `npm i -g @alibaba-group/open-code-review`.
|
||||
|
||||
|
|
|
|||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
|
|
@ -14,7 +14,7 @@ jobs:
|
|||
runs-on: self-hosted
|
||||
timeout-minutes: 15
|
||||
container:
|
||||
image: golang:1.26.4
|
||||
image: golang:1.26.5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
|
|
|
|||
4
.github/workflows/deploy-pages.yml
vendored
4
.github/workflows/deploy-pages.yml
vendored
|
|
@ -20,7 +20,7 @@ jobs:
|
|||
build:
|
||||
runs-on: self-hosted
|
||||
container:
|
||||
image: node:20
|
||||
image: node:24
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
|
|
@ -51,7 +51,7 @@ jobs:
|
|||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
runs-on: self-hosted
|
||||
container:
|
||||
image: node:20
|
||||
image: node:24
|
||||
needs: build
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
|
|
|
|||
494
.github/workflows/ocr-review.yml
vendored
494
.github/workflows/ocr-review.yml
vendored
|
|
@ -1,46 +1,19 @@
|
|||
# OpenCodeReview - GitHub Actions PR Auto-Review Pipeline
|
||||
#
|
||||
# This workflow automatically reviews pull requests using OpenCodeReview
|
||||
# and posts review comments directly on the PR.
|
||||
# Reviews pull requests using the reusable composite action defined in
|
||||
# action.yml at the repo root. See that file for the full list of inputs,
|
||||
# outputs, and implementation details (retry, idempotency, artifact upload, etc.).
|
||||
#
|
||||
# Triggers:
|
||||
# - PR opened (uses pull_request_target for fork secret access)
|
||||
# - Comment on PR containing '/open-code-review' or '@open-code-review'
|
||||
#
|
||||
# Required secrets:
|
||||
# OCR_LLM_URL - LLM API endpoint (e.g., https://api.openai.com/v1/chat/completions)
|
||||
# OCR_LLM_AUTH_TOKEN - Authentication token for the LLM API
|
||||
#
|
||||
# Optional secrets:
|
||||
# OCR_LLM_MODEL - Model name (default: gpt-4o)
|
||||
# OCR_LLM_USE_ANTHROPIC - Set to 'true' if using Anthropic Claude models
|
||||
#
|
||||
# Optional variables (for retry/delay tuning):
|
||||
# The retry strategy follows GitHub's documented guidance for REST API rate limits:
|
||||
# https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api
|
||||
# - Primary rate limit exhausted (x-ratelimit-remaining=0): wait until x-ratelimit-reset.
|
||||
# - Secondary rate limit with a retry-after header: wait exactly that long.
|
||||
# - Secondary rate limit with NO header: wait at least one minute, then use
|
||||
# exponential backoff on continued failures.
|
||||
#
|
||||
# OCR_RETRY_BASE_DELAY - Base delay (ms) for exponential backoff when no retry
|
||||
# header is present (default: 60000, per GitHub's
|
||||
# "at least one minute" recommendation for secondary limits).
|
||||
# OCR_RETRY_MAX_DELAY - Maximum delay (ms) cap applied to EVERY computed wait,
|
||||
# including retry-after and x-ratelimit-reset, so a far-future
|
||||
# reset cannot stall the job past its timeout (default: 300000 = 5 min).
|
||||
# OCR_MAX_RETRIES - Max retry attempts per comment when rate-limited (default: 3).
|
||||
# OCR_SUCCESS_DELAY - Delay (ms) between successful comment posts to pace requests (default: 2000).
|
||||
# OCR_FAILURE_DELAY - Delay (ms) after a non-retryable failure to pace subsequent requests (default: 1000).
|
||||
# OCR_LOW_REMAINING_THRESHOLD - When x-ratelimit-remaining is at or below this value,
|
||||
# proactively increase request spacing to avoid hitting the limit
|
||||
# (default: 3; GitHub best practice is to watch the header and slow down).
|
||||
# OCR_LOW_REMAINING_SPACING - Request spacing (ms) used when remaining quota is low
|
||||
# (default: 10000 = 10s).
|
||||
# Required secrets (mapped to action inputs):
|
||||
# OCR_LLM_URL - LLM API endpoint (e.g., https://api.openai.com/v1/chat/completions)
|
||||
# OCR_LLM_AUTH_TOKEN - Authentication token for the LLM API
|
||||
# OCR_LLM_MODEL - Model name
|
||||
# OCR_LLM_USE_ANTHROPIC - 'true' for Anthropic Claude, 'false' for OpenAI-compatible
|
||||
#
|
||||
# Note: GITHUB_TOKEN is automatically provided by GitHub Actions.
|
||||
# Note: The workflow also configures llm.extra_body to '{"thinking": {"type": "disabled"}}'
|
||||
# to disable thinking mode for compatibility with various LLM providers.
|
||||
|
||||
name: OpenCodeReview PR Review
|
||||
|
||||
|
|
@ -62,445 +35,30 @@ permissions:
|
|||
jobs:
|
||||
code-review:
|
||||
runs-on: self-hosted
|
||||
timeout-minutes: 30
|
||||
container:
|
||||
image: node:20
|
||||
image: node:24
|
||||
if: github.event_name == 'pull_request_target'
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
# Materialize action.yml + scripts/ into the workspace so the local
|
||||
# `uses: ./` action below can be resolved and loaded. For
|
||||
# pull_request_target this checks out the trusted base branch; the
|
||||
# composite action performs its own full checkout (fetch-depth: 0) later.
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # Full history needed for merge-base diff
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Mark repository as safe directory
|
||||
- name: Trust workspace
|
||||
run: git config --global --replace-all safe.directory '*'
|
||||
|
||||
- name: Fetch PR head ref (ensures fork commits are available)
|
||||
run: git fetch origin pull/${{ github.event.pull_request.number }}/head
|
||||
|
||||
- name: Install OpenCodeReview
|
||||
run: npm install -g @alibaba-group/open-code-review
|
||||
|
||||
- name: Configure OCR
|
||||
run: |
|
||||
ocr config set llm.url ${{ secrets.OCR_LLM_URL }}
|
||||
ocr config set llm.auth_token ${{ secrets.OCR_LLM_AUTH_TOKEN }}
|
||||
ocr config set llm.model ${{ secrets.OCR_LLM_MODEL }}
|
||||
ocr config set llm.use_anthropic ${{ secrets.OCR_LLM_USE_ANTHROPIC }}
|
||||
ocr config set llm.extra_body '{"enable_thinking": false}'
|
||||
ocr config set language English
|
||||
|
||||
- name: Run OpenCodeReview
|
||||
id: review
|
||||
run: |
|
||||
BASE_REF="${{ github.event.pull_request.base.ref }}"
|
||||
HEAD_SHA="${{ github.event.pull_request.head.sha }}"
|
||||
|
||||
echo "Reviewing PR: ${HEAD_SHA} against origin/${BASE_REF}"
|
||||
|
||||
# Run OCR in range mode with JSON output
|
||||
ocr review \
|
||||
--from "origin/${BASE_REF}" \
|
||||
--to "${HEAD_SHA}" \
|
||||
--format json \
|
||||
> /tmp/ocr-result.json 2>/tmp/ocr-stderr.log || true
|
||||
|
||||
echo "OCR review completed. Output:"
|
||||
cat /tmp/ocr-result.json
|
||||
echo "OCR review completed. Error log:"
|
||||
cat /tmp/ocr-stderr.log
|
||||
|
||||
- name: Post review comments to PR
|
||||
uses: actions/github-script@v7
|
||||
uses: ./
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const path = '/tmp/ocr-result.json';
|
||||
|
||||
// Read OCR output
|
||||
let result;
|
||||
try {
|
||||
const raw = fs.readFileSync(path, 'utf8');
|
||||
result = JSON.parse(raw);
|
||||
} catch (e) {
|
||||
console.log('Failed to parse OCR output:', e.message);
|
||||
// Post a simple comment if parsing fails
|
||||
const stderr = fs.readFileSync('/tmp/ocr-stderr.log', 'utf8').trim();
|
||||
if (stderr) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: `⚠️ **OpenCodeReview** encountered an error:\n${fencedBlock(stderr)}`
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const comments = result.comments || [];
|
||||
const warnings = result.warnings || [];
|
||||
|
||||
// If no comments, post a summary
|
||||
if (comments.length === 0) {
|
||||
const message = result.message || 'No comments generated. Looks good to me.';
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: `✅ **OpenCodeReview**: ${message}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare PR review with inline comments
|
||||
const prNumber = context.issue.number;
|
||||
let commitSha = context.payload.pull_request.head.sha;
|
||||
|
||||
// Build review comments array for the PR review API
|
||||
// Only inline comments with line info can be posted via createReview
|
||||
const reviewComments = [];
|
||||
const commentsWithoutLine = [];
|
||||
|
||||
for (const comment of comments) {
|
||||
const body = formatComment(comment);
|
||||
|
||||
// Check if comment has valid line information for inline comment (line >= 1)
|
||||
const hasValidLine = (comment.start_line >= 1) || (comment.end_line >= 1);
|
||||
if (!hasValidLine) {
|
||||
commentsWithoutLine.push({ comment, body });
|
||||
continue;
|
||||
}
|
||||
|
||||
const reviewComment = {
|
||||
path: comment.path,
|
||||
body: body
|
||||
};
|
||||
|
||||
// Use line range if available
|
||||
if (comment.start_line >= 1 && comment.end_line >= 1 && comment.start_line !== comment.end_line) {
|
||||
reviewComment.start_line = comment.start_line;
|
||||
reviewComment.line = comment.end_line;
|
||||
reviewComment.start_side = 'RIGHT';
|
||||
reviewComment.side = 'RIGHT';
|
||||
} else if (comment.end_line >= 1) {
|
||||
reviewComment.line = comment.end_line;
|
||||
reviewComment.side = 'RIGHT';
|
||||
} else if (comment.start_line >= 1) {
|
||||
reviewComment.line = comment.start_line;
|
||||
reviewComment.side = 'RIGHT';
|
||||
}
|
||||
|
||||
reviewComments.push({ comment, reviewComment });
|
||||
}
|
||||
|
||||
// Submit as a single PR review with all comments
|
||||
const totalCount = comments.length;
|
||||
const inlineCount = reviewComments.length;
|
||||
const summaryCount = commentsWithoutLine.length;
|
||||
let summaryBody = buildSummaryBody(totalCount, inlineCount, summaryCount, warnings);
|
||||
|
||||
// Add comments without line info to summary body
|
||||
summaryBody += formatSummaryComments(commentsWithoutLine);
|
||||
|
||||
// Statistics tracking
|
||||
let successCount = 0;
|
||||
let failedCount = 0;
|
||||
const failedComments = [];
|
||||
|
||||
try {
|
||||
const batchRes = await github.rest.pulls.createReview({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: prNumber,
|
||||
commit_id: commitSha,
|
||||
body: summaryBody,
|
||||
event: 'COMMENT',
|
||||
comments: reviewComments.map(({ reviewComment }) => reviewComment)
|
||||
});
|
||||
successCount = reviewComments.length;
|
||||
console.log(`Successfully posted review with ${successCount} inline comments (${commentsWithoutLine.length} in summary)`);
|
||||
logRateLimitQuota(batchRes, 'after batch createReview');
|
||||
} catch (e) {
|
||||
console.log('Failed to post review with inline comments:', e.message);
|
||||
console.log('Falling back to posting comments individually with rate-limit-aware retry...');
|
||||
|
||||
// Fallback: post comments one by one with delay to avoid secondary rate limits.
|
||||
// GitHub enforces ~80 content-generating requests per minute; spacing calls
|
||||
// helps stay under that threshold. Retry/wait durations are derived from the
|
||||
// rate-limit response headers per GitHub's documented strategy.
|
||||
const MAX_RETRIES = parseInt(process.env.OCR_MAX_RETRIES, 10) || 3;
|
||||
const SUCCESS_DELAY = parseInt(process.env.OCR_SUCCESS_DELAY, 10) || 2000; // delay after successful post
|
||||
const FAILURE_DELAY = parseInt(process.env.OCR_FAILURE_DELAY, 10) || 1000; // delay after non-retryable failure
|
||||
const LOW_REMAINING_THRESHOLD = parseInt(process.env.OCR_LOW_REMAINING_THRESHOLD, 10) || 3;
|
||||
const LOW_REMAINING_SPACING = parseInt(process.env.OCR_LOW_REMAINING_SPACING, 10) || 10000;
|
||||
|
||||
// If the batch itself was rate-limited, honor its rate-limit headers
|
||||
// (retry-after / x-ratelimit-reset) before retrying per-comment,
|
||||
// otherwise the first per-comment call re-hits the same wall immediately.
|
||||
const batchRetry = computeRetryDelayMs(e, 0);
|
||||
if (batchRetry != null) {
|
||||
const secs = (batchRetry.delayMs / 1000).toFixed(1);
|
||||
console.log(
|
||||
`Batch createReview was rate-limited (HTTP ${e.status}). ` +
|
||||
`Cooling down ${secs}s via '${batchRetry.source}' (${batchRetry.detail}) before per-comment retry.`
|
||||
);
|
||||
await sleep(batchRetry.delayMs);
|
||||
}
|
||||
|
||||
for (const { comment, reviewComment } of reviewComments) {
|
||||
let posted = false;
|
||||
for (let attempt = 0; attempt <= MAX_RETRIES && !posted; attempt++) {
|
||||
try {
|
||||
const res = await github.rest.pulls.createReview({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: prNumber,
|
||||
commit_id: commitSha,
|
||||
body: '',
|
||||
event: 'COMMENT',
|
||||
comments: [reviewComment]
|
||||
});
|
||||
successCount++;
|
||||
posted = true;
|
||||
console.log(`Successfully posted comment for ${reviewComment.path}`);
|
||||
// Proactive throttle: if remaining quota is low, slow down to
|
||||
// avoid hitting the limit (GitHub best practice: watch the header).
|
||||
const remaining = logRateLimitQuota(res, `after ${reviewComment.path}`);
|
||||
const lowQuota = remaining != null && remaining <= LOW_REMAINING_THRESHOLD;
|
||||
if (lowQuota) {
|
||||
console.log(`[rate-limit] quota low (remaining=${remaining} <= ${LOW_REMAINING_THRESHOLD}); increasing spacing to ${LOW_REMAINING_SPACING}ms.`);
|
||||
await sleep(LOW_REMAINING_SPACING);
|
||||
} else {
|
||||
await sleep(SUCCESS_DELAY);
|
||||
}
|
||||
} catch (innerE) {
|
||||
// Decide whether to retry and how long to wait, based on GitHub's
|
||||
// rate-limit documentation (retry-after / x-ratelimit-* headers).
|
||||
const retryInfo = computeRetryDelayMs(innerE, attempt);
|
||||
const willRetry = retryInfo != null && attempt < MAX_RETRIES;
|
||||
if (willRetry) {
|
||||
const secs = (retryInfo.delayMs / 1000).toFixed(1);
|
||||
console.log(
|
||||
`Rate-limited/transient error on ${reviewComment.path} ` +
|
||||
`(HTTP ${innerE.status}, attempt ${attempt + 1}/${MAX_RETRIES}). ` +
|
||||
`Waiting ${secs}s via '${retryInfo.source}' (${retryInfo.detail}). ` +
|
||||
`Error: ${innerE.message}`
|
||||
);
|
||||
await sleep(retryInfo.delayMs);
|
||||
} else {
|
||||
failedCount++;
|
||||
failedComments.push({ comment, error: innerE.message });
|
||||
const reason = retryInfo == null ? 'non-retryable error' : 'rate-limit retries exhausted';
|
||||
console.log(`Failed to post comment for ${reviewComment.path} (${reason}, HTTP ${innerE.status || 'n/a'}): ${innerE.message}`);
|
||||
// After exhausting retries use the success-style pace delay;
|
||||
// for other errors use the shorter failure pace delay.
|
||||
await sleep(retryInfo == null ? FAILURE_DELAY : SUCCESS_DELAY);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Post summary comment with statistics
|
||||
let finalBody = buildSummaryBody(totalCount, successCount, commentsWithoutLine.length + failedComments.length, warnings);
|
||||
finalBody += formatSummaryComments(commentsWithoutLine);
|
||||
finalBody += `\n\n---\n\n📊 **Posting Statistics:**`;
|
||||
finalBody += `\n- ✅ Successfully posted: ${successCount} comment(s)`;
|
||||
if (failedCount > 0) {
|
||||
finalBody += `\n- ❌ Failed to post: ${failedCount} comment(s)`;
|
||||
}
|
||||
|
||||
// Add failed comments as summary content so review feedback is not lost.
|
||||
if (failedComments.length > 0) {
|
||||
finalBody += '\n\n---\n\n### ⚠️ Inline comments shown in summary';
|
||||
for (const { comment, error } of failedComments) {
|
||||
finalBody += '\n\n---\n\n';
|
||||
finalBody += formatCommentMarkdown(comment, error);
|
||||
}
|
||||
}
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
body: finalBody
|
||||
});
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// Case-insensitive header lookup. Octokit normalizes response headers to
|
||||
// lowercase, but this defensive check also handles original casing so that
|
||||
// quota logging and retry delay computation never silently miss a header.
|
||||
function getHeader(headers, name) {
|
||||
const v = headers[name] != null ? headers[name] : headers[name.toLowerCase()];
|
||||
return v != null ? String(v).trim() : undefined;
|
||||
}
|
||||
|
||||
// Decide whether an error is worth retrying and, if so, how long to wait.
|
||||
// Implements GitHub's documented rate-limit retry strategy using the
|
||||
// response headers (retry-after, x-ratelimit-remaining, x-ratelimit-reset).
|
||||
// Returns { delayMs, source, detail } when retryable, or null otherwise.
|
||||
// See: https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api
|
||||
function computeRetryDelayMs(error, attempt) {
|
||||
if (!error) return null;
|
||||
const status = error.status;
|
||||
const message = String(error.message || '');
|
||||
const isRateLimit = status === 429 || (status === 403 && /rate limit|abuse|secondary/i.test(message));
|
||||
const isTransient = (status >= 500 && status < 600) || status === 408;
|
||||
if (!isRateLimit && !isTransient) return null;
|
||||
|
||||
const headers = ((error.response || {}).headers) || {};
|
||||
const header = (name) => getHeader(headers, name);
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
|
||||
// The absolute maximum wait for any single retry. Header-derived waits
|
||||
// (retry-after / x-ratelimit-reset) are GitHub's recommended durations,
|
||||
// but capping them prevents a far-future reset from stalling the CI job
|
||||
// past its timeout. When we cap, the next retry may re-hit the limit.
|
||||
const cap = parseInt(process.env.OCR_RETRY_MAX_DELAY, 10) || 300000;
|
||||
const base = parseInt(process.env.OCR_RETRY_BASE_DELAY, 10) || 60000;
|
||||
|
||||
// { rawMs, source, detail } describing the recommended wait before cap.
|
||||
let info = null;
|
||||
|
||||
if (isRateLimit) {
|
||||
// (1) Honor "retry-after" when present (seconds, or an HTTP-date).
|
||||
const retryAfter = header('retry-after');
|
||||
if (retryAfter) {
|
||||
const secs = Number(retryAfter);
|
||||
if (!isNaN(secs) && secs >= 0) {
|
||||
info = { rawMs: secs * 1000, source: 'retry-after', detail: `${secs}s (from header)` };
|
||||
} else {
|
||||
const dateMs = Date.parse(retryAfter);
|
||||
if (!isNaN(dateMs)) {
|
||||
info = { rawMs: Math.max(0, dateMs - Date.now()), source: 'retry-after (HTTP-date)', detail: retryAfter };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// (2) Primary limit exhausted (x-ratelimit-remaining=0): wait until reset.
|
||||
if (!info) {
|
||||
const remaining = header('x-ratelimit-remaining');
|
||||
const reset = header('x-ratelimit-reset');
|
||||
if (reset != null && Number(remaining) === 0) {
|
||||
const rawMs = Math.max(0, Number(reset) - nowSec) * 1000;
|
||||
info = { rawMs, source: 'x-ratelimit-reset', detail: `remaining=0, reset epoch=${reset} (in ${Math.ceil(rawMs / 1000)}s)` };
|
||||
}
|
||||
}
|
||||
|
||||
// (3) Secondary limit with no retry hint: docs say wait at least one
|
||||
// minute, then increase exponentially between retries.
|
||||
if (!info) {
|
||||
const backoff = Math.min(base * Math.pow(2, attempt), cap);
|
||||
const jitter = Math.floor(Math.random() * 1000);
|
||||
info = { rawMs: backoff + jitter, source: 'exponential-backoff', detail: `base=${base}ms*2^${attempt} (cap ${cap}ms) +${jitter}ms jitter` };
|
||||
}
|
||||
} else {
|
||||
// Transient server error (5xx / 408): back off without the 60s floor.
|
||||
// Use a shorter base than the rate-limit path: server hiccups are
|
||||
// typically short-lived, so a 2s initial wait (doubling per retry)
|
||||
// is sufficient and avoids stalling the CI job unnecessarily.
|
||||
const transientBase = 2000;
|
||||
const backoff = Math.min(transientBase * Math.pow(2, attempt), cap);
|
||||
const jitter = Math.floor(Math.random() * 1000);
|
||||
info = { rawMs: backoff + jitter, source: 'transient-backoff', detail: `base=${transientBase}ms*2^${attempt} (cap ${cap}ms) +${jitter}ms jitter (HTTP ${status})` };
|
||||
}
|
||||
|
||||
// Apply the universal cap to header-derived waits too.
|
||||
const delayMs = Math.min(info.rawMs, cap);
|
||||
if (delayMs < info.rawMs) {
|
||||
info.detail += ` [CAPPED to ${cap}ms; GitHub recommended ${Math.ceil(info.rawMs / 1000)}s]`;
|
||||
}
|
||||
return { delayMs, source: info.source, detail: info.detail };
|
||||
}
|
||||
|
||||
// Best-effort logging of remaining rate-limit quota from a successful response.
|
||||
// Returns the parsed x-ratelimit-remaining value (or null) for proactive throttling.
|
||||
function logRateLimitQuota(response, tag) {
|
||||
try {
|
||||
const h = (response && response.headers) || {};
|
||||
const header = (name) => getHeader(h, name);
|
||||
const remaining = header('x-ratelimit-remaining');
|
||||
const limit = header('x-ratelimit-limit');
|
||||
const reset = header('x-ratelimit-reset');
|
||||
if (remaining != null) {
|
||||
console.log(
|
||||
`[rate-limit] ${tag}: remaining=${remaining}/${limit != null ? limit : '?'}` +
|
||||
(reset != null ? `, reset epoch=${reset}` : '')
|
||||
);
|
||||
}
|
||||
return remaining != null ? Number(remaining) : null;
|
||||
} catch (_) { return null; }
|
||||
}
|
||||
|
||||
function formatComment(comment) {
|
||||
let body = comment.content || '';
|
||||
|
||||
// Add code suggestion if available
|
||||
if (comment.suggestion_code && comment.existing_code) {
|
||||
body += '\n\n**Suggestion:**\n';
|
||||
body += fencedBlock(comment.suggestion_code, 'suggestion');
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
function formatCommentMarkdown(comment, error) {
|
||||
let md = `### 📄 \`${comment.path}\``;
|
||||
if (comment.start_line && comment.end_line) {
|
||||
md += ` (L${comment.start_line}-L${comment.end_line})`;
|
||||
}
|
||||
md += '\n\n';
|
||||
if (error) {
|
||||
md += `⚠️ GitHub could not post this as an inline comment: ${error}\n\n`;
|
||||
}
|
||||
md += comment.content || '';
|
||||
|
||||
if (comment.suggestion_code && comment.existing_code) {
|
||||
md += '\n\n<details><summary>💡 Suggested Change</summary>\n\n';
|
||||
md += '**Before:**\n' + fencedBlock(comment.existing_code) + '\n\n';
|
||||
md += '**After:**\n' + fencedBlock(comment.suggestion_code) + '\n\n';
|
||||
md += '</details>';
|
||||
}
|
||||
|
||||
return md;
|
||||
}
|
||||
|
||||
function buildSummaryBody(totalCount, inlineCount, summaryCount, warnings) {
|
||||
let body = `🔍 **OpenCodeReview** found **${totalCount}** issue(s) in this PR.`;
|
||||
if (totalCount > 0) {
|
||||
body += `\n- ✅ ${inlineCount} posted as inline comment(s)`;
|
||||
body += `\n- 📝 ${summaryCount} posted as summary`;
|
||||
}
|
||||
if (warnings.length > 0) {
|
||||
body += `\n\n⚠️ ${warnings.length} warning(s) occurred during review.`;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
function formatSummaryComments(summaryComments) {
|
||||
let body = '';
|
||||
for (const { comment } of summaryComments) {
|
||||
body += '\n\n---\n\n';
|
||||
body += formatCommentMarkdown(comment);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
function fencedBlock(content, language = '') {
|
||||
const text = String(content || '');
|
||||
const fence = safeFence(text);
|
||||
let block = fence + language + '\n' + text;
|
||||
if (!text.endsWith('\n')) block += '\n';
|
||||
return block + fence;
|
||||
}
|
||||
|
||||
function safeFence(content) {
|
||||
const matches = String(content || '').match(/`+/g) || [];
|
||||
const maxTicks = matches.reduce((max, ticks) => Math.max(max, ticks.length), 0);
|
||||
return '`'.repeat(Math.max(3, maxTicks + 1));
|
||||
}
|
||||
llm_url: ${{ secrets.OCR_LLM_URL }}
|
||||
llm_auth_token: ${{ secrets.OCR_LLM_AUTH_TOKEN }}
|
||||
llm_model: ${{ secrets.OCR_LLM_MODEL }}
|
||||
llm_use_anthropic: ${{ secrets.OCR_LLM_USE_ANTHROPIC }}
|
||||
llm_extra_body: '{"enable_thinking": false}'
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
sticky_summary: 'true'
|
||||
incremental: 'false'
|
||||
upload_artifacts: 'true'
|
||||
|
|
|
|||
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
|
|
@ -11,7 +11,7 @@ jobs:
|
|||
build:
|
||||
runs-on: self-hosted
|
||||
container:
|
||||
image: golang:1.26.4
|
||||
image: golang:1.26.5
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
|
|
@ -160,7 +160,7 @@ jobs:
|
|||
needs: release
|
||||
runs-on: self-hosted
|
||||
container:
|
||||
image: node:20
|
||||
image: node:24
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
|
|
|
|||
114
README.ja-JP.md
114
README.ja-JP.md
|
|
@ -13,7 +13,6 @@
|
|||
<p align="center">
|
||||
<a href="https://www.npmjs.com/package/@alibaba-group/open-code-review"><img alt="npm" src="https://img.shields.io/npm/v/@alibaba-group/open-code-review?style=flat-square" /></a>
|
||||
<a href="https://github.com/alibaba/open-code-review/actions/workflows/release.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/alibaba/open-code-review/release.yml?style=flat-square" /></a>
|
||||
<a href="https://goreportcard.com/report/github.com/alibaba/open-code-review"><img alt="Go Report Card" src="https://goreportcard.com/badge/github.com/alibaba/open-code-review?style=flat-square" /></a>
|
||||
<a href="https://github.com/alibaba/open-code-review/blob/main/LICENSE"><img alt="License" src="https://img.shields.io/github/license/alibaba/open-code-review?style=flat-square" /></a>
|
||||
<a href="https://deepwiki.com/alibaba/open-code-review"><img alt="Ask DeepWiki" src="https://deepwiki.com/badge.svg" /></a>
|
||||
<a href="https://www.bestpractices.dev/projects/13328"><img alt="OpenSSF Best Practices" src="https://img.shields.io/badge/OpenSSF-Silver-4C566A?style=flat-square" /></a>
|
||||
|
|
@ -92,6 +91,10 @@ Open Code Reviewのコア哲学は、決定論的エンジニアリングとエ
|
|||
|
||||
## 使い方
|
||||
|
||||
### 前提条件
|
||||
|
||||
- **Git >= 2.41** — Open Code Review は diff 生成、コード検索、リポジトリ操作に Git を利用します。
|
||||
|
||||
### CLI
|
||||
|
||||
#### インストール
|
||||
|
|
@ -104,6 +107,18 @@ npm install -g @alibaba-group/open-code-review
|
|||
|
||||
インストール後、`ocr`コマンドがグローバルに利用可能になります。
|
||||
|
||||
**更新**
|
||||
|
||||
NPM でインストールした場合は、手動で最新バージョンへ更新できます:
|
||||
|
||||
```bash
|
||||
npm install -g @alibaba-group/open-code-review@latest
|
||||
```
|
||||
|
||||
NPM インストール版の `ocr` は、既定でバックグラウンドで新しいバージョンを確認し、自動的に更新します。自動更新を無効にするには、`OCR_NO_UPDATE=1` を設定してください。
|
||||
|
||||
インストールスクリプトまたは手動ダウンロードしたバイナリでインストールした場合は、同じインストール/ダウンロードコマンドを再実行すると、ローカルのバイナリを最新リリースに置き換えられます。特定のリリースタグに固定する必要がある場合は `OCR_VERSION` を使います。
|
||||
|
||||
**GitHub Releaseから**
|
||||
|
||||
1 つのコマンドで、お使いの OS / アーキテクチャ向けの最新バイナリをインストールできます(macOS / Linux):
|
||||
|
|
@ -265,6 +280,10 @@ ocr review --from main --to feature-branch
|
|||
# 単一コミット
|
||||
ocr review --commit abc123
|
||||
|
||||
# 中断した範囲または単一 commit レビューを再開
|
||||
ocr session list
|
||||
ocr review --from main --to feature-branch --resume <session-id>
|
||||
|
||||
# フルファイルスキャン — diffではなくファイル全体をレビュー(git履歴不要)
|
||||
ocr scan # リポジトリ全体をスキャン
|
||||
ocr scan --path internal/agent # ディレクトリまたは特定のファイルをスキャン
|
||||
|
|
@ -400,10 +419,35 @@ ocr review \
|
|||
|
||||
`--format json`フラグは、CIスクリプトでのパースに適した機械可読な結果を出力します。
|
||||
|
||||
各指摘には2つの構造化フィールドが付与され、CI統合はコメント本文を再パースせずに並べ替え・グループ化・フィルタリング・ビルドのゲート判定を行えます:
|
||||
|
||||
| フィールド | 許可される値 | 説明 |
|
||||
|-----------|-------------|------|
|
||||
| `category` | `bug`、`security`、`performance`、`maintainability`、`test`、`style`、`documentation`、`other` | 指摘が属するカテゴリ。 |
|
||||
| `severity` | `critical`、`high`、`medium`、`low` | 指摘の重要度。 |
|
||||
|
||||
JSON出力ではこの2つのフィールドは`content`や`start_line`などと同じ階層に並びます。ターミナルでは、コメントの前にインラインの`[category · severity]`バッジとして表示され、重要度に応じて色分けされます。
|
||||
|
||||
統合例は[`examples/`](./examples/)ディレクトリを参照してください:
|
||||
|
||||
- [`github_actions/`](./examples/github_actions/) — GitHub Actions統合の例
|
||||
- [`gitlab_ci/`](./examples/gitlab_ci/) — GitLab CI統合の例
|
||||
- [`gitflic_ci/`](./examples/gitflic_ci/) — GitFlic CI統合の例
|
||||
|
||||
#### GitHub Action
|
||||
|
||||
GitHub 向けに、本リポジトリはリポジトリルートにすぐ使える composite Action([`action.yml`](./action.yml))を同梱しています。自分で `ocr review` をスクリプト化する代わりに、これを直接参照するだけで、checkout、OCR のインストール、レビューの実行、インラインコメントとサマリーコメントの投稿、アーティファクトのアップロード、再試行・冪等性までの全パイプラインを処理できます:
|
||||
|
||||
```yaml
|
||||
- uses: alibaba/open-code-review@main
|
||||
with:
|
||||
llm_url: ${{ secrets.OCR_LLM_URL }}
|
||||
llm_auth_token: ${{ secrets.OCR_LLM_AUTH_TOKEN }}
|
||||
llm_model: ${{ vars.OCR_LLM_MODEL }}
|
||||
llm_use_anthropic: ${{ vars.OCR_LLM_USE_ANTHROPIC }}
|
||||
```
|
||||
|
||||
再現性を高めるため、バージョンタグまたはコミット SHA に固定してください。完全なワークフローデモ、inputs/outputs の全一覧、コメント投稿モード(スティッキーサマリー、非破壊的なインクリメンタル投稿)については [`examples/github_actions/`](./examples/github_actions/) ディレクトリを参照してください。
|
||||
|
||||
## コマンド
|
||||
|
||||
|
|
@ -418,6 +462,8 @@ ocr review \
|
|||
| `ocr config unset custom_providers.<name>` | — | カスタムプロバイダーを削除 |
|
||||
| `ocr llm test` | — | LLMの疎通テスト |
|
||||
| `ocr llm providers` | — | ビルトインLLMプロバイダーを一覧表示 |
|
||||
| `ocr session list` | `ocr sessions list`, `ocr session ls` | 保存済みレビューセッションを一覧表示 |
|
||||
| `ocr session show <id>` | `ocr sessions show <id>` | 1つのセッションとファイル単位のチェックポイントを表示 |
|
||||
| `ocr viewer` | `ocr v` | `localhost:5483`でWebUIセッションビューアーを起動 |
|
||||
| `ocr version` | — | バージョン情報を表示 |
|
||||
|
||||
|
|
@ -431,17 +477,54 @@ ocr review \
|
|||
| `--commit` | `-c` | — | レビュー対象の単一コミット |
|
||||
| `--exclude` | — | — | カンマ区切りのgitignoreスタイルパターンでスキップ対象を指定;rule.jsonのexcludesとマージ |
|
||||
| `--preview` | `-p` | `false` | LLMを実行せずにレビュー対象ファイルをプレビュー |
|
||||
| `--resume` | — | — | 以前の互換性のある範囲または単一 commit レビューセッションから再開 |
|
||||
| `--format` | `-f` | `text` | 出力形式:`text`または`json` |
|
||||
| `--concurrency` | — | `8` | ファイルレビューの最大同時実行数 |
|
||||
| `--timeout` | — | `10` | 同時実行タスクのタイムアウト(分) |
|
||||
| `--audience` | — | `human` | `human`(進捗を表示)または`agent`(サマリーのみ) |
|
||||
| `--background` | `-b` | — | レビューのための任意の要件/ビジネスコンテキスト。`--commit`使用時に未指定の場合、コミットメッセージから自動取得 |
|
||||
| `--background-file` | `-B` | — | Markdownファイルから読み込む任意の要件/ビジネスコンテキスト。`--background`と併用した場合はインラインの値が先に配置されます |
|
||||
| `--model` | — | — | このレビューでLLMモデルを選択または上書き |
|
||||
| `--rule` | — | — | カスタムJSONレビュールールへのパス |
|
||||
| `--max-tools` | — | 組み込み値 | ファイルごとのツール呼び出しラウンドの上限。テンプレートのデフォルトより大きい場合のみ有効 |
|
||||
| `--max-git-procs` | — | 組み込み値 | gitサブプロセスの最大同時実行数 |
|
||||
| `--tools` | — | — | カスタムJSONツール設定へのパス |
|
||||
|
||||
#### 再開可能なレビューとセッション
|
||||
|
||||
すべての `ocr review` 実行は、`~/.opencodereview/sessions/` 配下にローカル
|
||||
セッションログを保存します。正常終了したテキスト出力はレビュー結果に集中し、session ID
|
||||
は表示しません。保存済みセッションは `ocr session list/show` で確認でき、
|
||||
`--format json` では機械可読出力に `session_id` が含まれます。範囲または単一 commit
|
||||
レビューが中断された場合は、保存済みセッションを一覧表示し、同じレビュー対象に一致するセッションから再開します:
|
||||
|
||||
```bash
|
||||
ocr session list
|
||||
ocr session show <session-id>
|
||||
ocr review --from main --to feature-branch --resume <session-id>
|
||||
ocr review --commit abc123 --resume <session-id>
|
||||
```
|
||||
|
||||
再開は意図的に厳密です。範囲レビューと単一 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 <id>` | `--repo` | カレントディレクトリ | 確認するセッションのリポジトリ |
|
||||
| `ocr session show <id>` | `--json` | `false` | セッションメタデータとファイル単位の項目をJSONで出力 |
|
||||
|
||||
### `ocr scan`のフラグ
|
||||
|
||||
`ocr scan` はdiffではなくファイル全体をレビューします — 不慣れなコードベースの監査、マイグレーション前のスキャン、意味のあるdiffがないディレクトリなどに有用です。非gitディレクトリでも動作します(`.gitignore` を尊重するファイルシステムウォークにフォールバック)。
|
||||
|
|
@ -487,6 +570,12 @@ ocr review --from main --to my-feature --concurrency 4
|
|||
# 特定のコミットを詳細なJSON出力でレビュー
|
||||
ocr review --commit abc123 --format json --audience agent
|
||||
|
||||
# 中断した範囲または単一 commit レビューを再開
|
||||
ocr session list
|
||||
ocr session show <session-id>
|
||||
ocr review --from main --to my-feature --resume <session-id>
|
||||
ocr review --commit abc123 --resume <session-id>
|
||||
|
||||
# このレビューでモデルを選択またはオーバーライド
|
||||
ocr review --model claude-opus-4-6
|
||||
ocr review --commit abc123 --model claude-sonnet-4-6
|
||||
|
|
@ -494,6 +583,12 @@ ocr review --commit abc123 --model claude-sonnet-4-6
|
|||
# 要件コンテキストを提供してより的確なレビューを実施
|
||||
ocr review --background "ログインAPIにレート制限を追加"
|
||||
|
||||
# Markdownファイルから要件コンテキストを提供
|
||||
ocr review --background-file ./docs/my_business_context.md
|
||||
|
||||
# インラインのコンテキストとローカルのコンテキストファイルを組み合わせる(両方が使用されます)
|
||||
ocr review --background "認証に注目" --background-file ./docs/my_business_context.md
|
||||
|
||||
# カスタムレビュールールを使用
|
||||
ocr review --rule /path/to/my-rules.json
|
||||
|
||||
|
|
@ -748,13 +843,22 @@ ocr config set telemetry.otlp_endpoint localhost:4317
|
|||
|
||||
エクスポートデータにLLMのプロンプトとレスポンスを含めるには、`telemetry.content_logging`を設定してください。
|
||||
|
||||
**プロトコル選択:** 環境変数 `OTEL_EXPORTER_OTLP_PROTOCOL` でエクスポートプロトコルを選択できます:
|
||||
|
||||
| 値 | トランスポート | 説明 |
|
||||
|---|---|---|
|
||||
| `grpc`(デフォルト) | gRPC | デフォルトポート 4317 |
|
||||
| `http/protobuf` | HTTP | デフォルトポート 4318 |
|
||||
|
||||
**Endpoint 形式:** `telemetry.otlp_endpoint` は `host:port` または `http://host:port` 形式のベースURLを指定します。パスを含める必要はありません。SDKが [OTLP仕様](https://opentelemetry.io/docs/specs/otlp/#otlphttp-request)に従いシグナルパス(例:`/v1/traces`)を自動的に付加します。
|
||||
|
||||
## コントリビューション
|
||||
|
||||
開発環境のセットアップ、コーディングガイドライン、プルリクエストの提出方法については[CONTRIBUTING.md](CONTRIBUTING.md)を参照してください。
|
||||
このプロジェクトは、貢献してくださるすべての方々のおかげで成り立っています。開発環境のセットアップ、コーディングガイドライン、プルリクエストの提出方法については[CONTRIBUTING.md](CONTRIBUTING.md)を参照してください。
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://star-history.com/#alibaba/open-code-review&Date)
|
||||
<a href="https://github.com/alibaba/open-code-review/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=alibaba/open-code-review" />
|
||||
</a>
|
||||
|
||||
## ライセンス
|
||||
|
||||
|
|
|
|||
114
README.ko-KR.md
114
README.ko-KR.md
|
|
@ -13,7 +13,6 @@
|
|||
<p align="center">
|
||||
<a href="https://www.npmjs.com/package/@alibaba-group/open-code-review"><img alt="npm" src="https://img.shields.io/npm/v/@alibaba-group/open-code-review?style=flat-square" /></a>
|
||||
<a href="https://github.com/alibaba/open-code-review/actions/workflows/release.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/alibaba/open-code-review/release.yml?style=flat-square" /></a>
|
||||
<a href="https://goreportcard.com/report/github.com/alibaba/open-code-review"><img alt="Go Report Card" src="https://goreportcard.com/badge/github.com/alibaba/open-code-review?style=flat-square" /></a>
|
||||
<a href="https://github.com/alibaba/open-code-review/blob/main/LICENSE"><img alt="License" src="https://img.shields.io/github/license/alibaba/open-code-review?style=flat-square" /></a>
|
||||
<a href="https://deepwiki.com/alibaba/open-code-review"><img alt="Ask DeepWiki" src="https://deepwiki.com/badge.svg" /></a>
|
||||
<a href="https://www.bestpractices.dev/projects/13328"><img alt="OpenSSF Best Practices" src="https://img.shields.io/badge/OpenSSF-Silver-4C566A?style=flat-square" /></a>
|
||||
|
|
@ -92,6 +91,10 @@ agent의 강점은 동적 판단과 동적 context 검색이 중요한 지점에
|
|||
|
||||
## 사용 방법
|
||||
|
||||
### 사전 요구 사항
|
||||
|
||||
- **Git >= 2.41** — Open Code Review는 diff 생성, 코드 검색, 저장소 작업에 Git을 사용합니다.
|
||||
|
||||
### CLI
|
||||
|
||||
#### 설치
|
||||
|
|
@ -104,6 +107,18 @@ npm install -g @alibaba-group/open-code-review
|
|||
|
||||
설치 후 `ocr` 명령을 전역에서 사용할 수 있습니다.
|
||||
|
||||
**업데이트**
|
||||
|
||||
NPM으로 설치했다면 최신 버전으로 수동 업데이트할 수 있습니다:
|
||||
|
||||
```bash
|
||||
npm install -g @alibaba-group/open-code-review@latest
|
||||
```
|
||||
|
||||
NPM 설치의 `ocr`은 기본적으로 백그라운드에서 새 버전을 확인하고 자동으로 업데이트합니다. 자동 업데이트를 끄려면 `OCR_NO_UPDATE=1`을 설정하세요.
|
||||
|
||||
설치 스크립트나 수동 다운로드한 binary로 설치했다면 같은 설치/다운로드 명령을 다시 실행해 로컬 binary를 최신 release로 교체할 수 있습니다. 특정 release tag로 고정해야 한다면 `OCR_VERSION`을 사용하세요.
|
||||
|
||||
**GitHub Release 사용**
|
||||
|
||||
명령 한 번으로 사용 중인 OS/아키텍처에 맞는 최신 binary를 설치합니다 (macOS / Linux):
|
||||
|
|
@ -265,6 +280,10 @@ ocr review --from main --to feature-branch
|
|||
# 단일 commit
|
||||
ocr review --commit abc123
|
||||
|
||||
# 중단된 range 또는 단일 commit review 재개
|
||||
ocr session list
|
||||
ocr review --from main --to feature-branch --resume <session-id>
|
||||
|
||||
# 전체 파일 스캔 — diff 대신 파일 전체를 리뷰 (git 이력 불필요)
|
||||
ocr scan # 전체 repository 스캔
|
||||
ocr scan --path internal/agent # 디렉터리 또는 특정 파일 스캔
|
||||
|
|
@ -400,10 +419,35 @@ ocr review \
|
|||
|
||||
`--format json` flag는 CI script에서 파싱하기 좋은 machine-readable 결과를 출력합니다.
|
||||
|
||||
각 finding에는 두 개의 구조화된 field가 포함되어, CI 통합에서 comment 텍스트를 다시 파싱하지 않고도 정렬·그룹화·필터링하거나 build를 gate할 수 있습니다:
|
||||
|
||||
| Field | 허용 값 | 설명 |
|
||||
|-------|--------|------|
|
||||
| `category` | `bug`, `security`, `performance`, `maintainability`, `test`, `style`, `documentation`, `other` | 이슈가 속한 카테고리. |
|
||||
| `severity` | `critical`, `high`, `medium`, `low` | 이슈의 중요도. |
|
||||
|
||||
JSON 출력에서 두 field는 `content`, `start_line` 등과 같은 수준의 sibling으로 나타납니다. 터미널에서는 comment 앞에 인라인 `[category · severity]` badge로 표시되며 severity에 따라 색상이 지정됩니다.
|
||||
|
||||
통합 예시는 [`examples/`](./examples/) 디렉터리를 참고하세요.
|
||||
|
||||
- [`github_actions/`](./examples/github_actions/): GitHub Actions 통합 예시
|
||||
- [`gitlab_ci/`](./examples/gitlab_ci/): GitLab CI 통합 예시
|
||||
- [`gitflic_ci/`](./examples/gitflic_ci/): GitFlic CI 통합 예시
|
||||
|
||||
#### GitHub Action
|
||||
|
||||
GitHub의 경우, 이 리포지터리는 루트에 바로 사용할 수 있는 composite Action([`action.yml`](./action.yml))을 제공합니다. 직접 `ocr review` 스크립트를 작성하는 대신 이를 참조하기만 하면 전체 파이프라인 — checkout, OCR 설치, review 실행, inline/summary comment 게시, artifact 업로드, 재시도 및 멱등성 — 을 모두 처리합니다:
|
||||
|
||||
```yaml
|
||||
- uses: alibaba/open-code-review@main
|
||||
with:
|
||||
llm_url: ${{ secrets.OCR_LLM_URL }}
|
||||
llm_auth_token: ${{ secrets.OCR_LLM_AUTH_TOKEN }}
|
||||
llm_model: ${{ vars.OCR_LLM_MODEL }}
|
||||
llm_use_anthropic: ${{ vars.OCR_LLM_USE_ANTHROPIC }}
|
||||
```
|
||||
|
||||
재현성을 위해 version tag나 commit SHA에 고정하세요. 전체 workflow 데모와 inputs/outputs, comment 게시 모드(sticky summary, incremental non-destructive posting)의 전체 목록은 [`examples/github_actions/`](./examples/github_actions/) 디렉터리를 참고하세요.
|
||||
|
||||
## Commands
|
||||
|
||||
|
|
@ -418,6 +462,8 @@ ocr review \
|
|||
| `ocr config unset custom_providers.<name>` | - | 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 <id>` | `ocr sessions show <id>` | 단일 session과 파일별 checkpoint 확인 |
|
||||
| `ocr viewer` | `ocr v` | `localhost:5483`에서 WebUI session viewer 실행 |
|
||||
| `ocr version` | - | version 정보 표시 |
|
||||
|
||||
|
|
@ -431,17 +477,54 @@ ocr review \
|
|||
| `--commit` | `-c` | - | 리뷰할 단일 commit |
|
||||
| `--exclude` | - | - | 건너뛸 파일의 쉼표 구분 gitignore 스타일 패턴; rule.json의 excludes와 병합 |
|
||||
| `--preview` | `-p` | `false` | LLM 실행 없이 리뷰 대상 파일 미리보기 |
|
||||
| `--resume` | - | - | 이전의 호환되는 range 또는 단일 commit review session에서 재개 |
|
||||
| `--format` | `-f` | `text` | Output format: `text` 또는 `json` |
|
||||
| `--concurrency` | - | `8` | 최대 동시 파일 리뷰 수 |
|
||||
| `--timeout` | - | `10` | 동시 task timeout(분) |
|
||||
| `--audience` | - | `human` | `human`(progress 표시) 또는 `agent`(summary only) |
|
||||
| `--background` | `-b` | - | 리뷰를 위한 선택적 요구사항/비즈니스 컨텍스트. `--commit` 사용 시 미지정이면 commit message에서 자동 추출 |
|
||||
| `--background-file` | `-B` | - | Markdown 파일에서 읽어오는 선택적 요구사항/비즈니스 컨텍스트. `--background`와 함께 사용하면 inline 값이 먼저 배치됩니다 |
|
||||
| `--model` | - | - | 이번 리뷰에서 LLM model 선택 또는 override |
|
||||
| `--rule` | - | - | custom JSON review rules 경로 |
|
||||
| `--max-tools` | - | built-in | 파일별 최대 tool call round. template default보다 클 때만 적용 |
|
||||
| `--max-git-procs` | - | built-in | 최대 동시 git subprocess 수 |
|
||||
| `--tools` | - | - | custom JSON tools config 경로 |
|
||||
|
||||
#### Resumable Reviews and Sessions
|
||||
|
||||
모든 `ocr review` 실행은 `~/.opencodereview/sessions/` 아래에 local session log를 저장합니다.
|
||||
정상 완료된 text output은 review 결과에 집중하며 session ID를 출력하지 않습니다.
|
||||
저장된 session은 `ocr session list/show`로 찾을 수 있고, `--format json`을 사용하면
|
||||
machine-readable output에 `session_id`가 포함됩니다. range 또는 단일 commit review가 중단된 경우,
|
||||
저장된 session을 나열한 뒤 동일한 review target과 일치하는 session에서 재개합니다.
|
||||
|
||||
```bash
|
||||
ocr session list
|
||||
ocr session show <session-id>
|
||||
ocr review --from main --to feature-branch --resume <session-id>
|
||||
ocr review --commit abc123 --resume <session-id>
|
||||
```
|
||||
|
||||
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 <id>` | `--repo` | current dir | 확인할 session의 repository |
|
||||
| `ocr session show <id>` | `--json` | `false` | session metadata와 파일별 item을 JSON으로 출력 |
|
||||
|
||||
### `ocr scan` Flags
|
||||
|
||||
`ocr scan`은 diff가 아닌 전체 파일을 리뷰합니다 — 익숙하지 않은 코드베이스 감사, 마이그레이션 전 스캔, 의미 있는 diff가 없는 디렉터리 등에 유용합니다. 비-git 디렉터리에서도 작동합니다 (`.gitignore`를 따르는 파일 시스템 탐색으로 폴백).
|
||||
|
|
@ -487,6 +570,12 @@ ocr review --from main --to my-feature --concurrency 4
|
|||
# 특정 commit을 verbose JSON output으로 리뷰
|
||||
ocr review --commit abc123 --format json --audience agent
|
||||
|
||||
# 중단된 range 또는 단일 commit review 재개
|
||||
ocr session list
|
||||
ocr session show <session-id>
|
||||
ocr review --from main --to my-feature --resume <session-id>
|
||||
ocr review --commit abc123 --resume <session-id>
|
||||
|
||||
# 이번 리뷰에서 model 선택 또는 override
|
||||
ocr review --model claude-opus-4-6
|
||||
ocr review --commit abc123 --model claude-sonnet-4-6
|
||||
|
|
@ -494,6 +583,12 @@ ocr review --commit abc123 --model claude-sonnet-4-6
|
|||
# 요구사항 컨텍스트를 제공하여 더 정확한 리뷰 수행
|
||||
ocr review --background "로그인 API에 rate limiting 추가"
|
||||
|
||||
# Markdown 파일에서 요구사항 컨텍스트 제공
|
||||
ocr review --background-file ./docs/my_business_context.md
|
||||
|
||||
# inline 컨텍스트와 로컬 컨텍스트 파일을 함께 사용(둘 다 적용됨)
|
||||
ocr review --background "인증에 집중" --background-file ./docs/my_business_context.md
|
||||
|
||||
# custom review rules 사용
|
||||
ocr review --rule /path/to/my-rules.json
|
||||
|
||||
|
|
@ -705,13 +800,22 @@ ocr config set telemetry.otlp_endpoint localhost:4317
|
|||
|
||||
exported data에 LLM prompt와 response를 포함하려면 `telemetry.content_logging`을 설정합니다.
|
||||
|
||||
**프로토콜 선택:** 환경 변수 `OTEL_EXPORTER_OTLP_PROTOCOL`로 export 프로토콜을 선택할 수 있습니다:
|
||||
|
||||
| 값 | 전송 방식 | 설명 |
|
||||
|---|---|---|
|
||||
| `grpc` (기본값) | gRPC | 기본 포트 4317 |
|
||||
| `http/protobuf` | HTTP | 기본 포트 4318 |
|
||||
|
||||
**Endpoint 형식:** `telemetry.otlp_endpoint`는 `host:port` 또는 `http://host:port` 형식의 base URL을 지정합니다. 경로를 포함할 필요가 없습니다. SDK가 [OTLP 사양](https://opentelemetry.io/docs/specs/otlp/#otlphttp-request)에 따라 signal 경로(예: `/v1/traces`)를 자동으로 추가합니다.
|
||||
|
||||
## Contributing
|
||||
|
||||
개발 환경 설정, coding guideline, pull request 제출 방법은 [CONTRIBUTING.ko-KR.md](CONTRIBUTING.ko-KR.md)를 참고하세요.
|
||||
이 프로젝트는 기여해 주신 모든 분들 덕분에 존재합니다. 개발 환경 설정, coding guideline, pull request 제출 방법은 [CONTRIBUTING.ko-KR.md](CONTRIBUTING.ko-KR.md)를 참고하세요.
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://star-history.com/#alibaba/open-code-review&Date)
|
||||
<a href="https://github.com/alibaba/open-code-review/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=alibaba/open-code-review" />
|
||||
</a>
|
||||
|
||||
## License
|
||||
|
||||
|
|
|
|||
121
README.md
121
README.md
|
|
@ -13,7 +13,6 @@
|
|||
<p align="center">
|
||||
<a href="https://www.npmjs.com/package/@alibaba-group/open-code-review"><img alt="npm" src="https://img.shields.io/npm/v/@alibaba-group/open-code-review?style=flat-square" /></a>
|
||||
<a href="https://github.com/alibaba/open-code-review/actions/workflows/release.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/alibaba/open-code-review/release.yml?style=flat-square" /></a>
|
||||
<a href="https://goreportcard.com/report/github.com/alibaba/open-code-review"><img alt="Go Report Card" src="https://goreportcard.com/badge/github.com/alibaba/open-code-review?style=flat-square" /></a>
|
||||
<a href="https://github.com/alibaba/open-code-review/blob/main/LICENSE"><img alt="License" src="https://img.shields.io/github/license/alibaba/open-code-review?style=flat-square" /></a>
|
||||
<a href="https://deepwiki.com/alibaba/open-code-review"><img alt="Ask DeepWiki" src="https://deepwiki.com/badge.svg" /></a>
|
||||
<a href="https://www.bestpractices.dev/projects/13328"><img alt="OpenSSF Best Practices" src="https://img.shields.io/badge/OpenSSF-Silver-4C566A?style=flat-square" /></a>
|
||||
|
|
@ -92,6 +91,10 @@ The agent's strengths are concentrated where they matter most — dynamic decisi
|
|||
|
||||
## How to Use
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Git >= 2.41** — Open Code Review relies on Git for diff generation, code search, and repository operations.
|
||||
|
||||
### CLI
|
||||
|
||||
#### Install
|
||||
|
|
@ -104,6 +107,18 @@ npm install -g @alibaba-group/open-code-review
|
|||
|
||||
After installation, the `ocr` command is available globally.
|
||||
|
||||
**Update**
|
||||
|
||||
If you installed via NPM, update manually to the latest version:
|
||||
|
||||
```bash
|
||||
npm install -g @alibaba-group/open-code-review@latest
|
||||
```
|
||||
|
||||
NPM installations also check for newer versions in the background by default and upgrade automatically. To disable auto-updates, set `OCR_NO_UPDATE=1`.
|
||||
|
||||
If you installed with the install script or a manually downloaded binary, rerun the same install/download command to replace the local binary with the latest release. Use `OCR_VERSION` when you need to pin a specific release tag.
|
||||
|
||||
**From GitHub Release**
|
||||
|
||||
Install the latest binary for your OS/architecture with one command (macOS / Linux):
|
||||
|
|
@ -265,6 +280,10 @@ ocr review --from main --to feature-branch
|
|||
# Single commit
|
||||
ocr review --commit abc123
|
||||
|
||||
# Resume an interrupted range or commit review
|
||||
ocr session list
|
||||
ocr review --from main --to feature-branch --resume <session-id>
|
||||
|
||||
# Full-file scan — review whole files instead of a diff (no git history needed)
|
||||
ocr scan # scan the entire repository
|
||||
ocr scan --path internal/agent # scan a directory or specific files
|
||||
|
|
@ -402,10 +421,35 @@ The `--from` flag accepts a branch ref (e.g., `origin/main`) or commit SHA as th
|
|||
|
||||
The `--format json` flag outputs machine-readable results suitable for parsing in CI scripts.
|
||||
|
||||
Each finding carries two structured fields so CI integrations can sort, group, filter, or gate builds without re-parsing comment text:
|
||||
|
||||
| Field | Allowed values | Notes |
|
||||
|-------|----------------|-------|
|
||||
| `category` | `bug`, `security`, `performance`, `maintainability`, `test`, `style`, `documentation`, `other` | The category the issue belongs to. |
|
||||
| `severity` | `critical`, `high`, `medium`, `low` | The importance of the issue. |
|
||||
|
||||
In JSON output the two fields appear as siblings alongside `content`, `start_line`, etc. In the terminal, they render as an inline `[category · severity]` badge before the comment, colored by severity.
|
||||
|
||||
See the [`examples/`](./examples/) directory for integration examples:
|
||||
|
||||
- [`github_actions/`](./examples/github_actions/) — GitHub Actions integration example
|
||||
- [`gitlab_ci/`](./examples/gitlab_ci/) — GitLab CI integration example
|
||||
- [`gitflic_ci/`](./examples/gitflic_ci/) — GitFlic CI integration example
|
||||
|
||||
#### GitHub Action
|
||||
|
||||
For GitHub, this repository also ships a ready-to-use composite Action at the repo root ([`action.yml`](./action.yml)). Instead of scripting `ocr review` yourself, reference it directly and it handles the full pipeline — checkout, OCR install, running the review, posting inline and summary comments, uploading artifacts, and retry/idempotency:
|
||||
|
||||
```yaml
|
||||
- uses: alibaba/open-code-review@main
|
||||
with:
|
||||
llm_url: ${{ secrets.OCR_LLM_URL }}
|
||||
llm_auth_token: ${{ secrets.OCR_LLM_AUTH_TOKEN }}
|
||||
llm_model: ${{ vars.OCR_LLM_MODEL }}
|
||||
llm_use_anthropic: ${{ vars.OCR_LLM_USE_ANTHROPIC }}
|
||||
```
|
||||
|
||||
Pin to a version tag or commit SHA for reproducibility. See the [`examples/github_actions/`](./examples/github_actions/) directory for a complete workflow demo and the full list of inputs, outputs, and comment-posting modes (sticky summary, incremental non-destructive posting).
|
||||
|
||||
## Commands
|
||||
|
||||
|
|
@ -420,6 +464,8 @@ See the [`examples/`](./examples/) directory for integration examples:
|
|||
| `ocr config unset custom_providers.<name>` | — | 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 <id>` | `ocr sessions show <id>` | Inspect one session and its per-file checkpoints |
|
||||
| `ocr viewer` | `ocr v` | Launch WebUI session viewer on `localhost:5483` |
|
||||
| `ocr version` | — | Show version info |
|
||||
|
||||
|
|
@ -433,16 +479,55 @@ See the [`examples/`](./examples/) directory for integration examples:
|
|||
| `--commit` | `-c` | — | Single commit to review |
|
||||
| `--exclude` | — | — | Comma-separated gitignore-style patterns to skip; merged with rule.json excludes |
|
||||
| `--preview` | `-p` | `false` | Preview which files will be reviewed without running the LLM |
|
||||
| `--resume` | — | — | Resume from a previous compatible range or commit review session |
|
||||
| `--format` | `-f` | `text` | Output format: `text` or `json` |
|
||||
| `--concurrency` | — | `8` | Max concurrent file reviews |
|
||||
| `--timeout` | — | `10` | Concurrent task timeout in minutes |
|
||||
| `--audience` | — | `human` | `human` (show progress) or `agent` (summary only) |
|
||||
| `--background` | `-b` | — | Optional requirement/business context for the review; auto-filled from commit message when using `--commit` |
|
||||
| `--background-file` | `-B` | — | Optional requirement/business context from a Markdown file; Combined with `--background` the inline value is given first |
|
||||
| `--model` | — | — | Select or override the LLM model for this review |
|
||||
| `--rule` | — | — | Path to custom JSON review rules |
|
||||
| `--max-tools` | — | built-in | Max tool call rounds per file; only takes effect when greater than template default |
|
||||
| `--max-git-procs` | — | built-in | Max concurrent git subprocesses |
|
||||
| `--tools` | — | — | Path to custom JSON tools config |
|
||||
| `--max-git-procs` | — | `16` | Max concurrent git subprocesses |
|
||||
| `--tools` | — | built-in | Path to custom JSON tools config |
|
||||
|
||||
#### Resumable Reviews and Sessions
|
||||
|
||||
Every `ocr review` run persists a local session log under
|
||||
`~/.opencodereview/sessions/`. Successful text output stays focused on review
|
||||
results and does not print the session ID; use `ocr session list/show` to find
|
||||
saved sessions, or `--format json` to include `session_id` in machine-readable
|
||||
output. If a range or commit review is interrupted, list the saved sessions and
|
||||
resume from the one that matches the same review target:
|
||||
|
||||
```bash
|
||||
ocr session list
|
||||
ocr session show <session-id>
|
||||
ocr review --from main --to feature-branch --resume <session-id>
|
||||
ocr review --commit abc123 --resume <session-id>
|
||||
```
|
||||
|
||||
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 <id>` | `--repo` | current dir | Repository whose session should be inspected |
|
||||
| `ocr session show <id>` | `--json` | `false` | Emit session metadata and per-file items as JSON |
|
||||
|
||||
### `ocr scan` Flags
|
||||
|
||||
|
|
@ -492,6 +577,12 @@ ocr review --from main --to my-feature --concurrency 4
|
|||
# Review a specific commit with verbose JSON output
|
||||
ocr review --commit abc123 --format json --audience agent
|
||||
|
||||
# Resume an interrupted range or commit review
|
||||
ocr session list
|
||||
ocr session show <session-id>
|
||||
ocr review --from main --to my-feature --resume <session-id>
|
||||
ocr review --commit abc123 --resume <session-id>
|
||||
|
||||
# Select or override model for this review
|
||||
ocr review --model claude-opus-4-6
|
||||
ocr review --commit abc123 --model claude-sonnet-4-6
|
||||
|
|
@ -499,6 +590,12 @@ ocr review --commit abc123 --model claude-sonnet-4-6
|
|||
# Provide requirement context for more targeted review
|
||||
ocr review --background "Adding rate limiting to the login API"
|
||||
|
||||
# Provide requirement context from a Markdown file
|
||||
ocr review --background-file ./docs/my_business_context.md
|
||||
|
||||
# Combine inline context with a local context file (both are used)
|
||||
ocr review --background "Focus on auth" --background-file ./docs/my_business_context.md
|
||||
|
||||
# Use custom review rules
|
||||
ocr review --rule /path/to/my-rules.json
|
||||
|
||||
|
|
@ -753,13 +850,23 @@ ocr config set telemetry.otlp_endpoint localhost:4317
|
|||
|
||||
Set `telemetry.content_logging` to include LLM prompts and responses in exported data.
|
||||
|
||||
**Protocol selection:** Set the environment variable `OTEL_EXPORTER_OTLP_PROTOCOL` to choose the export protocol:
|
||||
|
||||
| Value | Transport | Notes |
|
||||
|---|---|---|
|
||||
| `grpc` (default) | gRPC | Default port 4317 |
|
||||
| `http/protobuf` | HTTP | Default port 4318 |
|
||||
|
||||
**Endpoint format:** `telemetry.otlp_endpoint` expects a base URL in `host:port` or `http://host:port` format, without a path component. The SDK appends the signal path (e.g. `/v1/traces`) automatically per the [OTLP specification](https://opentelemetry.io/docs/specs/otlp/#otlphttp-request).
|
||||
|
||||
|
||||
## Contributing
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, coding guidelines, and how to submit pull requests.
|
||||
This project exists thanks to all the people who contribute. See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, coding guidelines, and how to submit pull requests.
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://star-history.com/#alibaba/open-code-review&Date)
|
||||
<a href="https://github.com/alibaba/open-code-review/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=alibaba/open-code-review" />
|
||||
</a>
|
||||
|
||||
## License
|
||||
|
||||
|
|
|
|||
116
README.ru-RU.md
116
README.ru-RU.md
|
|
@ -13,7 +13,6 @@
|
|||
<p align="center">
|
||||
<a href="https://www.npmjs.com/package/@alibaba-group/open-code-review"><img alt="npm" src="https://img.shields.io/npm/v/@alibaba-group/open-code-review?style=flat-square" /></a>
|
||||
<a href="https://github.com/alibaba/open-code-review/actions/workflows/release.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/alibaba/open-code-review/release.yml?style=flat-square" /></a>
|
||||
<a href="https://goreportcard.com/report/github.com/alibaba/open-code-review"><img alt="Go Report Card" src="https://goreportcard.com/badge/github.com/alibaba/open-code-review?style=flat-square" /></a>
|
||||
<a href="https://github.com/alibaba/open-code-review/blob/main/LICENSE"><img alt="License" src="https://img.shields.io/github/license/alibaba/open-code-review?style=flat-square" /></a>
|
||||
<a href="https://deepwiki.com/alibaba/open-code-review"><img alt="Ask DeepWiki" src="https://deepwiki.com/badge.svg" /></a>
|
||||
<a href="https://www.bestpractices.dev/projects/13328"><img alt="OpenSSF Best Practices" src="https://img.shields.io/badge/OpenSSF-Silver-4C566A?style=flat-square" /></a>
|
||||
|
|
@ -92,6 +91,10 @@ Open Code Review — это CLI-инструмент для код-ревью н
|
|||
|
||||
## Как использовать
|
||||
|
||||
### Предварительные требования
|
||||
|
||||
- **Git >= 2.41** — Open Code Review использует Git для генерации diff, поиска по коду и операций с репозиторием.
|
||||
|
||||
### CLI
|
||||
|
||||
#### Установка
|
||||
|
|
@ -104,6 +107,18 @@ npm install -g @alibaba-group/open-code-review
|
|||
|
||||
После установки команда `ocr` доступна глобально.
|
||||
|
||||
**Обновление**
|
||||
|
||||
Если установка выполнена через NPM, обновите вручную до последней версии:
|
||||
|
||||
```bash
|
||||
npm install -g @alibaba-group/open-code-review@latest
|
||||
```
|
||||
|
||||
Установка через NPM также по умолчанию проверяет новые версии в фоне и обновляется автоматически. Чтобы отключить автообновления, задайте `OCR_NO_UPDATE=1`.
|
||||
|
||||
Если вы устанавливали через install script или вручную скачанный бинарный файл, повторно запустите ту же команду установки/скачивания, чтобы заменить локальный бинарный файл последним релизом. Используйте `OCR_VERSION`, если нужно зафиксировать конкретный тег релиза.
|
||||
|
||||
**Из GitHub Release**
|
||||
|
||||
Установите свежий бинарный файл для вашей ОС/архитектуры одной командой (macOS / Linux):
|
||||
|
|
@ -265,6 +280,10 @@ ocr review --from main --to feature-branch
|
|||
# Один коммит
|
||||
ocr review --commit abc123
|
||||
|
||||
# Возобновить прерванное ревью диапазона или одного коммита
|
||||
ocr session list
|
||||
ocr review --from main --to feature-branch --resume <session-id>
|
||||
|
||||
# Полнофайловое сканирование — ревью целых файлов вместо диффа (история git не нужна)
|
||||
ocr scan # сканировать весь репозиторий
|
||||
ocr scan --path internal/agent # сканировать каталог или конкретные файлы
|
||||
|
|
@ -402,10 +421,35 @@ ocr review \
|
|||
|
||||
Флаг `--format json` выводит машиночитаемый результат, удобный для разбора в CI-скриптах.
|
||||
|
||||
Каждое замечание содержит два структурированных поля, чтобы CI-интеграции могли сортировать, группировать, фильтровать замечания или блокировать сборку без повторного разбора текста комментария:
|
||||
|
||||
| Поле | Допустимые значения | Примечание |
|
||||
|------|---------------------|------------|
|
||||
| `category` | `bug`, `security`, `performance`, `maintainability`, `test`, `style`, `documentation`, `other` | Категория, к которой относится замечание. |
|
||||
| `severity` | `critical`, `high`, `medium`, `low` | Важность замечания. |
|
||||
|
||||
В JSON-выводе эти два поля располагаются рядом с `content`, `start_line` и др. В терминале они отображаются перед комментарием как встроенный бейдж `[category · severity]`, цвет которого определяется важностью.
|
||||
|
||||
Примеры интеграции — в каталоге [`examples/`](./examples/):
|
||||
|
||||
- [`github_actions/`](./examples/github_actions/) — пример интеграции с GitHub Actions
|
||||
- [`gitlab_ci/`](./examples/gitlab_ci/) — пример интеграции с GitLab CI
|
||||
- [`gitflic_ci/`](./examples/gitflic_ci/) — пример интеграции с GitFlic CI
|
||||
|
||||
#### GitHub Action
|
||||
|
||||
Для GitHub в корне репозитория также поставляется готовая к использованию composite Action ([`action.yml`](./action.yml)). Вместо того чтобы вручную скриптовать `ocr review`, просто подключите её — она берёт на себя весь конвейер: checkout, установку OCR, запуск ревью, публикацию инлайн- и сводных комментариев, загрузку артефактов, а также повтор и идемпотентность:
|
||||
|
||||
```yaml
|
||||
- uses: alibaba/open-code-review@main
|
||||
with:
|
||||
llm_url: ${{ secrets.OCR_LLM_URL }}
|
||||
llm_auth_token: ${{ secrets.OCR_LLM_AUTH_TOKEN }}
|
||||
llm_model: ${{ vars.OCR_LLM_MODEL }}
|
||||
llm_use_anthropic: ${{ vars.OCR_LLM_USE_ANTHROPIC }}
|
||||
```
|
||||
|
||||
Для воспроизводимости зафиксируйте тег версии или SHA коммита. Полный демо-воркфлоу, а также полный список входов, выходов и режимов публикации комментариев (закреплённая сводка, инкрементальная неразрушающая публикация) см. в каталоге [`examples/github_actions/`](./examples/github_actions/).
|
||||
|
||||
## Команды
|
||||
|
||||
|
|
@ -420,6 +464,8 @@ ocr review \
|
|||
| `ocr config unset custom_providers.<name>` | — | Удалить пользовательского провайдера |
|
||||
| `ocr llm test` | — | Проверить подключение к LLM |
|
||||
| `ocr llm providers` | — | Показать список встроенных LLM-провайдеров |
|
||||
| `ocr session list` | `ocr sessions list`, `ocr session ls` | Показать сохранённые сессии ревью |
|
||||
| `ocr session show <id>` | `ocr sessions show <id>` | Показать одну сессию и её checkpoint'ы по файлам |
|
||||
| `ocr viewer` | `ocr v` | Запустить WebUI-просмотрщик сессий на `localhost:5483` |
|
||||
| `ocr version` | — | Показать информацию о версии |
|
||||
|
||||
|
|
@ -433,17 +479,56 @@ ocr review \
|
|||
| `--commit` | `-c` | — | Один коммит для ревью |
|
||||
| `--exclude` | — | — | Паттерны в стиле gitignore через запятую для пропуска файлов; объединяются с excludes из rule.json |
|
||||
| `--preview` | `-p` | `false` | Показать, какие файлы попадут в ревью, без запуска LLM |
|
||||
| `--resume` | — | — | Возобновить предыдущую совместимую сессию ревью диапазона или одного коммита |
|
||||
| `--format` | `-f` | `text` | Формат вывода: `text` или `json` |
|
||||
| `--concurrency` | — | `8` | Максимум одновременных ревью файлов |
|
||||
| `--timeout` | — | `10` | Таймаут конкурентной задачи в минутах |
|
||||
| `--audience` | — | `human` | `human` (показывать прогресс) или `agent` (только сводка) |
|
||||
| `--background` | `-b` | — | Необязательный контекст требований/бизнес-логики для ревью; при `--commit` автоматически заполняется из сообщения коммита |
|
||||
| `--background-file` | `-B` | — | Необязательный контекст требований/бизнес-логики из Markdown-файла; при совместном использовании с `--background` встроенное значение идёт первым |
|
||||
| `--model` | — | — | Выбрать или переопределить LLM-модель для этого ревью |
|
||||
| `--rule` | — | — | Путь к пользовательским JSON-правилам ревью |
|
||||
| `--max-tools` | — | встроенное | Максимум раундов вызова инструментов на файл; действует, только если больше значения шаблона по умолчанию |
|
||||
| `--max-git-procs` | — | встроенное | Максимум одновременных git-подпроцессов |
|
||||
| `--tools` | — | — | Путь к пользовательскому JSON-конфигу инструментов |
|
||||
|
||||
#### Возобновляемые ревью и сессии
|
||||
|
||||
Каждый запуск `ocr review` сохраняет локальный журнал сессии в
|
||||
`~/.opencodereview/sessions/`. Успешный текстовый вывод остаётся сфокусированным
|
||||
на результате ревью и не печатает session ID. Сохранённые сессии можно найти через
|
||||
`ocr session list/show`, а `--format json` добавляет `session_id` в машиночитаемый
|
||||
вывод. Если ревью диапазона или одного коммита было прервано, выберите сохранённую
|
||||
сессию с тем же целевым ревью и возобновите её:
|
||||
|
||||
```bash
|
||||
ocr session list
|
||||
ocr session show <session-id>
|
||||
ocr review --from main --to feature-branch --resume <session-id>
|
||||
ocr review --commit abc123 --resume <session-id>
|
||||
```
|
||||
|
||||
Возобновление намеренно строгое: поддерживаются только ревью диапазона веток и одного
|
||||
коммита, но не ревью рабочей копии. Текущие `--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 <id>` | `--repo` | текущий каталог | Репозиторий, сессию которого нужно посмотреть |
|
||||
| `ocr session show <id>` | `--json` | `false` | Вывести метаданные сессии и элементы по файлам в JSON |
|
||||
|
||||
### Флаги `ocr scan`
|
||||
|
||||
`ocr scan` проверяет целые файлы, а не дифф — удобно для аудита незнакомой кодовой базы, предмиграционного сканирования или любого каталога без значимого диффа. Работает и в каталогах без git (используется обход файловой системы с учётом `.gitignore`).
|
||||
|
|
@ -489,6 +574,12 @@ ocr review --from main --to my-feature --concurrency 4
|
|||
# Ревью конкретного коммита с подробным JSON-выводом
|
||||
ocr review --commit abc123 --format json --audience agent
|
||||
|
||||
# Возобновить прерванное ревью диапазона или одного коммита
|
||||
ocr session list
|
||||
ocr session show <session-id>
|
||||
ocr review --from main --to my-feature --resume <session-id>
|
||||
ocr review --commit abc123 --resume <session-id>
|
||||
|
||||
# Выбрать или переопределить модель для этого ревью
|
||||
ocr review --model claude-opus-4-6
|
||||
ocr review --commit abc123 --model claude-sonnet-4-6
|
||||
|
|
@ -496,6 +587,12 @@ ocr review --commit abc123 --model claude-sonnet-4-6
|
|||
# Передать контекст требований для более прицельного ревью
|
||||
ocr review --background "Добавляем rate limiting в API логина"
|
||||
|
||||
# Передать контекст требований из Markdown-файла
|
||||
ocr review --background-file ./docs/my_business_context.md
|
||||
|
||||
# Совместить встроенный контекст с локальным файлом контекста (используются оба)
|
||||
ocr review --background "Фокус на аутентификации" --background-file ./docs/my_business_context.md
|
||||
|
||||
# Использовать собственные правила ревью
|
||||
ocr review --rule /path/to/my-rules.json
|
||||
|
||||
|
|
@ -750,13 +847,22 @@ ocr config set telemetry.otlp_endpoint localhost:4317
|
|||
|
||||
Установите `telemetry.content_logging`, чтобы включать промпты и ответы LLM в экспортируемые данные.
|
||||
|
||||
**Выбор протокола:** Переменная окружения `OTEL_EXPORTER_OTLP_PROTOCOL` определяет протокол экспорта:
|
||||
|
||||
| Значение | Транспорт | Описание |
|
||||
|---|---|---|
|
||||
| `grpc` (по умолчанию) | gRPC | Порт по умолчанию 4317 |
|
||||
| `http/protobuf` | HTTP | Порт по умолчанию 4318 |
|
||||
|
||||
**Формат endpoint:** `telemetry.otlp_endpoint` принимает базовый URL в формате `host:port` или `http://host:port` без компонента пути. SDK автоматически добавляет путь сигнала (например, `/v1/traces`) в соответствии со [спецификацией OTLP](https://opentelemetry.io/docs/specs/otlp/#otlphttp-request).
|
||||
|
||||
## Участие в разработке
|
||||
|
||||
В [CONTRIBUTING.ru-RU.md](CONTRIBUTING.ru-RU.md) описаны настройка окружения разработки, рекомендации по коду и порядок отправки pull request'ов.
|
||||
Этот проект существует благодаря всем, кто вносит свой вклад. В [CONTRIBUTING.ru-RU.md](CONTRIBUTING.ru-RU.md) описаны настройка окружения разработки, рекомендации по коду и порядок отправки pull request'ов.
|
||||
|
||||
## История звёзд
|
||||
|
||||
[](https://star-history.com/#alibaba/open-code-review&Date)
|
||||
<a href="https://github.com/alibaba/open-code-review/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=alibaba/open-code-review" />
|
||||
</a>
|
||||
|
||||
## Лицензия
|
||||
|
||||
|
|
|
|||
113
README.zh-CN.md
113
README.zh-CN.md
|
|
@ -13,7 +13,6 @@
|
|||
<p align="center">
|
||||
<a href="https://www.npmjs.com/package/@alibaba-group/open-code-review"><img alt="npm" src="https://img.shields.io/npm/v/@alibaba-group/open-code-review?style=flat-square" /></a>
|
||||
<a href="https://github.com/alibaba/open-code-review/actions/workflows/release.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/alibaba/open-code-review/release.yml?style=flat-square" /></a>
|
||||
<a href="https://goreportcard.com/report/github.com/alibaba/open-code-review"><img alt="Go Report Card" src="https://goreportcard.com/badge/github.com/alibaba/open-code-review?style=flat-square" /></a>
|
||||
<a href="https://github.com/alibaba/open-code-review/blob/main/LICENSE"><img alt="License" src="https://img.shields.io/github/license/alibaba/open-code-review?style=flat-square" /></a>
|
||||
<a href="https://deepwiki.com/alibaba/open-code-review"><img alt="Ask DeepWiki" src="https://deepwiki.com/badge.svg" /></a>
|
||||
<a href="https://www.bestpractices.dev/projects/13328"><img alt="OpenSSF Best Practices" src="https://img.shields.io/badge/OpenSSF-Silver-4C566A?style=flat-square" /></a>
|
||||
|
|
@ -92,6 +91,10 @@ Open Code Review 的核心设计理念是将确定性工程与 Agent 结合,
|
|||
|
||||
## 如何使用
|
||||
|
||||
### 前置条件
|
||||
|
||||
- **Git >= 2.41** — Open Code Review 依赖 Git 进行 diff 生成、代码搜索和仓库操作。
|
||||
|
||||
### CLI
|
||||
|
||||
#### 安装
|
||||
|
|
@ -104,6 +107,18 @@ npm install -g @alibaba-group/open-code-review
|
|||
|
||||
安装后,`ocr` 命令即可全局使用。
|
||||
|
||||
**更新**
|
||||
|
||||
如果通过 NPM 安装,可手动更新到最新版本:
|
||||
|
||||
```bash
|
||||
npm install -g @alibaba-group/open-code-review@latest
|
||||
```
|
||||
|
||||
通过 NPM 安装的 `ocr` 还会默认在后台检查新版本并自动升级;如需关闭自动更新,可设置 `OCR_NO_UPDATE=1`。
|
||||
|
||||
如果通过安装脚本或手动下载二进制文件安装,重新运行对应的安装/下载命令即可替换为最新 release。需要固定版本时,可继续通过 `OCR_VERSION` 指定 release tag。
|
||||
|
||||
**从 GitHub Release 下载**
|
||||
|
||||
使用一条命令为你的操作系统/架构安装最新二进制文件(macOS / Linux):
|
||||
|
|
@ -265,6 +280,10 @@ ocr review --from main --to feature-branch
|
|||
# 单个提交
|
||||
ocr review --commit abc123
|
||||
|
||||
# 恢复中断的区间或单 commit 评审
|
||||
ocr session list
|
||||
ocr review --from main --to feature-branch --resume <session-id>
|
||||
|
||||
# 全量文件扫描 —— 审查整个文件而非 diff(无需 git 历史)
|
||||
ocr scan # 扫描整个仓库
|
||||
ocr scan --path internal/agent # 扫描指定目录或文件
|
||||
|
|
@ -400,10 +419,35 @@ ocr review \
|
|||
|
||||
`--format json` 参数输出适合 CI 脚本解析的机器可读结果。
|
||||
|
||||
每条评审结果都带有两个结构化字段,便于 CI 集成在无需解析评论文本的情况下排序、分组、过滤或卡点构建:
|
||||
|
||||
| 字段 | 允许的取值 | 说明 |
|
||||
|------|-----------|------|
|
||||
| `category` | `bug`、`security`、`performance`、`maintainability`、`test`、`style`、`documentation`、`other` | 问题所属的类别。 |
|
||||
| `severity` | `critical`、`high`、`medium`、`low` | 问题的严重程度。 |
|
||||
|
||||
在 JSON 输出中,这两个字段与 `content`、`start_line` 等平级;在终端中,它们会以内联的 `[category · severity]` 徽章形式显示在评论前,并按严重程度着色。
|
||||
|
||||
集成示例请参见 [`examples/`](./examples/) 目录:
|
||||
|
||||
- [`github_actions/`](./examples/github_actions/) — GitHub Actions 集成示例
|
||||
- [`gitlab_ci/`](./examples/gitlab_ci/) — GitLab CI 集成示例
|
||||
- [`gitflic_ci/`](./examples/gitflic_ci/) — GitFlic CI 集成示例
|
||||
|
||||
#### GitHub Action
|
||||
|
||||
对于 GitHub,本仓库还在仓库根目录提供了一个开箱即用的 composite Action([`action.yml`](./action.yml))。你无需自己编写 `ocr review` 脚本,直接引用它即可完成完整流程——checkout、安装 OCR、执行审查、发布行内评论与汇总评论、上传 artifacts,以及重试与幂等处理:
|
||||
|
||||
```yaml
|
||||
- uses: alibaba/open-code-review@main
|
||||
with:
|
||||
llm_url: ${{ secrets.OCR_LLM_URL }}
|
||||
llm_auth_token: ${{ secrets.OCR_LLM_AUTH_TOKEN }}
|
||||
llm_model: ${{ vars.OCR_LLM_MODEL }}
|
||||
llm_use_anthropic: ${{ vars.OCR_LLM_USE_ANTHROPIC }}
|
||||
```
|
||||
|
||||
为保障可复现性,请固定到某个版本标签或 commit SHA。完整的 workflow 示例以及 inputs、outputs 与评论发布模式(置顶汇总、增量非破坏式发布)的完整列表,请参见 [`examples/github_actions/`](./examples/github_actions/) 目录。
|
||||
|
||||
## 命令
|
||||
|
||||
|
|
@ -418,6 +462,8 @@ ocr review \
|
|||
| `ocr config unset custom_providers.<name>` | — | 删除自定义供应商 |
|
||||
| `ocr llm test` | — | 测试 LLM 连通性 |
|
||||
| `ocr llm providers` | — | 列出内置 LLM 供应商 |
|
||||
| `ocr session list` | `ocr sessions list`, `ocr session ls` | 列出已保存的评审会话 |
|
||||
| `ocr session show <id>` | `ocr sessions show <id>` | 查看单个会话及其逐文件检查点 |
|
||||
| `ocr viewer` | `ocr v` | 启动 WebUI 会话查看器,地址 `localhost:5483` |
|
||||
| `ocr version` | — | 显示版本信息 |
|
||||
|
||||
|
|
@ -431,17 +477,53 @@ ocr review \
|
|||
| `--commit` | `-c` | — | 审查单个提交 |
|
||||
| `--exclude` | — | — | 以逗号分隔的 gitignore 风格模式,用于跳过匹配文件;与 rule.json 中的 excludes 合并 |
|
||||
| `--preview` | `-p` | `false` | 预览将被审查的文件列表,不调用 LLM |
|
||||
| `--resume` | — | — | 从之前兼容的区间或单 commit 评审会话恢复 |
|
||||
| `--format` | `-f` | `text` | 输出格式:`text` 或 `json` |
|
||||
| `--concurrency` | — | `8` | 最大并发文件审查数 |
|
||||
| `--timeout` | — | `10` | 并发任务超时时间(分钟) |
|
||||
| `--audience` | — | `human` | `human`(显示进度)或 `agent`(仅输出摘要) |
|
||||
| `--background` | `-b` | — | 可选的需求/业务背景信息;使用 `--commit` 时如未指定则自动从 commit message 中提取 |
|
||||
| `--background-file` | `-B` | — | 来自 Markdown 文件的可选需求/业务背景信息;与 `--background` 同时使用时,内联内容排在前面 |
|
||||
| `--model` | — | — | 为本次审查选择或覆盖 LLM 模型 |
|
||||
| `--rule` | — | — | 自定义 JSON 审查规则路径 |
|
||||
| `--max-tools` | — | 内置默认 | 每个文件的最大工具调用轮次;仅在大于模板默认值时生效 |
|
||||
| `--max-git-procs` | — | 内置默认 | 最大并发 git 子进程数 |
|
||||
| `--tools` | — | — | 自定义 JSON 工具配置路径 |
|
||||
|
||||
#### 可恢复评审与会话
|
||||
|
||||
每次 `ocr review` 都会在 `~/.opencodereview/sessions/` 下保存本地会话日志。
|
||||
正常完成的文本输出只展示评审结果,不打印 session ID;可使用
|
||||
`ocr session list/show` 查找已保存会话,或用 `--format json` 在机器可读输出中获取
|
||||
`session_id`。如果区间或单 commit 评审被中断,可列出保存的会话,并从匹配相同评审目标的会话恢复:
|
||||
|
||||
```bash
|
||||
ocr session list
|
||||
ocr session show <session-id>
|
||||
ocr review --from main --to feature-branch --resume <session-id>
|
||||
ocr review --commit abc123 --resume <session-id>
|
||||
```
|
||||
|
||||
恢复逻辑是严格的:仅支持分支区间和单 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 <id>` | `--repo` | 当前目录 | 要查看会话的仓库 |
|
||||
| `ocr session show <id>` | `--json` | `false` | 以 JSON 输出会话元数据和逐文件条目 |
|
||||
|
||||
### `ocr scan` 参数
|
||||
|
||||
`ocr scan` 审查整个文件而非 diff —— 适用于审计不熟悉的代码库、迁移前扫描,或任何没有有意义 diff 的目录。它也可以在非 git 目录中工作(会回退到遵循 `.gitignore` 的文件系统遍历)。
|
||||
|
|
@ -487,6 +569,12 @@ ocr review --from main --to my-feature --concurrency 4
|
|||
# 审查特定提交并以 JSON 格式输出详细信息
|
||||
ocr review --commit abc123 --format json --audience agent
|
||||
|
||||
# 恢复中断的区间或单 commit 评审
|
||||
ocr session list
|
||||
ocr session show <session-id>
|
||||
ocr review --from main --to my-feature --resume <session-id>
|
||||
ocr review --commit abc123 --resume <session-id>
|
||||
|
||||
# 为本次审查选择或覆盖模型
|
||||
ocr review --model claude-opus-4-6
|
||||
ocr review --commit abc123 --model claude-sonnet-4-6
|
||||
|
|
@ -494,6 +582,12 @@ ocr review --commit abc123 --model claude-sonnet-4-6
|
|||
# 提供需求背景以获得更有针对性的审查
|
||||
ocr review --background "为登录 API 添加限流"
|
||||
|
||||
# 从 Markdown 文件提供需求背景
|
||||
ocr review --background-file ./docs/my_business_context.md
|
||||
|
||||
# 将内联背景与本地背景文件结合使用(两者都会生效)
|
||||
ocr review --background "关注鉴权" --background-file ./docs/my_business_context.md
|
||||
|
||||
# 使用自定义审查规则
|
||||
ocr review --rule /path/to/my-rules.json
|
||||
|
||||
|
|
@ -738,13 +832,22 @@ ocr config set telemetry.otlp_endpoint localhost:4317
|
|||
|
||||
设置 `telemetry.content_logging` 可在导出数据中包含 LLM 提示词和响应。
|
||||
|
||||
**协议选择:** 通过环境变量 `OTEL_EXPORTER_OTLP_PROTOCOL` 选择导出协议:
|
||||
|
||||
| 值 | 传输方式 | 说明 |
|
||||
|---|---|---|
|
||||
| `grpc`(默认) | gRPC | 默认端口 4317 |
|
||||
| `http/protobuf` | HTTP | 默认端口 4318 |
|
||||
|
||||
**Endpoint 格式:** `telemetry.otlp_endpoint` 的值为 `host:port` 或 `http://host:port`,无需包含路径。SDK 会根据 [OTLP 规范](https://opentelemetry.io/docs/specs/otlp/#otlphttp-request)自动追加信号路径(如 `/v1/traces`)。
|
||||
|
||||
## 贡献
|
||||
|
||||
参见 [CONTRIBUTING.zh-CN.md](CONTRIBUTING.zh-CN.md) 了解开发环境搭建、编码规范以及如何提交 Pull Request。
|
||||
感谢所有为本项目做出贡献的人。参见 [CONTRIBUTING.zh-CN.md](CONTRIBUTING.zh-CN.md) 了解开发环境搭建、编码规范以及如何提交 Pull Request。
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://star-history.com/#alibaba/open-code-review&Date)
|
||||
<a href="https://github.com/alibaba/open-code-review/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=alibaba/open-code-review" />
|
||||
</a>
|
||||
|
||||
## 许可证
|
||||
|
||||
|
|
|
|||
17
ROADMAP.md
17
ROADMAP.md
|
|
@ -19,6 +19,9 @@ OpenCodeReview currently provides:
|
|||
- CI/CD integration (GitHub Actions, GitLab CI, etc.).
|
||||
- Multi-provider LLM support (OpenAI-compatible, Anthropic, Google Gemini,
|
||||
Amazon Bedrock, Azure OpenAI, etc.).
|
||||
- MCP server — expose OpenCodeReview over the
|
||||
[Model Context Protocol](https://modelcontextprotocol.io/) so review
|
||||
capabilities can be invoked from any MCP-compatible client.
|
||||
- Review rules engine with per-file pattern matching.
|
||||
- Multi-language documentation (English, Chinese, Japanese, Korean, Russian).
|
||||
|
||||
|
|
@ -30,13 +33,15 @@ OpenCodeReview currently provides:
|
|||
PyCharm, and other JetBrains IDEs with the same capabilities as the
|
||||
existing VSCode extension.
|
||||
|
||||
### MCP Integration
|
||||
### Delegate Mode
|
||||
|
||||
- **Standard MCP server** — Expose OpenCodeReview as a
|
||||
[Model Context Protocol](https://modelcontextprotocol.io/) server,
|
||||
allowing users to integrate external context tools (documentation
|
||||
retrieval, issue trackers, internal knowledge bases) into the review
|
||||
process through the standard MCP interface.
|
||||
- **Subscription-friendly review** — An opt-in mode where `ocr` no longer
|
||||
depends on a separately-configured LLM endpoint. Instead of calling an
|
||||
LLM itself, `ocr` resolves the review scope, applies excludes, loads
|
||||
review rules, injects background context, and collects the diffs, then
|
||||
hands that off as a structured review task for the host coding agent
|
||||
(e.g. Claude Code) to execute using its own agent loop and included
|
||||
subscription usage — removing the need for a standalone API key.
|
||||
|
||||
### Ultra Mode
|
||||
|
||||
|
|
|
|||
305
action.yml
Normal file
305
action.yml
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
name: OpenCodeReview PR Review
|
||||
description: >-
|
||||
AI-powered GitHub PR review with inline comments, sticky summary, and
|
||||
incremental non-destructive posting.
|
||||
author: alibaba
|
||||
branding:
|
||||
icon: eye
|
||||
color: green
|
||||
|
||||
inputs:
|
||||
llm_url:
|
||||
description: LLM API endpoint URL (mapped to env OCR_LLM_URL).
|
||||
required: true
|
||||
llm_auth_token:
|
||||
description: LLM auth token (mapped to env OCR_LLM_TOKEN).
|
||||
required: true
|
||||
llm_model:
|
||||
description: Model name (mapped to env OCR_LLM_MODEL).
|
||||
required: true
|
||||
llm_use_anthropic:
|
||||
description: "'true' for Anthropic Claude, 'false' for OpenAI-compatible APIs
|
||||
(mapped to env OCR_USE_ANTHROPIC). Required to force an explicit choice."
|
||||
required: true
|
||||
llm_auth_header:
|
||||
description: Custom auth header name (mapped to env OCR_LLM_AUTH_HEADER).
|
||||
required: false
|
||||
llm_extra_headers:
|
||||
description: Extra headers "K=V,K=V" (mapped to env OCR_LLM_EXTRA_HEADERS).
|
||||
required: false
|
||||
llm_extra_body:
|
||||
description: >-
|
||||
extra_body JSON for LLM requests. No env var exists for this, so it is
|
||||
written via `ocr config set llm.extra_body`.
|
||||
required: false
|
||||
default: '{"thinking": {"type": "disabled"}}'
|
||||
language:
|
||||
description: >-
|
||||
Review output language, written via `ocr config set language`
|
||||
(e.g. English, 中文). No env var exists for this.
|
||||
required: false
|
||||
default: 'English'
|
||||
llm_timeout:
|
||||
description: LLM request timeout in seconds (mapped to env OCR_LLM_TIMEOUT).
|
||||
required: false
|
||||
github_token:
|
||||
description: GitHub token used to post review comments.
|
||||
required: false
|
||||
default: ${{ github.token }}
|
||||
ocr_version:
|
||||
description: npm version spec for @alibaba-group/open-code-review.
|
||||
required: false
|
||||
default: latest
|
||||
review_concurrency:
|
||||
description: Value passed to `ocr review --concurrency`.
|
||||
required: false
|
||||
background:
|
||||
description: Value passed to `ocr review --background`.
|
||||
required: false
|
||||
rule:
|
||||
description: Path to a custom rules JSON file passed to `ocr review --rule`.
|
||||
required: false
|
||||
upload_artifacts:
|
||||
description: >-
|
||||
Upload raw JSON result and stderr as workflow artifacts. Must be the
|
||||
literal string 'true' or 'false' (quoted); the step gates on a string
|
||||
comparison, so an unquoted YAML boolean will not match.
|
||||
required: false
|
||||
default: 'true'
|
||||
sticky_summary:
|
||||
description: >-
|
||||
Summary dimension. true = update an existing summary comment in place
|
||||
(sticky) instead of posting a new one each run.
|
||||
required: false
|
||||
default: 'true'
|
||||
incremental:
|
||||
description: >-
|
||||
Incremental dimension. true = only append inline comments whose (path,
|
||||
line range) does not overlap an existing bot review comment. History is
|
||||
never deleted (non-destructive).
|
||||
required: false
|
||||
default: 'false'
|
||||
incremental_overlap_threshold:
|
||||
description: >-
|
||||
IoU (intersection-over-union) threshold used by incremental mode to decide
|
||||
whether a new multi-line comment overlaps an existing one. Two single-line
|
||||
comments match when on the same line; single- vs multi-line never match.
|
||||
Value in (0, 1]; ignored unless incremental is true.
|
||||
required: false
|
||||
default: '0.6'
|
||||
base_ref:
|
||||
description: >-
|
||||
Override the base ref. Provide this (and head_sha) when invoking from a
|
||||
non-PR event such as issue_comment.
|
||||
required: false
|
||||
head_sha:
|
||||
description: Override the head commit SHA (use with base_ref for comment triggers).
|
||||
required: false
|
||||
node_version:
|
||||
description: Node.js version for actions/setup-node.
|
||||
required: false
|
||||
default: '24'
|
||||
|
||||
outputs:
|
||||
comments_total:
|
||||
description: Total number of review comments generated by OCR.
|
||||
value: ${{ steps.post.outputs.comments_total }}
|
||||
comments_inline:
|
||||
description: Number of inline comments successfully posted.
|
||||
value: ${{ steps.post.outputs.comments_inline }}
|
||||
comments_skipped:
|
||||
description: Number of inline comments skipped by incremental mode (overlap with history).
|
||||
value: ${{ steps.post.outputs.comments_skipped }}
|
||||
comments_failed:
|
||||
description: Number of inline comments that failed to post.
|
||||
value: ${{ steps.post.outputs.comments_failed }}
|
||||
summary_comment_url:
|
||||
description: URL of the posted/updated summary comment, if any.
|
||||
value: ${{ steps.post.outputs.summary_comment_url }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Check git and Node.js
|
||||
id: check_deps
|
||||
shell: bash
|
||||
run: |
|
||||
if command -v git >/dev/null 2>&1; then
|
||||
echo "git_installed=true" >> "$GITHUB_OUTPUT"
|
||||
echo "git is already installed: $(git --version)"
|
||||
else
|
||||
echo "git_installed=false" >> "$GITHUB_OUTPUT"
|
||||
echo "git is not installed"
|
||||
fi
|
||||
if command -v node >/dev/null 2>&1; then
|
||||
echo "node_installed=true" >> "$GITHUB_OUTPUT"
|
||||
echo "node is already installed: $(node --version)"
|
||||
else
|
||||
echo "node_installed=false" >> "$GITHUB_OUTPUT"
|
||||
echo "node is not installed"
|
||||
fi
|
||||
|
||||
- name: Install git
|
||||
if: steps.check_deps.outputs.git_installed != 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y git
|
||||
elif command -v brew >/dev/null 2>&1; then
|
||||
brew install git
|
||||
elif command -v yum >/dev/null 2>&1; then
|
||||
sudo yum install -y git
|
||||
elif command -v apk >/dev/null 2>&1; then
|
||||
sudo apk add --no-cache git
|
||||
else
|
||||
echo "::error::Unable to install git: no supported package manager found"
|
||||
exit 1
|
||||
fi
|
||||
git --version
|
||||
|
||||
- name: Setup Node.js
|
||||
if: steps.check_deps.outputs.node_installed != 'true'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ inputs.node_version }}
|
||||
|
||||
- name: Resolve PR refs
|
||||
shell: bash
|
||||
env:
|
||||
INPUT_BASE_REF: ${{ inputs.base_ref }}
|
||||
INPUT_HEAD_SHA: ${{ inputs.head_sha }}
|
||||
EVENT_BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||
EVENT_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
BASE_REF="${INPUT_BASE_REF:-$EVENT_BASE_REF}"
|
||||
HEAD_SHA="${INPUT_HEAD_SHA:-$EVENT_HEAD_SHA}"
|
||||
echo "BASE_REF=$BASE_REF" >> "$GITHUB_ENV"
|
||||
echo "HEAD_SHA=$HEAD_SHA" >> "$GITHUB_ENV"
|
||||
echo "PR base ref: $BASE_REF"
|
||||
echo "PR head sha: $HEAD_SHA"
|
||||
|
||||
- name: Checkout base
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# Checkout the trusted base, not the PR head. OCR reviews the
|
||||
# base-to-head diff from git objects; the head commit's blobs are
|
||||
# fetched separately so they are resolvable without materializing
|
||||
# untrusted PR files into the working tree.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Fetch PR head (fork-safe)
|
||||
if: env.HEAD_SHA != ''
|
||||
shell: bash
|
||||
env:
|
||||
PR_NUM: ${{ github.event.pull_request.number || github.event.issue.number }}
|
||||
run: |
|
||||
if [ -n "$PR_NUM" ]; then
|
||||
git fetch origin "pull/${PR_NUM}/head"
|
||||
fi
|
||||
|
||||
- name: Compute merge-base
|
||||
shell: bash
|
||||
run: |
|
||||
git fetch origin "${BASE_REF}" 2>/dev/null || true
|
||||
MERGE_BASE=$(git merge-base "origin/${BASE_REF}" "${HEAD_SHA}" 2>/dev/null || echo "${HEAD_SHA}")
|
||||
echo "MERGE_BASE=$MERGE_BASE" >> "$GITHUB_ENV"
|
||||
echo "Reviewing ${HEAD_SHA} from merge-base ${MERGE_BASE} (base origin/${BASE_REF})"
|
||||
|
||||
- name: Install OpenCodeReview
|
||||
shell: bash
|
||||
env:
|
||||
OCR_VERSION: ${{ inputs.ocr_version }}
|
||||
run: |
|
||||
npm install -g "@alibaba-group/open-code-review@${OCR_VERSION}"
|
||||
echo "OpenCodeReview installed:"
|
||||
ocr version || true
|
||||
|
||||
- name: Configure OCR
|
||||
env:
|
||||
OCR_EXTRA_BODY: ${{ inputs.llm_extra_body }}
|
||||
OCR_LANGUAGE: ${{ inputs.language }}
|
||||
shell: bash
|
||||
run: |
|
||||
ocr config set llm.extra_body "$OCR_EXTRA_BODY"
|
||||
ocr config set language "$OCR_LANGUAGE"
|
||||
|
||||
- name: Run OpenCodeReview
|
||||
env:
|
||||
OCR_LLM_URL: ${{ inputs.llm_url }}
|
||||
OCR_LLM_TOKEN: ${{ inputs.llm_auth_token }}
|
||||
OCR_LLM_MODEL: ${{ inputs.llm_model }}
|
||||
OCR_USE_ANTHROPIC: ${{ inputs.llm_use_anthropic }}
|
||||
OCR_LLM_AUTH_HEADER: ${{ inputs.llm_auth_header }}
|
||||
OCR_LLM_EXTRA_HEADERS: ${{ inputs.llm_extra_headers }}
|
||||
OCR_LLM_TIMEOUT: ${{ inputs.llm_timeout }}
|
||||
OCR_REVIEW_CONCURRENCY: ${{ inputs.review_concurrency }}
|
||||
OCR_BACKGROUND: ${{ inputs.background }}
|
||||
OCR_RULE: ${{ inputs.rule }}
|
||||
shell: bash
|
||||
run: |
|
||||
ARGS=(--from "${MERGE_BASE}" --to "${HEAD_SHA}" --format json)
|
||||
[ -n "$OCR_REVIEW_CONCURRENCY" ] && ARGS+=(--concurrency "$OCR_REVIEW_CONCURRENCY")
|
||||
[ -n "$OCR_BACKGROUND" ] && ARGS+=(--background "$OCR_BACKGROUND")
|
||||
[ -n "$OCR_RULE" ] && ARGS+=(--rule "$OCR_RULE")
|
||||
set +e
|
||||
ocr review "${ARGS[@]}" > /tmp/ocr-result.json 2>/tmp/ocr-stderr.log
|
||||
OCR_EXIT_CODE=$?
|
||||
set -e
|
||||
echo "OCR_EXIT_CODE=$OCR_EXIT_CODE" >> "$GITHUB_ENV"
|
||||
echo "=== OCR result ==="
|
||||
cat /tmp/ocr-result.json
|
||||
echo "=== OCR stderr ==="
|
||||
cat /tmp/ocr-stderr.log
|
||||
|
||||
- name: Upload review artifacts
|
||||
if: ${{ always() && inputs.upload_artifacts == 'true' }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ocr-review-result-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: |
|
||||
/tmp/ocr-result.json
|
||||
/tmp/ocr-stderr.log
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Fail job on OCR error
|
||||
if: env.OCR_EXIT_CODE != '0'
|
||||
shell: bash
|
||||
run: |
|
||||
echo "ocr review exited with code ${OCR_EXIT_CODE}; see uploaded artifacts for details."
|
||||
exit "${OCR_EXIT_CODE}"
|
||||
|
||||
- name: Post review comments
|
||||
if: env.OCR_EXIT_CODE == '0'
|
||||
id: post
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
OCR_INCREMENTAL_OVERLAP_THRESHOLD: ${{ inputs.incremental_overlap_threshold }}
|
||||
with:
|
||||
github-token: ${{ inputs.github_token }}
|
||||
script: |
|
||||
// Locate the helper shipped alongside action.yml at runtime.
|
||||
// GITHUB_ACTION_PATH: correct for published (remote) actions and for
|
||||
// local actions when not running in a container.
|
||||
// GITHUB_WORKSPACE: correct for local `uses: ./` actions — under
|
||||
// self-hosted + container setups, GITHUB_ACTION_PATH points to the
|
||||
// host path (invisible inside the container), whereas GITHUB_WORKSPACE
|
||||
// is correctly mapped to /__w.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const REL = 'scripts/github-actions/post-review-comments.js';
|
||||
const roots = [process.env.GITHUB_ACTION_PATH, process.env.GITHUB_WORKSPACE].filter(Boolean);
|
||||
const helper = roots.map(r => path.resolve(r, REL)).find(p => fs.existsSync(p));
|
||||
if (!helper) throw new Error(`Could not locate ${REL}; searched roots: ${roots.join(', ')}`);
|
||||
const { runPostReviewComments } = require(helper);
|
||||
await runPostReviewComments({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
fs,
|
||||
resultPath: '/tmp/ocr-result.json',
|
||||
stderrPath: '/tmp/ocr-stderr.log',
|
||||
stickySummary: ${{ inputs.sticky_summary == 'true' }},
|
||||
incremental: ${{ inputs.incremental == 'true' }},
|
||||
incrementalOverlapThreshold: parseFloat(process.env.OCR_INCREMENTAL_OVERLAP_THRESHOLD),
|
||||
});
|
||||
135
cmd/opencodereview/background_file.go
Normal file
135
cmd/opencodereview/background_file.go
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
const (
|
||||
backgroundSoftLimit = 2000
|
||||
backgroundHardLimit = 8000
|
||||
backgroundOpenTag = "<ocr_user_background>"
|
||||
backgroundCloseTag = "</ocr_user_background>"
|
||||
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)
|
||||
}
|
||||
312
cmd/opencodereview/background_file_test.go
Normal file
312
cmd/opencodereview/background_file_test.go
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// writeTempFile writes content to a temporary file and returns its path.
|
||||
func writeTempFile(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "background.md")
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("write temp file: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestLoadBackgroundFileNotFound(t *testing.T) {
|
||||
_, err := loadBackgroundFile(filepath.Join(t.TempDir(), "does-not-exist.md"))
|
||||
if err == nil {
|
||||
t.Fatal("expected an error for a missing file, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadBackgroundFileEmpty(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"zero bytes": "",
|
||||
"whitespace only": " \n\t \n ",
|
||||
"invisible only": "\u200B\u200E\u00AD\uFEFF",
|
||||
}
|
||||
for name, content := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, err := loadBackgroundFile(writeTempFile(t, content))
|
||||
if err == nil {
|
||||
t.Fatal("expected an error for empty-after-sanitisation content, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "empty") {
|
||||
t.Errorf("error = %q, want it to mention 'empty'", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadBackgroundFileControlCharRemoval(t *testing.T) {
|
||||
// Mix in NUL, bell, DEL, a C1 control char, zero-width space, BOM and an
|
||||
// LTR mark around legitimate text.
|
||||
content := "Hello\x00\x07world\x7f\u0085!\u200B\uFEFF\u200E"
|
||||
got, err := loadBackgroundFile(writeTempFile(t, content))
|
||||
if err != nil {
|
||||
t.Fatalf("loadBackgroundFile: %v", err)
|
||||
}
|
||||
for _, bad := range []string{"\x00", "\x07", "\x7f", "\u0085", "\u200B", "\uFEFF", "\u200E"} {
|
||||
if strings.Contains(got, bad) {
|
||||
t.Errorf("result still contains control/invisible char %q: %q", bad, got)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(got, "Helloworld!") {
|
||||
t.Errorf("expected cleaned text to contain %q, got %q", "Helloworld!", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeMarkdownPreservesNewlinesAndTabs(t *testing.T) {
|
||||
got := sanitizeMarkdown("line1\n\tindented\nline3")
|
||||
want := "line1\n\tindented\nline3"
|
||||
if got != want {
|
||||
t.Errorf("sanitizeMarkdown = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeMarkdownCollapsesNewlines(t *testing.T) {
|
||||
got := sanitizeMarkdown("a\n\n\n\n\nb")
|
||||
want := "a\n\nb"
|
||||
if got != want {
|
||||
t.Errorf("sanitizeMarkdown = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeMarkdownNormalizesCRLF(t *testing.T) {
|
||||
got := sanitizeMarkdown("a\r\nb\r\nc")
|
||||
want := "a\nb\nc"
|
||||
if got != want {
|
||||
t.Errorf("sanitizeMarkdown = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeMarkdownTrims(t *testing.T) {
|
||||
got := sanitizeMarkdown(" \n hello \n ")
|
||||
if got != "hello" {
|
||||
t.Errorf("sanitizeMarkdown = %q, want %q", got, "hello")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadBackgroundFileDelimiters(t *testing.T) {
|
||||
got, err := loadBackgroundFile(writeTempFile(t, "Some requirement context."))
|
||||
if err != nil {
|
||||
t.Fatalf("loadBackgroundFile: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(got, backgroundOpenTag+"\n") {
|
||||
t.Errorf("result missing opening delimiter: %q", got)
|
||||
}
|
||||
if !strings.HasSuffix(got, "\n"+backgroundCloseTag) {
|
||||
t.Errorf("result missing closing delimiter: %q", got)
|
||||
}
|
||||
want := backgroundOpenTag + "\nSome requirement context.\n" + backgroundCloseTag
|
||||
if got != want {
|
||||
t.Errorf("result = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadBackgroundFileRejectsReservedDelimiters(t *testing.T) {
|
||||
for _, tag := range []string{backgroundOpenTag, backgroundCloseTag} {
|
||||
t.Run(tag, func(t *testing.T) {
|
||||
content := "Some context " + tag + " and more text."
|
||||
_, err := loadBackgroundFile(writeTempFile(t, content))
|
||||
if err == nil {
|
||||
t.Fatalf("expected an error for content containing %q, got nil", tag)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "reserved delimiters") {
|
||||
t.Errorf("error = %q, want it to mention 'reserved delimiters'", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeBackgroundSanitizesInline(t *testing.T) {
|
||||
t.Run("inline only", func(t *testing.T) {
|
||||
// Control char, zero-width space and surrounding whitespace must be removed.
|
||||
got := mergeBackground(" \x00Inline\u200B context ", "")
|
||||
if got != "Inline context" {
|
||||
t.Errorf("mergeBackground = %q, want %q", got, "Inline context")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("inline combined with file", func(t *testing.T) {
|
||||
wrapped := backgroundOpenTag + "\nfrom file\n" + backgroundCloseTag
|
||||
got := mergeBackground("\x07dirty\uFEFF inline\n\n\n\nend", wrapped)
|
||||
if strings.ContainsRune(got, '\x07') || strings.ContainsRune(got, '\uFEFF') {
|
||||
t.Errorf("inline portion was not sanitised: %q", got)
|
||||
}
|
||||
// Excess blank lines in the inline portion are collapsed to one.
|
||||
if strings.Contains(got, "\n\n\n") {
|
||||
t.Errorf("inline newlines were not collapsed: %q", got)
|
||||
}
|
||||
// The file portion is preserved intact.
|
||||
if !strings.Contains(got, wrapped) {
|
||||
t.Errorf("file portion was altered: %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestMergeBackground(t *testing.T) {
|
||||
wrapped := backgroundOpenTag + "\nfrom file\n" + backgroundCloseTag
|
||||
|
||||
t.Run("both present are combined", func(t *testing.T) {
|
||||
got := mergeBackground("inline context", wrapped)
|
||||
want := "inline context\n\n" + wrapped
|
||||
if got != want {
|
||||
t.Errorf("mergeBackground = %q, want %q", got, want)
|
||||
}
|
||||
// Both inputs must survive in the result.
|
||||
if !strings.Contains(got, "inline context") || !strings.Contains(got, "from file") {
|
||||
t.Errorf("merged background dropped one of the inputs: %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("inline only", func(t *testing.T) {
|
||||
if got := mergeBackground("inline only", ""); got != "inline only" {
|
||||
t.Errorf("mergeBackground = %q, want %q", got, "inline only")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("file only", func(t *testing.T) {
|
||||
if got := mergeBackground("", wrapped); got != wrapped {
|
||||
t.Errorf("mergeBackground = %q, want %q", got, wrapped)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestLoadBackgroundFileSoftLimit(t *testing.T) {
|
||||
// Just above the soft limit but below the hard size limit: must succeed.
|
||||
content := strings.Repeat("a", backgroundSoftLimit+100)
|
||||
got, err := loadBackgroundFile(writeTempFile(t, content))
|
||||
if err != nil {
|
||||
t.Fatalf("loadBackgroundFile: %v", err)
|
||||
}
|
||||
if !strings.Contains(got, content) {
|
||||
t.Error("expected content to be preserved past the soft limit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadBackgroundFileOversized(t *testing.T) {
|
||||
// A file larger than maxBackgroundFileBytes must be rejected up front,
|
||||
// before its content is read into memory.
|
||||
content := strings.Repeat("a", maxBackgroundFileBytes+1)
|
||||
_, err := loadBackgroundFile(writeTempFile(t, content))
|
||||
if err == nil {
|
||||
t.Fatal("expected an error for an oversized file, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "maximum") {
|
||||
t.Errorf("error = %q, want it to mention the byte 'maximum'", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadBackgroundFileDirectory(t *testing.T) {
|
||||
if _, err := loadBackgroundFile(t.TempDir()); err == nil {
|
||||
t.Fatal("expected an error when the path is a directory, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadBackgroundFileHardLimit(t *testing.T) {
|
||||
content := strings.Repeat("a", backgroundHardLimit+1)
|
||||
_, err := loadBackgroundFile(writeTempFile(t, content))
|
||||
if err == nil {
|
||||
t.Fatal("expected an error when exceeding the hard size limit, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "hard limit") {
|
||||
t.Errorf("error = %q, want it to mention 'hard limit'", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadBackgroundFileHardLimitExcludesWrapper(t *testing.T) {
|
||||
// The wrapper delimiters must NOT count toward the limit: cleaned content of
|
||||
// exactly the hard limit is accepted even though the wrapped string is longer.
|
||||
content := strings.Repeat("a", backgroundHardLimit)
|
||||
if _, err := loadBackgroundFile(writeTempFile(t, content)); err != nil {
|
||||
t.Fatalf("cleaned content at the hard limit must be accepted, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadBackgroundFileMultiByteRuneCount(t *testing.T) {
|
||||
// Multi-byte runes must be counted as single characters, not bytes.
|
||||
// A precomposed accented letter is one rune but two bytes; a string of exactly
|
||||
// backgroundHardLimit runes (~2x the byte count) must still be accepted.
|
||||
content := strings.Repeat("\u00E9", backgroundHardLimit)
|
||||
got, err := loadBackgroundFile(writeTempFile(t, content))
|
||||
if err != nil {
|
||||
t.Fatalf("loadBackgroundFile rejected content within the rune limit: %v", err)
|
||||
}
|
||||
if !strings.Contains(got, content) {
|
||||
t.Error("expected multi-byte content to be preserved")
|
||||
}
|
||||
}
|
||||
|
||||
// initRepoWithCommit creates a real git repository with a single commit whose
|
||||
// message is `message`, and returns the repo directory and the commit hash.
|
||||
func initRepoWithCommit(t *testing.T, message string) (string, string) {
|
||||
t.Helper()
|
||||
repo := t.TempDir()
|
||||
run := func(args ...string) []byte {
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = repo
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %v failed: %v\n%s", args, err, out)
|
||||
}
|
||||
return out
|
||||
}
|
||||
run("init", "-q")
|
||||
run("config", "user.email", "test@example.com")
|
||||
run("config", "user.name", "Test")
|
||||
run("config", "commit.gpgsign", "false")
|
||||
if err := os.WriteFile(filepath.Join(repo, "file.txt"), []byte("hello\n"), 0o600); err != nil {
|
||||
t.Fatalf("write file: %v", err)
|
||||
}
|
||||
run("add", ".")
|
||||
run("commit", "-q", "-m", message)
|
||||
hash := strings.TrimSpace(string(run("rev-parse", "HEAD")))
|
||||
return repo, hash
|
||||
}
|
||||
|
||||
// TestBackgroundFromCommitThenFile reproduces the resolution order used by
|
||||
// runReview when --background-file is supplied but --background is not: the
|
||||
// inline background is first auto-filled from the commit message, then the
|
||||
// background file is appended. Both must end up in the final background.
|
||||
func TestBackgroundFromCommitThenFile(t *testing.T) {
|
||||
const commitMsg = "Implement rate limiting on login"
|
||||
repo, hash := initRepoWithCommit(t, commitMsg)
|
||||
|
||||
// Mirror runReview: --background empty + --commit set -> use commit message.
|
||||
background := ""
|
||||
msg, err := getCommitMessage(repo, hash)
|
||||
if err != nil {
|
||||
t.Fatalf("getCommitMessage: %v", err)
|
||||
}
|
||||
if msg != commitMsg {
|
||||
t.Fatalf("commit message = %q, want %q", msg, commitMsg)
|
||||
}
|
||||
if background == "" {
|
||||
background = msg
|
||||
}
|
||||
|
||||
// Then --background-file is loaded and merged in.
|
||||
fileBg, err := loadBackgroundFile(writeTempFile(t, "Extra context from a file."))
|
||||
if err != nil {
|
||||
t.Fatalf("loadBackgroundFile: %v", err)
|
||||
}
|
||||
background = mergeBackground(background, fileBg)
|
||||
|
||||
// The commit message must come first, followed by the wrapped file content.
|
||||
if !strings.HasPrefix(background, commitMsg+"\n\n") {
|
||||
t.Errorf("expected commit message to lead the background, got %q", background)
|
||||
}
|
||||
if !strings.Contains(background, "Extra context from a file.") {
|
||||
t.Errorf("expected file content to be appended, got %q", background)
|
||||
}
|
||||
if !strings.Contains(background, backgroundOpenTag) || !strings.Contains(background, backgroundCloseTag) {
|
||||
t.Errorf("expected file content to keep its delimiters, got %q", background)
|
||||
}
|
||||
}
|
||||
|
|
@ -480,23 +480,13 @@ func ensureModelInList(models []string, model string) []string {
|
|||
if model == "" {
|
||||
return models
|
||||
}
|
||||
if modelListContains(models, model) {
|
||||
if llm.ModelListContains(models, model) {
|
||||
return models
|
||||
}
|
||||
out := append([]string(nil), models...)
|
||||
return append(out, model)
|
||||
}
|
||||
|
||||
func modelListContains(models []string, target string) bool {
|
||||
target = strings.TrimSpace(target)
|
||||
for _, model := range models {
|
||||
if model == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func setProviderValue(cfg *Config, key, value string) error {
|
||||
parts := strings.SplitN(key, ".", 3)
|
||||
if len(parts) != 3 || parts[1] == "" || parts[2] == "" {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@ import (
|
|||
"testing"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/otel"
|
||||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||
|
||||
"github.com/open-code-review/open-code-review/internal/agent"
|
||||
"github.com/open-code-review/open-code-review/internal/model"
|
||||
)
|
||||
|
|
@ -22,6 +25,8 @@ type mockResultProvider struct {
|
|||
warnings []agent.AgentWarning
|
||||
projectSummary string
|
||||
toolCalls map[string]int64
|
||||
resumeInfo *agent.ResumeInfo
|
||||
sessionID string
|
||||
}
|
||||
|
||||
func (m *mockResultProvider) Diffs() []model.Diff { return m.diffs }
|
||||
|
|
@ -34,6 +39,8 @@ func (m *mockResultProvider) TotalCacheWriteTokens() int64 { return m.cacheWri
|
|||
func (m *mockResultProvider) Warnings() []agent.AgentWarning { return m.warnings }
|
||||
func (m *mockResultProvider) ProjectSummary() string { return m.projectSummary }
|
||||
func (m *mockResultProvider) ToolCalls() map[string]int64 { return m.toolCalls }
|
||||
func (m *mockResultProvider) ResumeInfo() *agent.ResumeInfo { return m.resumeInfo }
|
||||
func (m *mockResultProvider) SessionID() string { return m.sessionID }
|
||||
|
||||
func TestEmitRunResult_JSONNoFiles(t *testing.T) {
|
||||
ag := &mockResultProvider{filesReviewed: 0}
|
||||
|
|
@ -80,6 +87,32 @@ func TestEmitRunResult_JSONWithComments(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestEmitRunResult_JSONWithResumeInfo(t *testing.T) {
|
||||
ag := &mockResultProvider{
|
||||
filesReviewed: 2,
|
||||
resumeInfo: &agent.ResumeInfo{
|
||||
ResumedFrom: "old-session",
|
||||
ReusedFiles: 1,
|
||||
RerunFiles: 1,
|
||||
PreviousModel: "anthropic-model",
|
||||
CurrentModel: "openai-model",
|
||||
},
|
||||
}
|
||||
got := captureStdout(t, func() {
|
||||
err := emitRunResult(context.Background(), ag, nil, time.Now(), "json", "developer", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
var out jsonOutput
|
||||
if err := json.Unmarshal([]byte(got), &out); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if out.Resume == nil || out.Resume.ResumedFrom != "old-session" || out.Resume.ReusedFiles != 1 || out.Resume.RerunFiles != 1 {
|
||||
t.Fatalf("resume = %+v", out.Resume)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitRunResult_TextNoComments(t *testing.T) {
|
||||
ag := &mockResultProvider{filesReviewed: 2}
|
||||
got := captureStdout(t, func() {
|
||||
|
|
@ -93,6 +126,19 @@ func TestEmitRunResult_TextNoComments(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestEmitRunResult_TextDoesNotPrintSuccessfulSessionHint(t *testing.T) {
|
||||
ag := &mockResultProvider{filesReviewed: 2, sessionID: "session-123"}
|
||||
got := captureStdout(t, func() {
|
||||
err := emitRunResult(context.Background(), ag, nil, time.Now(), "text", "developer", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
if strings.Contains(got, "session-123") || strings.Contains(got, "--resume") {
|
||||
t.Fatalf("successful text output should not print session ID or resume hint, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitRunResult_TextWithComments(t *testing.T) {
|
||||
ag := &mockResultProvider{filesReviewed: 1}
|
||||
comments := []model.LlmComment{{Path: "a.go", Content: "rename", StartLine: 5, EndLine: 10}}
|
||||
|
|
@ -175,3 +221,78 @@ func TestEmitRunResult_NilQuietHandle(t *testing.T) {
|
|||
})
|
||||
_ = got
|
||||
}
|
||||
|
||||
func TestEmitRunResult_JSONTraceIDFromContext(t *testing.T) {
|
||||
tp := sdktrace.NewTracerProvider()
|
||||
defer func() { _ = tp.Shutdown(context.Background()) }()
|
||||
otel.SetTracerProvider(tp)
|
||||
|
||||
ctx, span := tp.Tracer("test").Start(context.Background(), "test-root")
|
||||
wantTraceID := span.SpanContext().TraceID().String()
|
||||
defer span.End()
|
||||
|
||||
ag := &mockResultProvider{
|
||||
filesReviewed: 2,
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
totalTokens: 15,
|
||||
}
|
||||
got := captureStdout(t, func() {
|
||||
err := emitRunResult(ctx, ag, nil, time.Now(), "json", "developer", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
var out jsonOutput
|
||||
if err := json.Unmarshal([]byte(got), &out); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if out.TraceID != wantTraceID {
|
||||
t.Errorf("trace_id = %q, want %q", out.TraceID, wantTraceID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitRunResult_JSONNoFilesTraceID(t *testing.T) {
|
||||
tp := sdktrace.NewTracerProvider()
|
||||
defer func() { _ = tp.Shutdown(context.Background()) }()
|
||||
otel.SetTracerProvider(tp)
|
||||
|
||||
ctx, span := tp.Tracer("test").Start(context.Background(), "test-root")
|
||||
wantTraceID := span.SpanContext().TraceID().String()
|
||||
defer span.End()
|
||||
|
||||
ag := &mockResultProvider{filesReviewed: 0}
|
||||
got := captureStdout(t, func() {
|
||||
err := emitRunResult(ctx, ag, nil, time.Now(), "json", "developer", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
var out jsonOutput
|
||||
if err := json.Unmarshal([]byte(got), &out); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if out.Status != "skipped" {
|
||||
t.Errorf("status = %q, want skipped", out.Status)
|
||||
}
|
||||
if out.TraceID != wantTraceID {
|
||||
t.Errorf("trace_id = %q, want %q", out.TraceID, wantTraceID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitRunResult_JSONIncludesSessionID(t *testing.T) {
|
||||
ag := &mockResultProvider{filesReviewed: 1, sessionID: "session-99"}
|
||||
got := captureStdout(t, func() {
|
||||
err := emitRunResult(context.Background(), ag, nil, time.Now(), "json", "developer", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
var out jsonOutput
|
||||
if err := json.Unmarshal([]byte(got), &out); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if out.SessionID != "session-99" {
|
||||
t.Errorf("session_id = %q, want session-99", out.SessionID)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -101,10 +101,12 @@ type reviewOptions struct {
|
|||
from string
|
||||
to string
|
||||
commit string
|
||||
resume string
|
||||
excludes string // --exclude: comma-separated gitignore-style patterns
|
||||
outputFormat string
|
||||
audience string // --audience: "human" (default) or "agent"
|
||||
background string // --background: optional requirement context
|
||||
backgroundFile string // --background-file: path to a Markdown file used as background
|
||||
model string // --model: override resolved LLM model for this review
|
||||
concurrency int
|
||||
perFileTimeout int
|
||||
|
|
@ -125,12 +127,14 @@ func parseReviewFlags(args []string) (reviewOptions, error) {
|
|||
a.StringVar(&opts.from, "from", "", "source ref to start diff from (e.g., 'main')")
|
||||
a.StringVar(&opts.to, "to", "", "target ref to end diff at (e.g., 'feature-branch')")
|
||||
a.StringVarP(&opts.commit, "commit", "c", "", "single commit hash or tag to review (vs its parent)")
|
||||
a.StringVar(&opts.resume, "resume", "", "resume from a previous review session id")
|
||||
a.StringVar(&opts.excludes, "exclude", "", "comma-separated gitignore-style patterns to exclude; merged with rule.json excludes")
|
||||
a.StringVarP(&opts.outputFormat, "format", "f", "text", "output format: text or json")
|
||||
a.IntVar(&opts.concurrency, "concurrency", 8, "max concurrent file reviews")
|
||||
a.IntVar(&opts.perFileTimeout, "timeout", 10, "concurrent task timeout in minutes")
|
||||
a.StringVar(&opts.audience, "audience", "human", "output audience: human (show progress) or agent (summary only)")
|
||||
a.StringVarP(&opts.background, "background", "b", "", "optional requirement/business context for the review")
|
||||
a.StringVarP(&opts.backgroundFile, "background-file", "B", "", "optional requirement/business context from a Markdown file (combined with --background; inline value appears first when both are set)")
|
||||
a.StringVar(&opts.model, "model", "", "override LLM model for this review (e.g., claude-opus-4-6)")
|
||||
a.IntVar(&opts.maxTools, "max-tools", 0, "max tool call rounds per file (0 = template default; min 10)")
|
||||
a.IntVar(&opts.maxGitProcs, "max-git-procs", 16, "max concurrent git subprocesses")
|
||||
|
|
@ -162,6 +166,9 @@ func parseReviewFlags(args []string) (reviewOptions, error) {
|
|||
if opts.to != "" && opts.from == "" {
|
||||
return opts, fmt.Errorf("--from is required when --to is specified")
|
||||
}
|
||||
if opts.preview && opts.resume != "" {
|
||||
return opts, fmt.Errorf("--preview and --resume cannot be used together")
|
||||
}
|
||||
|
||||
switch opts.audience {
|
||||
case "human", "agent":
|
||||
|
|
@ -203,6 +210,9 @@ Examples:
|
|||
ocr review --commit abc123
|
||||
ocr review -c abc123
|
||||
|
||||
# Resume a previous range review
|
||||
ocr review --from master --to dev-ref --resume <session-id>
|
||||
|
||||
# Output JSON format
|
||||
ocr review --format json
|
||||
ocr review -f json
|
||||
|
|
@ -214,22 +224,29 @@ Examples:
|
|||
ocr review --preview
|
||||
ocr review -c abc123 -p
|
||||
|
||||
# Provide requirement/business context inline, from a Markdown file, or both
|
||||
ocr review --background "Adding rate limiting to the login API"
|
||||
ocr review --background-file ./docs/requirements.md
|
||||
ocr review --background "Focus on auth" --background-file ./docs/requirements.md
|
||||
|
||||
Flags:
|
||||
--audience string output audience: human (show progress) or agent (summary only) (default "human")
|
||||
-b, --background string optional requirement/business context for the review
|
||||
-c, --commit string single commit hash or tag to review (vs its parent)
|
||||
-f, --format string output format: text or json (default "text")
|
||||
--concurrency int max concurrent file reviews (default 8)
|
||||
--max-git-procs int max concurrent git subprocesses (default 16)
|
||||
--from string source ref to start diff from (e.g., 'main')
|
||||
--max-tools int max tool call rounds per file (0 = template default; min 10)
|
||||
--model string override LLM model for this review (e.g., claude-opus-4-6)
|
||||
-p, --preview preview which files will be reviewed without running the LLM
|
||||
--repo string root directory of the git repository (default: current dir)
|
||||
--rule string path to JSON file with system review rules
|
||||
--timeout int concurrent task timeout in minutes (default 10)
|
||||
--to string target ref to end diff at (e.g., 'feature-branch')
|
||||
--tools string path to JSON tools config file (default: embedded)`)
|
||||
--audience string output audience: human (show progress) or agent (summary only) (default "human")
|
||||
-b, --background string optional requirement/business context for the review
|
||||
-B, --background-file string path to a Markdown file used as review background (combined with --background; inline value appears first when both are set)
|
||||
-c, --commit string single commit hash or tag to review (vs its parent)
|
||||
-f, --format string output format: text or json (default "text")
|
||||
--concurrency int max concurrent file reviews (default 8)
|
||||
--max-git-procs int max concurrent git subprocesses (default 16)
|
||||
--from string source ref to start diff from (e.g., 'main')
|
||||
--max-tools int max tool call rounds per file (0 = template default; min 10)
|
||||
--model string override LLM model for this review (e.g., claude-opus-4-6)
|
||||
-p, --preview preview which files will be reviewed without running the LLM
|
||||
--repo string root directory of the git repository (default: current dir)
|
||||
--resume string resume from a previous review session id
|
||||
--rule string path to JSON file with system review rules
|
||||
--timeout int concurrent task timeout in minutes (default 10)
|
||||
--to string target ref to end diff at (e.g., 'feature-branch')
|
||||
--tools string path to JSON tools config file (default: embedded)`)
|
||||
}
|
||||
|
||||
// --- config subcommand ---
|
||||
|
|
|
|||
|
|
@ -5,6 +5,20 @@ import (
|
|||
"time"
|
||||
)
|
||||
|
||||
func TestParseReviewFlagsBackgroundFile(t *testing.T) {
|
||||
for _, flag := range []string{"--background-file", "-B"} {
|
||||
t.Run(flag, func(t *testing.T) {
|
||||
opts, err := parseReviewFlags([]string{flag, "./docs/req.md"})
|
||||
if err != nil {
|
||||
t.Fatalf("parseReviewFlags: %v", err)
|
||||
}
|
||||
if opts.backgroundFile != "./docs/req.md" {
|
||||
t.Errorf("backgroundFile = %q, want %q", opts.backgroundFile, "./docs/req.md")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseReviewFlagsModelOverride(t *testing.T) {
|
||||
opts, err := parseReviewFlags([]string{"--model", "claude-opus-4-6"})
|
||||
if err != nil {
|
||||
|
|
@ -22,6 +36,23 @@ func TestParseReviewFlagsModelOverride(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestParseReviewFlagsResume(t *testing.T) {
|
||||
opts, err := parseReviewFlags([]string{"--from", "main", "--to", "feature", "--resume", "session-123"})
|
||||
if err != nil {
|
||||
t.Fatalf("parseReviewFlags: %v", err)
|
||||
}
|
||||
if opts.resume != "session-123" {
|
||||
t.Errorf("resume = %q, want session-123", opts.resume)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseReviewFlags_PreviewWithResume(t *testing.T) {
|
||||
_, err := parseReviewFlags([]string{"--commit", "abc123", "--preview", "--resume", "session-123"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for --preview with --resume")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseReviewFlags_InvalidAudience(t *testing.T) {
|
||||
_, err := parseReviewFlags([]string{"--audience", "robot"})
|
||||
if err == nil {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,15 @@ func runGitCmd(repoDir string, args ...string) ([]byte, error) {
|
|||
return cmd.CombinedOutput()
|
||||
}
|
||||
|
||||
// runGitCmdStdout is like runGitCmd but returns stdout only. Use it when the
|
||||
// output is consumed as data (e.g. a resolved path) so git's stderr warnings
|
||||
// (permissions, deprecations, config notices) can't pollute the result.
|
||||
func runGitCmdStdout(repoDir string, args ...string) ([]byte, error) {
|
||||
fullArgs := append([]string{"-C", repoDir}, args...)
|
||||
cmd := exec.Command("git", fullArgs...)
|
||||
return cmd.Output()
|
||||
}
|
||||
|
||||
func getCommitMessage(repoDir, commit string) (string, error) {
|
||||
out, err := runGitCmd(repoDir, "log", "-1", "--format=%B", "--end-of-options", commit)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -23,11 +23,17 @@ func initTestGitRepo(t *testing.T) string {
|
|||
}
|
||||
}
|
||||
f := filepath.Join(dir, "README.md")
|
||||
os.WriteFile(f, []byte("hello"), 0o644)
|
||||
if err := os.WriteFile(f, []byte("hello"), 0o644); err != nil {
|
||||
t.Fatalf("write README: %v", err)
|
||||
}
|
||||
cmd := exec.Command("git", "-C", dir, "add", ".")
|
||||
cmd.CombinedOutput()
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("git add: %v: %s", err, out)
|
||||
}
|
||||
cmd = exec.Command("git", "-C", dir, "commit", "-m", "initial commit")
|
||||
cmd.CombinedOutput()
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("git commit: %v: %s", err, out)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
|
|
@ -93,9 +99,18 @@ func TestResolveRepoDir_NotGitRepo(t *testing.T) {
|
|||
|
||||
func TestResolveRepoDir_EmptyUsesWd(t *testing.T) {
|
||||
dir := initTestGitRepo(t)
|
||||
origDir, _ := os.Getwd()
|
||||
defer os.Chdir(origDir)
|
||||
os.Chdir(dir)
|
||||
origDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("getwd: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := os.Chdir(origDir); err != nil {
|
||||
t.Errorf("restore chdir: %v", err)
|
||||
}
|
||||
}()
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatalf("chdir: %v", err)
|
||||
}
|
||||
|
||||
resolved, err := resolveRepoDir("")
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -56,6 +56,8 @@ func dispatch() error {
|
|||
return runRules(args[1:])
|
||||
case "viewer":
|
||||
return runViewer(args[1:])
|
||||
case "session", "sessions":
|
||||
return runSession(args[1:])
|
||||
case "-h", "--help":
|
||||
printTopLevelUsage()
|
||||
return nil
|
||||
|
|
@ -77,6 +79,7 @@ Commands:
|
|||
config Manage configuration settings
|
||||
llm LLM utility commands
|
||||
viewer Start the WebUI session viewer
|
||||
session, sessions List and inspect saved review sessions
|
||||
version Show version information
|
||||
|
||||
Examples:
|
||||
|
|
@ -89,6 +92,7 @@ Examples:
|
|||
ocr config set llm.model opus-4-6 Set a config value
|
||||
ocr llm test Test LLM connectivity
|
||||
ocr llm providers List built-in providers
|
||||
ocr session list List saved review sessions
|
||||
ocr version Show version info
|
||||
|
||||
Use "ocr review -h" for more information about review.
|
||||
|
|
@ -96,6 +100,7 @@ Use "ocr scan -h" for more information about scan.
|
|||
Use "ocr rules -h" for more information about rules.
|
||||
Use "ocr config" for more information about config.
|
||||
Use "ocr llm" for more information about LLM utilities.
|
||||
Use "ocr session -h" for more information about session inspection.
|
||||
|
||||
GitHub: https://github.com/alibaba/open-code-review`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,7 +61,19 @@ func renderComment(comment model.LlmComment) {
|
|||
fmt.Printf("\n\033[2m─── %s:%d-%d ───\033[0m\n", sanitizeTerminal(comment.Path), comment.StartLine, comment.EndLine)
|
||||
|
||||
if comment.Content != "" {
|
||||
for _, ln := range wrapByRunes(sanitizeTerminal(comment.Content), 100) {
|
||||
badge := buildBadge(comment)
|
||||
content := sanitizeTerminal(comment.Content)
|
||||
if badge != "" {
|
||||
// Prepend the plain badge text to the content so it wraps inline with
|
||||
// the first line, then colorize just the badge prefix after wrapping.
|
||||
content = badge + " " + content
|
||||
}
|
||||
lines := wrapByRunes(content, 100)
|
||||
for i, ln := range lines {
|
||||
if i == 0 && badge != "" && strings.HasPrefix(ln, badge) {
|
||||
color := severityColor(comment.Severity)
|
||||
ln = color + badge + "\033[0m" + ln[len(badge):]
|
||||
}
|
||||
fmt.Printf("%s\n", ln)
|
||||
}
|
||||
fmt.Println()
|
||||
|
|
@ -83,6 +95,41 @@ func renderComment(comment model.LlmComment) {
|
|||
fmt.Println()
|
||||
}
|
||||
|
||||
// buildBadge renders a compact "[category · severity]" tag for a finding. It returns
|
||||
// an empty string when neither structured field is present, so text output for findings
|
||||
// without metadata is unchanged.
|
||||
func buildBadge(comment model.LlmComment) string {
|
||||
category := sanitizeTerminal(comment.Category)
|
||||
severity := sanitizeTerminal(comment.Severity)
|
||||
switch {
|
||||
case category != "" && severity != "":
|
||||
return fmt.Sprintf("[%s · %s]", category, severity)
|
||||
case category != "":
|
||||
return fmt.Sprintf("[%s]", category)
|
||||
case severity != "":
|
||||
return fmt.Sprintf("[%s]", severity)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// severityColor maps a finding severity to an ANSI color used for its badge.
|
||||
// Unknown or empty severities fall back to dim.
|
||||
func severityColor(severity string) string {
|
||||
switch severity {
|
||||
case "critical":
|
||||
return "\033[1;91m" // bold bright red
|
||||
case "high":
|
||||
return "\033[91m" // bright red
|
||||
case "medium":
|
||||
return "\033[93m" // bright yellow
|
||||
case "low":
|
||||
return "\033[94m" // bright blue
|
||||
default:
|
||||
return "\033[2m" // dim
|
||||
}
|
||||
}
|
||||
|
||||
// printDiffLine renders a single diff line with colored prefix and background on content.
|
||||
func printDiffLine(prefix, content, fgColor, bgColor string) {
|
||||
fmt.Printf("%s%s%s %s%s\033[0m\n", fgColor+bgColor, prefix, "\033[0m"+bgColor, content, "\033[0m")
|
||||
|
|
@ -193,12 +240,15 @@ type jsonToolCalls struct {
|
|||
|
||||
type jsonOutput struct {
|
||||
Status string `json:"status"`
|
||||
TraceID string `json:"trace_id,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Summary *jsonSummary `json:"summary,omitempty"`
|
||||
ToolCalls *jsonToolCalls `json:"tool_calls"`
|
||||
Comments []model.LlmComment `json:"comments"`
|
||||
Warnings []agent.AgentWarning `json:"warnings,omitempty"`
|
||||
ProjectSummary string `json:"project_summary,omitempty"`
|
||||
Resume *agent.ResumeInfo `json:"resume,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
}
|
||||
|
||||
func outputJSON(comments []model.LlmComment) error {
|
||||
|
|
@ -216,9 +266,10 @@ func outputJSON(comments []model.LlmComment) error {
|
|||
|
||||
func outputJSONWithWarnings(comments []model.LlmComment, warnings []agent.AgentWarning,
|
||||
filesReviewed, inputTokens, outputTokens, totalTokens, cacheReadTokens, cacheWriteTokens int64,
|
||||
duration time.Duration, projectSummary string, toolCalls map[string]int64) error {
|
||||
duration time.Duration, projectSummary string, toolCalls map[string]int64, traceID string, resumeInfo *agent.ResumeInfo, sessionID string) error {
|
||||
out := jsonOutput{
|
||||
Status: "success",
|
||||
TraceID: traceID,
|
||||
Comments: comments,
|
||||
Summary: &jsonSummary{
|
||||
FilesReviewed: filesReviewed,
|
||||
|
|
@ -231,6 +282,8 @@ func outputJSONWithWarnings(comments []model.LlmComment, warnings []agent.AgentW
|
|||
Elapsed: duration.Round(time.Second).String(),
|
||||
},
|
||||
ProjectSummary: projectSummary,
|
||||
Resume: resumeInfo,
|
||||
SessionID: sessionID,
|
||||
}
|
||||
var total int64
|
||||
for _, v := range toolCalls {
|
||||
|
|
@ -264,9 +317,10 @@ func outputJSONWithWarnings(comments []model.LlmComment, warnings []agent.AgentW
|
|||
return enc.Encode(out)
|
||||
}
|
||||
|
||||
func outputJSONNoFiles() error {
|
||||
func outputJSONNoFiles(traceID string) error {
|
||||
out := jsonOutput{
|
||||
Status: "skipped",
|
||||
TraceID: traceID,
|
||||
Message: "No supported files changed.",
|
||||
Comments: []model.LlmComment{},
|
||||
ToolCalls: &jsonToolCalls{
|
||||
|
|
|
|||
|
|
@ -168,9 +168,8 @@ func TestOutputJSONWithWarnings_NoCommentsSubtaskError(t *testing.T) {
|
|||
os.Stdout = w
|
||||
|
||||
warnings := []agent.AgentWarning{{Type: "subtask_error", File: "x.go", Message: "fail"}}
|
||||
err := outputJSONWithWarnings(nil, warnings, 1, 10, 5, 15, 0, 0, time.Second, "", nil)
|
||||
|
||||
w.Close()
|
||||
err := outputJSONWithWarnings(nil, warnings, 1, 10, 5, 15, 0, 0, time.Second, "", nil, "abc123trace", nil, "")
|
||||
_ = w.Close()
|
||||
os.Stdout = old
|
||||
|
||||
if err != nil {
|
||||
|
|
@ -178,16 +177,21 @@ func TestOutputJSONWithWarnings_NoCommentsSubtaskError(t *testing.T) {
|
|||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
buf.ReadFrom(r)
|
||||
_, _ = buf.ReadFrom(r)
|
||||
|
||||
var out jsonOutput
|
||||
json.Unmarshal(buf.Bytes(), &out)
|
||||
if err := json.Unmarshal(buf.Bytes(), &out); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if out.Status != "completed_with_errors" {
|
||||
t.Errorf("status = %q, want completed_with_errors", out.Status)
|
||||
}
|
||||
if !strings.Contains(out.Message, "errors") {
|
||||
t.Errorf("message = %q, expected to mention errors", out.Message)
|
||||
}
|
||||
if out.TraceID != "abc123trace" {
|
||||
t.Errorf("trace_id = %q, want abc123trace", out.TraceID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusBadge(t *testing.T) {
|
||||
|
|
@ -222,7 +226,7 @@ func TestOutputJSON(t *testing.T) {
|
|||
}
|
||||
err := outputJSON(comments)
|
||||
|
||||
w.Close()
|
||||
_ = w.Close()
|
||||
os.Stdout = old
|
||||
|
||||
if err != nil {
|
||||
|
|
@ -230,7 +234,7 @@ func TestOutputJSON(t *testing.T) {
|
|||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
buf.ReadFrom(r)
|
||||
_, _ = buf.ReadFrom(r)
|
||||
|
||||
var out jsonOutput
|
||||
if err := json.Unmarshal(buf.Bytes(), &out); err != nil {
|
||||
|
|
@ -251,7 +255,7 @@ func TestOutputJSON_NoComments(t *testing.T) {
|
|||
|
||||
err := outputJSON(nil)
|
||||
|
||||
w.Close()
|
||||
_ = w.Close()
|
||||
os.Stdout = old
|
||||
|
||||
if err != nil {
|
||||
|
|
@ -259,10 +263,12 @@ func TestOutputJSON_NoComments(t *testing.T) {
|
|||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
buf.ReadFrom(r)
|
||||
_, _ = buf.ReadFrom(r)
|
||||
|
||||
var out jsonOutput
|
||||
json.Unmarshal(buf.Bytes(), &out)
|
||||
if err := json.Unmarshal(buf.Bytes(), &out); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if out.Message == "" {
|
||||
t.Error("expected non-empty message when no comments")
|
||||
}
|
||||
|
|
@ -275,9 +281,8 @@ func TestOutputJSONWithWarnings(t *testing.T) {
|
|||
|
||||
comments := []model.LlmComment{{Path: "b.go", Content: "test"}}
|
||||
warnings := []agent.AgentWarning{{Type: "subtask_error", File: "c.go", Message: "failed"}}
|
||||
err := outputJSONWithWarnings(comments, warnings, 5, 100, 50, 150, 10, 5, 3*time.Second, "summary", map[string]int64{"file_read": 3})
|
||||
|
||||
w.Close()
|
||||
err := outputJSONWithWarnings(comments, warnings, 5, 100, 50, 150, 10, 5, 3*time.Second, "summary", map[string]int64{"file_read": 3}, "trace-xyz-789", nil, "")
|
||||
_ = w.Close()
|
||||
os.Stdout = old
|
||||
|
||||
if err != nil {
|
||||
|
|
@ -285,10 +290,12 @@ func TestOutputJSONWithWarnings(t *testing.T) {
|
|||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
buf.ReadFrom(r)
|
||||
_, _ = buf.ReadFrom(r)
|
||||
|
||||
var out jsonOutput
|
||||
json.Unmarshal(buf.Bytes(), &out)
|
||||
if err := json.Unmarshal(buf.Bytes(), &out); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if out.Status != "completed_with_errors" {
|
||||
t.Errorf("status = %q, want completed_with_errors", out.Status)
|
||||
}
|
||||
|
|
@ -301,6 +308,9 @@ func TestOutputJSONWithWarnings(t *testing.T) {
|
|||
if out.ToolCalls == nil || out.ToolCalls.Total != 3 {
|
||||
t.Errorf("ToolCalls.Total = %v", out.ToolCalls)
|
||||
}
|
||||
if out.TraceID != "trace-xyz-789" {
|
||||
t.Errorf("trace_id = %q, want trace-xyz-789", out.TraceID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutputJSONWithWarnings_NoCommentsNoErrors(t *testing.T) {
|
||||
|
|
@ -309,9 +319,8 @@ func TestOutputJSONWithWarnings_NoCommentsNoErrors(t *testing.T) {
|
|||
os.Stdout = w
|
||||
|
||||
warnings := []agent.AgentWarning{{Type: "warning", Message: "something"}}
|
||||
err := outputJSONWithWarnings(nil, warnings, 2, 50, 20, 70, 0, 0, time.Second, "", nil)
|
||||
|
||||
w.Close()
|
||||
err := outputJSONWithWarnings(nil, warnings, 2, 50, 20, 70, 0, 0, time.Second, "", nil, "", nil, "")
|
||||
_ = w.Close()
|
||||
os.Stdout = old
|
||||
|
||||
if err != nil {
|
||||
|
|
@ -319,10 +328,12 @@ func TestOutputJSONWithWarnings_NoCommentsNoErrors(t *testing.T) {
|
|||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
buf.ReadFrom(r)
|
||||
_, _ = buf.ReadFrom(r)
|
||||
|
||||
var out jsonOutput
|
||||
json.Unmarshal(buf.Bytes(), &out)
|
||||
if err := json.Unmarshal(buf.Bytes(), &out); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if out.Status != "completed_with_warnings" {
|
||||
t.Errorf("status = %q, want completed_with_warnings", out.Status)
|
||||
}
|
||||
|
|
@ -336,9 +347,9 @@ func TestOutputJSONNoFiles(t *testing.T) {
|
|||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
|
||||
err := outputJSONNoFiles()
|
||||
err := outputJSONNoFiles("test-trace-id-456")
|
||||
|
||||
w.Close()
|
||||
_ = w.Close()
|
||||
os.Stdout = old
|
||||
|
||||
if err != nil {
|
||||
|
|
@ -346,13 +357,18 @@ func TestOutputJSONNoFiles(t *testing.T) {
|
|||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
buf.ReadFrom(r)
|
||||
_, _ = buf.ReadFrom(r)
|
||||
|
||||
var out jsonOutput
|
||||
json.Unmarshal(buf.Bytes(), &out)
|
||||
if err := json.Unmarshal(buf.Bytes(), &out); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if out.Status != "skipped" {
|
||||
t.Errorf("status = %q, want skipped", out.Status)
|
||||
}
|
||||
if out.TraceID != "test-trace-id-456" {
|
||||
t.Errorf("trace_id = %q, want test-trace-id-456", out.TraceID)
|
||||
}
|
||||
}
|
||||
|
||||
func captureStdout(t *testing.T, fn func()) string {
|
||||
|
|
@ -364,10 +380,10 @@ func captureStdout(t *testing.T, fn func()) string {
|
|||
}
|
||||
os.Stdout = w
|
||||
fn()
|
||||
w.Close()
|
||||
_ = w.Close()
|
||||
os.Stdout = old
|
||||
var buf bytes.Buffer
|
||||
buf.ReadFrom(r)
|
||||
_, _ = buf.ReadFrom(r)
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,78 @@
|
|||
package main
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/open-code-review/open-code-review/internal/model"
|
||||
)
|
||||
|
||||
func TestBuildBadge(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
comment model.LlmComment
|
||||
want string
|
||||
}{
|
||||
{"both fields", model.LlmComment{Category: "security", Severity: "high"}, "[security · high]"},
|
||||
{"category only", model.LlmComment{Category: "bug"}, "[bug]"},
|
||||
{"severity only", model.LlmComment{Severity: "low"}, "[low]"},
|
||||
{"neither", model.LlmComment{}, ""},
|
||||
{"strips control chars", model.LlmComment{Category: "bug\x1b[0m", Severity: "high"}, "[bug[0m · high]"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := buildBadge(tt.comment); got != tt.want {
|
||||
t.Errorf("buildBadge() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeverityColor(t *testing.T) {
|
||||
// Each known severity must map to a distinct color; unknown falls back to dim.
|
||||
seen := map[string]string{}
|
||||
for _, sev := range []string{"critical", "high", "medium", "low"} {
|
||||
c := severityColor(sev)
|
||||
if c == "" {
|
||||
t.Errorf("severityColor(%q) is empty", sev)
|
||||
}
|
||||
if prev, ok := seen[c]; ok {
|
||||
t.Errorf("severityColor(%q) shares color with %q", sev, prev)
|
||||
}
|
||||
seen[c] = sev
|
||||
}
|
||||
if got := severityColor("bogus"); got != "\033[2m" {
|
||||
t.Errorf("severityColor(unknown) = %q, want dim", got)
|
||||
}
|
||||
if got := severityColor(""); got != "\033[2m" {
|
||||
t.Errorf("severityColor(empty) = %q, want dim", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderComment_BadgeInline verifies the badge is colorized and rendered inline
|
||||
// with the first line of the comment content.
|
||||
func TestRenderComment_BadgeInline(t *testing.T) {
|
||||
out := captureStdout(t, func() {
|
||||
renderComment(model.LlmComment{
|
||||
Path: "internal/mcp/client.go",
|
||||
StartLine: 27,
|
||||
EndLine: 27,
|
||||
Content: "Potential environment variable leak.",
|
||||
Category: "security",
|
||||
Severity: "high",
|
||||
})
|
||||
})
|
||||
if !strings.Contains(out, "[security · high]") {
|
||||
t.Errorf("expected badge in output, got:\n%s", out)
|
||||
}
|
||||
// severity high → bright red; the badge must be wrapped in the color + reset.
|
||||
if !strings.Contains(out, "\033[91m[security · high]\033[0m") {
|
||||
t.Errorf("expected colorized badge, got:\n%q", out)
|
||||
}
|
||||
if !strings.Contains(out, "Potential environment variable leak.") {
|
||||
t.Errorf("expected content in output, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeTerminal(t *testing.T) {
|
||||
tests := []struct {
|
||||
|
|
@ -21,8 +93,8 @@ func TestSanitizeTerminal(t *testing.T) {
|
|||
{"only control chars", "\x1b\x07\x00\x7f", ""},
|
||||
{"unicode preserved", "代码审查 レビュー 🔍", "代码审查 レビュー 🔍"},
|
||||
{"mixed safe and unsafe", "path\x1b[0m/file.go", "path[0m/file.go"},
|
||||
{"strips C1 CSI (U+009B)", "beforeafter", "beforeafter"},
|
||||
{"strips C1 OSC (U+009D)", "beforeafter", "beforeafter"},
|
||||
{"strips C1 CSI (U+009B)", "before\u009bafter", "beforeafter"},
|
||||
{"strips C1 OSC (U+009D)", "before\u009dafter", "beforeafter"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -32,13 +32,9 @@ func runConfigProvider() error {
|
|||
final := finalModel.(providerTUIModel)
|
||||
|
||||
if !final.confirmed {
|
||||
// TUI persists changes (create/edit/model/add/delete) directly to disk
|
||||
// during the session, so the on-disk file is already up to date for any
|
||||
// savedInSession operation. No additional post-TUI apply step is needed.
|
||||
if final.savedInSession {
|
||||
return nil
|
||||
}
|
||||
fmt.Println("Cancelled.")
|
||||
// TUI persists changes during the session; Esc only abandons the final
|
||||
// provider/API-key confirmation step.
|
||||
printWizardCancelled(final.savedInSession, "Configuration changes")
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -55,6 +51,16 @@ func runConfigProvider() error {
|
|||
return applyOfficialProviderConfig(configPath, cfg, result)
|
||||
}
|
||||
|
||||
// printWizardCancelled prints the standard Esc-cancel message for config wizards.
|
||||
// changesDescription is a short noun phrase, e.g. "Configuration changes".
|
||||
func printWizardCancelled(savedInSession bool, changesDescription string) {
|
||||
if savedInSession {
|
||||
fmt.Printf("Cancelled. (%s made during this session were kept.)\n", changesDescription)
|
||||
return
|
||||
}
|
||||
fmt.Println("Cancelled.")
|
||||
}
|
||||
|
||||
func applyProviderDeletions(configPath string, cfg *Config, names []string) (bool, error) {
|
||||
clearedActive := false
|
||||
for _, name := range names {
|
||||
|
|
@ -133,7 +139,8 @@ func applyCustomProviderConfig(configPath string, cfg *Config, result providerTU
|
|||
if result.provider == "" {
|
||||
return fmt.Errorf("provider name is required")
|
||||
}
|
||||
if result.model == "" {
|
||||
model := result.resolvedModel()
|
||||
if model == "" {
|
||||
return fmt.Errorf("model is required")
|
||||
}
|
||||
|
||||
|
|
@ -142,11 +149,11 @@ func applyCustomProviderConfig(configPath string, cfg *Config, result providerTU
|
|||
}
|
||||
|
||||
entry := cfg.CustomProviders[result.provider]
|
||||
entry.Model = result.model
|
||||
entry.Model = model
|
||||
if len(result.models) > 0 {
|
||||
entry.Models = append([]string(nil), result.models...)
|
||||
}
|
||||
entry.Models = ensureModelInList(entry.Models, result.model)
|
||||
entry.Models = ensureModelInList(entry.Models, model)
|
||||
if result.url != "" {
|
||||
entry.URL = result.url
|
||||
}
|
||||
|
|
@ -162,14 +169,16 @@ func applyCustomProviderConfig(configPath string, cfg *Config, result providerTU
|
|||
}
|
||||
if result.apiKey != "" {
|
||||
entry.APIKey = result.apiKey
|
||||
} else {
|
||||
entry.APIKey = ""
|
||||
}
|
||||
cfg.CustomProviders[result.provider] = entry
|
||||
|
||||
if !result.isEdit {
|
||||
cfg.Provider = result.provider
|
||||
cfg.Model = result.model
|
||||
cfg.Model = model
|
||||
} else if cfg.Provider == result.provider {
|
||||
cfg.Model = result.model
|
||||
cfg.Model = model
|
||||
}
|
||||
|
||||
if err := saveConfig(configPath, cfg); err != nil {
|
||||
|
|
@ -182,13 +191,13 @@ func applyCustomProviderConfig(configPath string, cfg *Config, result providerTU
|
|||
} else {
|
||||
fmt.Printf("\nCustom provider %q updated (not currently active).\n", result.provider)
|
||||
}
|
||||
fmt.Printf("Model: %s\n", result.model)
|
||||
fmt.Printf("Model: %s\n", model)
|
||||
fmt.Println("\nTip: run 'ocr config model' to switch model later.")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("\nProvider set to: %s (custom)\n", result.provider)
|
||||
fmt.Printf("Model: %s\n", result.model)
|
||||
fmt.Printf("Model: %s\n", model)
|
||||
|
||||
fmt.Println("\nTesting connection...")
|
||||
if err := runLLMTest(); err != nil {
|
||||
|
|
@ -202,7 +211,11 @@ func applyCustomProviderConfig(configPath string, cfg *Config, result providerTU
|
|||
}
|
||||
|
||||
func applyOfficialProviderConfig(configPath string, cfg *Config, result providerTUIResult) error {
|
||||
if result.provider == "" || result.model == "" {
|
||||
if result.provider == "" {
|
||||
return fmt.Errorf("provider and model are required")
|
||||
}
|
||||
model := result.resolvedModel()
|
||||
if model == "" {
|
||||
return fmt.Errorf("provider and model are required")
|
||||
}
|
||||
|
||||
|
|
@ -223,12 +236,15 @@ func applyOfficialProviderConfig(configPath string, cfg *Config, result provider
|
|||
}
|
||||
|
||||
entry := cfg.Providers[result.provider]
|
||||
entry.Model = result.model
|
||||
entry.Model = model
|
||||
if len(result.models) > 0 {
|
||||
entry.Models = mergeModelLists(entry.Models, result.models)
|
||||
}
|
||||
if result.apiKey != "" {
|
||||
entry.APIKey = result.apiKey
|
||||
} else {
|
||||
// Confirmed empty key: clear saved api_key so resolver falls back to $ENV_VAR.
|
||||
entry.APIKey = ""
|
||||
}
|
||||
cfg.Providers[result.provider] = entry
|
||||
|
||||
|
|
@ -236,14 +252,14 @@ func applyOfficialProviderConfig(configPath string, cfg *Config, result provider
|
|||
cfg.Model = ""
|
||||
}
|
||||
cfg.Provider = result.provider
|
||||
cfg.Model = result.model
|
||||
cfg.Model = model
|
||||
|
||||
if err := saveConfig(configPath, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("\nProvider set to: %s\n", result.provider)
|
||||
fmt.Printf("Model: %s\n", result.model)
|
||||
fmt.Printf("Model: %s\n", model)
|
||||
|
||||
fmt.Println("\nTesting connection...")
|
||||
if err := runLLMTest(); err != nil {
|
||||
|
|
@ -274,8 +290,10 @@ func runConfigModel() error {
|
|||
currentModel := ""
|
||||
provider := llm.Provider{Name: cfg.Provider, DisplayName: cfg.Provider}
|
||||
isCustom := false
|
||||
registryModels := []string(nil)
|
||||
if preset, isPreset := llm.LookupProvider(cfg.Provider); isPreset {
|
||||
provider = preset
|
||||
registryModels = append([]string(nil), preset.Models...)
|
||||
if entry, ok := cfg.Providers[cfg.Provider]; ok {
|
||||
currentModel = activeModelForProvider(cfg, cfg.Provider, entry)
|
||||
provider.Models = mergeModelLists(provider.Models, entry.Models)
|
||||
|
|
@ -293,7 +311,15 @@ func runConfigModel() error {
|
|||
provider.Models = mergeModelLists(entry.Models)
|
||||
}
|
||||
|
||||
m := newModelTUI(provider, currentModel)
|
||||
m := newModelTUIConfig(modelTUIConfig{
|
||||
Provider: provider,
|
||||
CurrentModel: currentModel,
|
||||
RegistryModels: registryModels,
|
||||
ExistingCfg: cfg,
|
||||
ConfigPath: configPath,
|
||||
ProviderName: cfg.Provider,
|
||||
IsCustom: isCustom,
|
||||
})
|
||||
p := tea.NewProgram(m)
|
||||
finalModel, err := p.Run()
|
||||
if err != nil {
|
||||
|
|
@ -302,7 +328,7 @@ func runConfigModel() error {
|
|||
|
||||
final := finalModel.(modelTUIModel)
|
||||
if final.cancelled {
|
||||
fmt.Println("Cancelled.")
|
||||
printWizardCancelled(final.savedInSession, "Model list changes")
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -325,7 +351,9 @@ func runConfigModel() error {
|
|||
}
|
||||
entry := cfg.Providers[cfg.Provider]
|
||||
entry.Model = selectedModel
|
||||
if !modelListContains(provider.Models, selectedModel) {
|
||||
// Use registry-only list: provider.Models was captured before the TUI and
|
||||
// may include stale entry.Models from add/delete during the session.
|
||||
if !llm.ModelListContains(registryModels, selectedModel) {
|
||||
entry.Models = ensureModelInList(entry.Models, selectedModel)
|
||||
}
|
||||
cfg.Providers[cfg.Provider] = entry
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package main
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
|
@ -203,3 +204,175 @@ func TestApplyOfficialProviderConfig_MissingFields(t *testing.T) {
|
|||
t.Fatal("expected error for missing provider/model")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyOfficialProviderConfig_EmptyKeyClearsSavedAPIKey(t *testing.T) {
|
||||
t.Setenv("DEEPSEEK_API_KEY", "sk-from-env")
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "config.json")
|
||||
cfg := &Config{
|
||||
Provider: "deepseek",
|
||||
Model: "deepseek-v4-flash",
|
||||
Providers: map[string]ProviderEntry{
|
||||
"deepseek": {
|
||||
APIKey: "old-saved-key",
|
||||
Model: "deepseek-v4-flash",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := applyOfficialProviderConfig(configPath, cfg, providerTUIResult{
|
||||
provider: "deepseek",
|
||||
model: "deepseek-v4-flash",
|
||||
apiKey: "",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("applyOfficialProviderConfig: %v", err)
|
||||
}
|
||||
if got := cfg.Providers["deepseek"].APIKey; got != "" {
|
||||
t.Errorf("in-memory APIKey = %q, want empty", got)
|
||||
}
|
||||
diskCfg, err := loadOrCreateConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("load config: %v", err)
|
||||
}
|
||||
if got := diskCfg.Providers["deepseek"].APIKey; got != "" {
|
||||
t.Errorf("persisted APIKey = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyCustomProviderConfig_EmptyKeyClearsSavedAPIKey(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "config.json")
|
||||
cfg := &Config{
|
||||
Provider: "aaa",
|
||||
Model: "test",
|
||||
CustomProviders: map[string]ProviderEntry{
|
||||
"aaa": {
|
||||
URL: "https://example.com/v1",
|
||||
Protocol: "openai",
|
||||
APIKey: "old-saved-key",
|
||||
Model: "test",
|
||||
Models: []string{"test"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := applyCustomProviderConfig(configPath, cfg, providerTUIResult{
|
||||
provider: "aaa",
|
||||
model: "test",
|
||||
models: []string{"test"},
|
||||
apiKey: "",
|
||||
isCustom: true,
|
||||
url: "https://example.com/v1",
|
||||
protocol: "openai",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("applyCustomProviderConfig: %v", err)
|
||||
}
|
||||
if got := cfg.CustomProviders["aaa"].APIKey; got != "" {
|
||||
t.Errorf("APIKey = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderTUIResult_ResolvedModel(t *testing.T) {
|
||||
r := providerTUIResult{
|
||||
provider: "baidu-qianfan",
|
||||
model: "glm-5",
|
||||
}
|
||||
if got := r.resolvedModel(); got != "glm-5" {
|
||||
t.Errorf("resolvedModel() = %q, want glm-5", got)
|
||||
}
|
||||
|
||||
r = providerTUIResult{
|
||||
provider: "baidu-qianfan",
|
||||
sessionModelPick: map[string]string{
|
||||
"baidu-qianfan": "glm-5",
|
||||
},
|
||||
}
|
||||
if got := r.resolvedModel(); got != "glm-5" {
|
||||
t.Errorf("resolvedModel() from session pick = %q, want glm-5", got)
|
||||
}
|
||||
|
||||
r = providerTUIResult{provider: "baidu-qianfan"}
|
||||
if got := r.resolvedModel(); got != "" {
|
||||
t.Errorf("resolvedModel() = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyOfficialProviderConfig_UsesSessionModelPick(t *testing.T) {
|
||||
t.Setenv("QIANFAN_API_KEY", "sk-from-env")
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "config.json")
|
||||
cfg := &Config{
|
||||
Provider: "deepseek",
|
||||
Model: "deepseek-v4-flash",
|
||||
Providers: map[string]ProviderEntry{
|
||||
"deepseek": {Model: "deepseek-v4-flash"},
|
||||
},
|
||||
}
|
||||
|
||||
err := applyOfficialProviderConfig(configPath, cfg, providerTUIResult{
|
||||
provider: "baidu-qianfan",
|
||||
apiKey: "",
|
||||
sessionModelPick: map[string]string{
|
||||
"baidu-qianfan": "glm-5",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("applyOfficialProviderConfig: %v", err)
|
||||
}
|
||||
if cfg.Provider != "baidu-qianfan" {
|
||||
t.Errorf("Provider = %q, want baidu-qianfan", cfg.Provider)
|
||||
}
|
||||
if cfg.Model != "glm-5" {
|
||||
t.Errorf("Model = %q, want glm-5", cfg.Model)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintWizardCancelled(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
savedInSession bool
|
||||
scope string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "no changes",
|
||||
savedInSession: false,
|
||||
scope: "Configuration changes",
|
||||
want: "Cancelled.\n",
|
||||
},
|
||||
{
|
||||
name: "provider wizard kept changes",
|
||||
savedInSession: true,
|
||||
scope: "Configuration changes",
|
||||
want: "Cancelled. (Configuration changes made during this session were kept.)\n",
|
||||
},
|
||||
{
|
||||
name: "model wizard kept changes",
|
||||
savedInSession: true,
|
||||
scope: "Model list changes",
|
||||
want: "Cancelled. (Model list changes made during this session were kept.)\n",
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
old := os.Stdout
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
os.Stdout = w
|
||||
printWizardCancelled(tc.savedInSession, tc.scope)
|
||||
_ = w.Close()
|
||||
os.Stdout = old
|
||||
got, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != tc.want {
|
||||
t.Errorf("output = %q, want %q", string(got), tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,8 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
|
|
@ -44,6 +46,73 @@ func TestCustomProviderActiveModel_MatchingProvider(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestOfficialProviderActiveModel_NilCfg(t *testing.T) {
|
||||
m := providerTUIModel{existingCfg: nil}
|
||||
got := m.officialProviderActiveModel(llm.Provider{Name: "anthropic"})
|
||||
if got != "" {
|
||||
t.Errorf("expected empty string for nil cfg, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOfficialProviderActiveModel_DifferentProvider(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Provider: "deepseek",
|
||||
Model: "deepseek-v4-flash",
|
||||
Providers: map[string]ProviderEntry{
|
||||
"deepseek": {Model: "deepseek-v4-flash"},
|
||||
},
|
||||
}
|
||||
m := newProviderTUI(cfg, "")
|
||||
got := m.officialProviderActiveModel(llm.Provider{Name: "anthropic", DisplayName: "Anthropic Claude API"})
|
||||
if got != "" {
|
||||
t.Errorf("expected empty string for non-active provider, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOfficialProviderActiveModel_MatchingProvider(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Provider: "anthropic",
|
||||
Model: "claude-opus-4-8",
|
||||
Providers: map[string]ProviderEntry{
|
||||
"anthropic": {Model: "claude-opus-4-8"},
|
||||
},
|
||||
}
|
||||
m := newProviderTUI(cfg, "")
|
||||
got := m.officialProviderActiveModel(llm.Provider{Name: "anthropic", DisplayName: "Anthropic Claude API"})
|
||||
if got != "claude-opus-4-8" {
|
||||
t.Errorf("expected claude-opus-4-8, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOfficialProviderActiveModel_EmptyModel(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Provider: "anthropic",
|
||||
Providers: map[string]ProviderEntry{
|
||||
"anthropic": {},
|
||||
},
|
||||
}
|
||||
m := newProviderTUI(cfg, "")
|
||||
got := m.officialProviderActiveModel(llm.Provider{Name: "anthropic"})
|
||||
if got != "" {
|
||||
t.Errorf("expected empty model, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderTUIView_OfficialTab_ShowsActiveModelSuffix(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Provider: "anthropic",
|
||||
Model: "claude-opus-4-8",
|
||||
Providers: map[string]ProviderEntry{
|
||||
"anthropic": {Model: "claude-opus-4-8"},
|
||||
},
|
||||
}
|
||||
m := newProviderTUI(cfg, "")
|
||||
got := stripANSI(m.View().Content)
|
||||
if !strings.Contains(got, "(claude-opus-4-8)") {
|
||||
t.Errorf("view missing active model suffix; got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelProviderName_OfficialTab(t *testing.T) {
|
||||
m := newProviderTUI(&Config{}, "")
|
||||
name := m.modelProviderName()
|
||||
|
|
@ -547,6 +616,12 @@ func TestNewModelTUI(t *testing.T) {
|
|||
t.Skip("no providers")
|
||||
}
|
||||
m := newModelTUI(p[0], "")
|
||||
if m.isCustomProvider {
|
||||
t.Error("preset provider test helper should set isCustomProvider=false")
|
||||
}
|
||||
if len(m.registryModels) == 0 {
|
||||
t.Error("registryModels should be populated for preset provider")
|
||||
}
|
||||
if m.modelIdx != 0 {
|
||||
t.Errorf("modelIdx = %d, want 0 for empty currentModel", m.modelIdx)
|
||||
}
|
||||
|
|
@ -861,6 +936,447 @@ func TestModelTUI_View_CustomModel(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func officialConfigModelTUI(t *testing.T, configPath string, extraModels []string) modelTUIModel {
|
||||
t.Helper()
|
||||
preset, ok := llm.LookupProvider("dashscope")
|
||||
if !ok {
|
||||
t.Skip("dashscope provider not in registry")
|
||||
}
|
||||
models := []string{"qwen3.7-max"}
|
||||
models = append(models, extraModels...)
|
||||
cfg := &Config{
|
||||
Provider: "dashscope",
|
||||
Model: "qwen3.7-max",
|
||||
Providers: map[string]ProviderEntry{
|
||||
"dashscope": {
|
||||
Model: "qwen3.7-max",
|
||||
Models: models,
|
||||
},
|
||||
},
|
||||
}
|
||||
provider := preset
|
||||
provider.Models = mergeModelLists(preset.Models, cfg.Providers["dashscope"].Models)
|
||||
return newModelTUIConfig(modelTUIConfig{
|
||||
Provider: provider,
|
||||
RegistryModels: preset.Models,
|
||||
ExistingCfg: cfg,
|
||||
ConfigPath: configPath,
|
||||
ProviderName: "dashscope",
|
||||
IsCustom: false,
|
||||
})
|
||||
}
|
||||
|
||||
func customConfigModelTUI(t *testing.T, configPath string, models []string) modelTUIModel {
|
||||
t.Helper()
|
||||
cfg := &Config{
|
||||
Provider: "my-llm",
|
||||
Model: "m1",
|
||||
CustomProviders: map[string]ProviderEntry{
|
||||
"my-llm": {
|
||||
URL: "https://custom.api/v1",
|
||||
Protocol: "openai",
|
||||
Model: "m1",
|
||||
Models: append([]string(nil), models...),
|
||||
},
|
||||
},
|
||||
}
|
||||
provider := llm.Provider{
|
||||
Name: "my-llm",
|
||||
DisplayName: "my-llm (custom)",
|
||||
Models: mergeModelLists(models),
|
||||
}
|
||||
return newModelTUIConfig(modelTUIConfig{
|
||||
Provider: provider,
|
||||
CurrentModel: "m1",
|
||||
ExistingCfg: cfg,
|
||||
ConfigPath: configPath,
|
||||
ProviderName: "my-llm",
|
||||
IsCustom: true,
|
||||
})
|
||||
}
|
||||
|
||||
func asModelTUIModel(t *testing.T, m tea.Model) modelTUIModel {
|
||||
t.Helper()
|
||||
switch v := m.(type) {
|
||||
case modelTUIModel:
|
||||
return v
|
||||
case *modelTUIModel:
|
||||
return *v
|
||||
default:
|
||||
t.Fatalf("expected modelTUIModel, got %T", m)
|
||||
return modelTUIModel{}
|
||||
}
|
||||
}
|
||||
|
||||
func modelTUIEnterCustomModelName(t *testing.T, m modelTUIModel, name string) modelTUIModel {
|
||||
t.Helper()
|
||||
m.modelIdx = len(m.displayModels())
|
||||
result, _ := m.Update(enterKey())
|
||||
m2 := result.(modelTUIModel)
|
||||
if !m2.customModel {
|
||||
t.Fatal("expected customModel after enter on custom item")
|
||||
}
|
||||
m2.modelInput.SetValue(name)
|
||||
result, _ = m2.Update(enterKey())
|
||||
return result.(modelTUIModel)
|
||||
}
|
||||
|
||||
func modelTUIIdxForName(t *testing.T, m modelTUIModel, name string) int {
|
||||
t.Helper()
|
||||
for i, model := range m.displayModels() {
|
||||
if model == name {
|
||||
return i
|
||||
}
|
||||
}
|
||||
t.Fatalf("model %q not found in %v", name, m.displayModels())
|
||||
return -1
|
||||
}
|
||||
|
||||
func TestModelTUI_Official_AddCustomModelStaysOnList(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "config.json")
|
||||
m := officialConfigModelTUI(t, configPath, nil)
|
||||
m3 := modelTUIEnterCustomModelName(t, m, "new-model")
|
||||
|
||||
if m3.confirmed {
|
||||
t.Error("adding a model should not confirm and quit")
|
||||
}
|
||||
if m3.customModel {
|
||||
t.Error("customModel should be cleared after add")
|
||||
}
|
||||
got := m3.existingCfg.Providers["dashscope"].Models
|
||||
if !llm.ModelListContains(got, "new-model") {
|
||||
t.Errorf("Models = %v, want new-model appended", got)
|
||||
}
|
||||
if m3.existingCfg.Model != "qwen3.7-max" {
|
||||
t.Errorf("cfg.Model = %q, want active model unchanged", m3.existingCfg.Model)
|
||||
}
|
||||
diskCfg, err := loadOrCreateConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("load disk config: %v", err)
|
||||
}
|
||||
if !llm.ModelListContains(diskCfg.Providers["dashscope"].Models, "new-model") {
|
||||
t.Errorf("disk Models = %v, want new-model persisted", diskCfg.Providers["dashscope"].Models)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelTUI_Official_ListEnterConfirmsSelection(t *testing.T) {
|
||||
m := officialConfigModelTUI(t, "", nil)
|
||||
m2 := modelTUIEnterCustomModelName(t, m, "picked-model")
|
||||
m2.modelIdx = modelTUIIdxForName(t, m2, "picked-model")
|
||||
result, _ := m2.Update(enterKey())
|
||||
m3 := result.(modelTUIModel)
|
||||
if !m3.confirmed {
|
||||
t.Error("enter on list item should confirm selection")
|
||||
}
|
||||
if m3.selectedModel() != "picked-model" {
|
||||
t.Errorf("selectedModel() = %q, want picked-model", m3.selectedModel())
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelTUI_CustomProvider_AddCustomModelStaysOnList(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "config.json")
|
||||
m := customConfigModelTUI(t, configPath, []string{"m1"})
|
||||
m3 := modelTUIEnterCustomModelName(t, m, "new-custom-model")
|
||||
|
||||
if m3.confirmed {
|
||||
t.Error("adding a model should not confirm and quit")
|
||||
}
|
||||
if !llm.ModelListContains(m3.existingCfg.CustomProviders["my-llm"].Models, "new-custom-model") {
|
||||
t.Errorf("Models = %v, want new-custom-model appended", m3.existingCfg.CustomProviders["my-llm"].Models)
|
||||
}
|
||||
if m3.existingCfg.CustomProviders["my-llm"].Model != "m1" {
|
||||
t.Errorf("entry.Model = %q, want active model unchanged", m3.existingCfg.CustomProviders["my-llm"].Model)
|
||||
}
|
||||
if !m3.savedInSession {
|
||||
t.Error("savedInSession should be true after add")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelTUI_EscCancelWithoutChangesNoSavedInSession(t *testing.T) {
|
||||
m := officialConfigModelTUI(t, "", nil)
|
||||
result, _ := m.Update(escKey())
|
||||
m2 := result.(modelTUIModel)
|
||||
if !m2.cancelled {
|
||||
t.Fatal("esc should cancel")
|
||||
}
|
||||
if m2.savedInSession {
|
||||
t.Error("savedInSession should be false when no add/delete occurred")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelTUI_DeleteSetsSavedInSession(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "config.json")
|
||||
m := customConfigModelTUI(t, configPath, []string{"m1", "aaa"})
|
||||
m.modelIdx = modelTUIIdxForName(t, m, "aaa")
|
||||
m.deleteModelName = "aaa"
|
||||
m.confirmingDeleteModel = true
|
||||
|
||||
result, _ := m.confirmDeleteCustomProviderModel()
|
||||
m2 := asModelTUIModel(t, result)
|
||||
if !m2.savedInSession {
|
||||
t.Error("savedInSession should be true after delete")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelTUI_AddCustomModelRejectsDuplicate(t *testing.T) {
|
||||
m := officialConfigModelTUI(t, "", []string{"dup-model"})
|
||||
m.modelIdx = len(m.displayModels())
|
||||
result, _ := m.Update(enterKey())
|
||||
m2 := result.(modelTUIModel)
|
||||
m2.modelInput.SetValue("dup-model")
|
||||
result, _ = m2.Update(enterKey())
|
||||
m3 := result.(modelTUIModel)
|
||||
if m3.formError != "Already in list: dup-model" {
|
||||
t.Errorf("formError = %q, want duplicate message", m3.formError)
|
||||
}
|
||||
if !m3.customModel {
|
||||
t.Error("customModel should stay open after duplicate reject")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelTUI_Official_DeleteUserAddedModel(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "config.json")
|
||||
m := officialConfigModelTUI(t, configPath, []string{"my-custom-model"})
|
||||
m.modelIdx = modelTUIIdxForName(t, m, "my-custom-model")
|
||||
|
||||
result, _ := m.Update(dKey())
|
||||
m2 := result.(modelTUIModel)
|
||||
if !m2.confirmingDeleteModel {
|
||||
t.Fatal("pressing d on user-added model should set confirmingDeleteModel = true")
|
||||
}
|
||||
|
||||
result, _ = m2.Update(yKey())
|
||||
m3 := result.(modelTUIModel)
|
||||
got := m3.existingCfg.Providers["dashscope"].Models
|
||||
if len(got) != 1 || got[0] != "qwen3.7-max" {
|
||||
t.Errorf("Models = %v, want [qwen3.7-max]", got)
|
||||
}
|
||||
|
||||
diskCfg, err := loadOrCreateConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("load disk config: %v", err)
|
||||
}
|
||||
if len(diskCfg.Providers["dashscope"].Models) != 1 {
|
||||
t.Errorf("disk Models = %v, want [qwen3.7-max]", diskCfg.Providers["dashscope"].Models)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelTUI_Official_DeleteBuiltInModelIgnored(t *testing.T) {
|
||||
m := officialConfigModelTUI(t, "", []string{"my-custom-model"})
|
||||
m.modelIdx = modelTUIIdxForName(t, m, "qwen3.7-max")
|
||||
|
||||
result, _ := m.Update(dKey())
|
||||
m2 := result.(modelTUIModel)
|
||||
if m2.confirmingDeleteModel {
|
||||
t.Error("pressing d on built-in model should not trigger delete confirmation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsUserAddedOfficialModelName_RegistryDuplicateNotUserAdded(t *testing.T) {
|
||||
preset, ok := llm.LookupProvider("dashscope")
|
||||
if !ok {
|
||||
t.Skip("dashscope provider not in registry")
|
||||
}
|
||||
cfg := &Config{
|
||||
Providers: map[string]ProviderEntry{
|
||||
"dashscope": {Models: []string{"qwen3.7-max", "my-custom-model"}},
|
||||
},
|
||||
}
|
||||
if isUserAddedOfficialModelName("qwen3.7-max", "dashscope", preset.Models, cfg) {
|
||||
t.Error("registry model should not be treated as user-added even when listed in config")
|
||||
}
|
||||
if !isUserAddedOfficialModelName("my-custom-model", "dashscope", preset.Models, cfg) {
|
||||
t.Error("config-only model should be user-added")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelTUI_Official_RegistryModelNotDeletable(t *testing.T) {
|
||||
m := officialConfigModelTUI(t, "", []string{"my-custom-model"})
|
||||
m.modelIdx = modelTUIIdxForName(t, m, "qwen3.7-max")
|
||||
|
||||
if m.isUserAddedModel("qwen3.7-max") {
|
||||
t.Error("qwen3.7-max should not be user-added when it is in the registry")
|
||||
}
|
||||
|
||||
result, _ := m.Update(dKey())
|
||||
m2 := result.(modelTUIModel)
|
||||
if m2.confirmingDeleteModel {
|
||||
t.Error("pressing d on registry model should not trigger delete confirmation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelTUI_Official_DeleteOnCustomModelInputIgnored(t *testing.T) {
|
||||
m := officialConfigModelTUI(t, "", []string{"my-custom-model"})
|
||||
m.modelIdx = len(m.displayModels())
|
||||
|
||||
result, _ := m.Update(dKey())
|
||||
m2 := result.(modelTUIModel)
|
||||
if m2.confirmingDeleteModel {
|
||||
t.Error("pressing d on Enter custom model name... should not trigger delete confirmation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelTUI_CustomProvider_DeleteOnCustomModelInputIgnored(t *testing.T) {
|
||||
m := customConfigModelTUI(t, "", []string{"m1", "aaa"})
|
||||
m.modelIdx = len(m.displayModels())
|
||||
|
||||
result, _ := m.Update(dKey())
|
||||
m2 := result.(modelTUIModel)
|
||||
if m2.confirmingDeleteModel {
|
||||
t.Error("pressing d on Enter custom model name... should not trigger delete confirmation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelTUI_Official_UserAddedModelShowsDeleteHint(t *testing.T) {
|
||||
m := officialConfigModelTUI(t, "", []string{"my-custom-model"})
|
||||
|
||||
m.modelIdx = modelTUIIdxForName(t, m, "qwen3.7-max")
|
||||
got := stripANSI(m.View().Content)
|
||||
if strings.Contains(got, "d Delete") {
|
||||
t.Errorf("built-in model should not show d Delete hint; got:\n%s", got)
|
||||
}
|
||||
|
||||
m.modelIdx = modelTUIIdxForName(t, m, "my-custom-model")
|
||||
got = stripANSI(m.View().Content)
|
||||
if !strings.Contains(got, "d Delete") {
|
||||
t.Errorf("user-added model should show d Delete hint; got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelTUI_CustomProvider_ShowsDeleteHint(t *testing.T) {
|
||||
m := customConfigModelTUI(t, "", []string{"m1", "aaa"})
|
||||
|
||||
m.modelIdx = len(m.displayModels())
|
||||
got := stripANSI(m.View().Content)
|
||||
if strings.Contains(got, "d Delete") {
|
||||
t.Errorf("custom input row should not show d Delete hint; got:\n%s", got)
|
||||
}
|
||||
|
||||
m.modelIdx = modelTUIIdxForName(t, m, "aaa")
|
||||
got = stripANSI(m.View().Content)
|
||||
if !strings.Contains(got, "d Delete") {
|
||||
t.Errorf("custom model row should show d Delete hint; got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelTUI_CustomProvider_DeleteClearsCustomInput(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "config.json")
|
||||
m := customConfigModelTUI(t, configPath, []string{"m1", "aaa"})
|
||||
m.modelIdx = modelTUIIdxForName(t, m, "aaa")
|
||||
m.customModel = true
|
||||
m.modelInput.SetValue("aaa")
|
||||
m.deleteModelName = "aaa"
|
||||
m.confirmingDeleteModel = true
|
||||
|
||||
result, _ := m.confirmDeleteCustomProviderModel()
|
||||
m2 := asModelTUIModel(t, result)
|
||||
|
||||
if m2.customModel {
|
||||
t.Error("customModel should be false after delete")
|
||||
}
|
||||
if m2.modelInput.Value() != "" {
|
||||
t.Errorf("modelInput = %q, want empty after delete", m2.modelInput.Value())
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelTUI_CustomProvider_DeleteModelViaDKey(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "config.json")
|
||||
m := customConfigModelTUI(t, configPath, []string{"m1", "aaa"})
|
||||
m.modelIdx = modelTUIIdxForName(t, m, "aaa")
|
||||
|
||||
result, _ := m.Update(dKey())
|
||||
m2 := result.(modelTUIModel)
|
||||
if !m2.confirmingDeleteModel || m2.deleteModelName != "aaa" {
|
||||
t.Fatalf("after d: confirming=%v deleteModelName=%q", m2.confirmingDeleteModel, m2.deleteModelName)
|
||||
}
|
||||
|
||||
result, _ = m2.Update(yKey())
|
||||
m3 := result.(modelTUIModel)
|
||||
got := m3.existingCfg.CustomProviders["my-llm"].Models
|
||||
if len(got) != 1 || got[0] != "m1" {
|
||||
t.Errorf("Models = %v, want [m1]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelTUI_CustomProvider_DeleteCancel(t *testing.T) {
|
||||
m := customConfigModelTUI(t, "", []string{"m1", "aaa"})
|
||||
m.modelIdx = modelTUIIdxForName(t, m, "aaa")
|
||||
|
||||
result, _ := m.Update(dKey())
|
||||
m2 := result.(modelTUIModel)
|
||||
result, _ = m2.Update(nKey())
|
||||
m3 := result.(modelTUIModel)
|
||||
if m3.confirmingDeleteModel {
|
||||
t.Error("confirmingDeleteModel should be false after n")
|
||||
}
|
||||
got := m3.existingCfg.CustomProviders["my-llm"].Models
|
||||
if len(got) != 2 {
|
||||
t.Errorf("Models = %v, want unchanged", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelTUI_Official_DeleteCancel(t *testing.T) {
|
||||
cancelKeys := []struct {
|
||||
name string
|
||||
key tea.KeyPressMsg
|
||||
}{
|
||||
{"n", nKey()},
|
||||
{"esc", escKey()},
|
||||
}
|
||||
for _, tc := range cancelKeys {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
m := officialConfigModelTUI(t, "", []string{"my-custom-model"})
|
||||
m.modelIdx = modelTUIIdxForName(t, m, "my-custom-model")
|
||||
|
||||
result, _ := m.Update(dKey())
|
||||
m2 := result.(modelTUIModel)
|
||||
if !m2.confirmingDeleteModel {
|
||||
t.Fatal("expected confirmingDeleteModel after d")
|
||||
}
|
||||
|
||||
result, _ = m2.Update(tc.key)
|
||||
m3 := result.(modelTUIModel)
|
||||
if m3.confirmingDeleteModel {
|
||||
t.Error("confirmingDeleteModel should be false after cancel")
|
||||
}
|
||||
got := m3.existingCfg.Providers["dashscope"].Models
|
||||
if len(got) != 2 || got[1] != "my-custom-model" {
|
||||
t.Errorf("Models = %v, want model unchanged", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelTUI_Official_DeleteActiveUserModelClearsCfg(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "config.json")
|
||||
m := officialConfigModelTUI(t, configPath, []string{"my-custom-model"})
|
||||
m.existingCfg.Model = "my-custom-model"
|
||||
m.existingCfg.Providers["dashscope"] = ProviderEntry{
|
||||
Model: "my-custom-model",
|
||||
Models: []string{"qwen3.7-max", "my-custom-model"},
|
||||
}
|
||||
m.modelIdx = modelTUIIdxForName(t, m, "my-custom-model")
|
||||
|
||||
result, _ := m.Update(dKey())
|
||||
m2 := result.(modelTUIModel)
|
||||
result, _ = m2.Update(yKey())
|
||||
m3 := result.(modelTUIModel)
|
||||
|
||||
if m3.existingCfg.Providers["dashscope"].Model != "" {
|
||||
t.Errorf("entry.Model = %q, want empty", m3.existingCfg.Providers["dashscope"].Model)
|
||||
}
|
||||
if m3.existingCfg.Model != "" {
|
||||
t.Errorf("cfg.Model = %q, want empty", m3.existingCfg.Model)
|
||||
}
|
||||
}
|
||||
|
||||
// --- result() tests ---
|
||||
|
||||
func TestResult_OfficialTab(t *testing.T) {
|
||||
|
|
@ -1077,6 +1593,269 @@ func TestSyncSessionModelSelection_EmptyModel(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestSyncSessionModelSelection_CrossOfficialProviderNoPersist(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "config.json")
|
||||
cfg := &Config{
|
||||
Provider: "deepseek",
|
||||
Model: "deepseek-v4-flash",
|
||||
Providers: map[string]ProviderEntry{
|
||||
"deepseek": {Model: "deepseek-v4-flash"},
|
||||
},
|
||||
}
|
||||
m := newProviderTUI(cfg, configPath)
|
||||
m.activeTab = tabOfficial
|
||||
for i, p := range m.providers {
|
||||
if p.Name == "baidu-qianfan" {
|
||||
m.officialIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
m.modelIdx = 0
|
||||
for i, name := range m.models() {
|
||||
if name == "glm-5" {
|
||||
m.modelIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if err := m.syncSessionModelSelection(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if m.savedInSession {
|
||||
t.Error("savedInSession should be false when browsing a non-active provider")
|
||||
}
|
||||
if _, err := os.Stat(configPath); err == nil {
|
||||
t.Fatal("config file should not be written for cross-provider navigation")
|
||||
}
|
||||
if got := cfg.Providers["baidu-qianfan"].Model; got != "" {
|
||||
t.Errorf("in-memory baidu model = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSessionModelSelection_ActiveOfficialProviderDefersPersist(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "config.json")
|
||||
cfg := &Config{
|
||||
Provider: "deepseek",
|
||||
Model: "deepseek-v4-flash",
|
||||
Providers: map[string]ProviderEntry{
|
||||
"deepseek": {Model: "deepseek-v4-flash"},
|
||||
},
|
||||
}
|
||||
m := newProviderTUI(cfg, configPath)
|
||||
m.activeTab = tabOfficial
|
||||
for i, name := range m.models() {
|
||||
if name == "deepseek-v4-pro" {
|
||||
m.modelIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if err := m.syncSessionModelSelection(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if m.savedInSession {
|
||||
t.Error("savedInSession should be false before wizard confirm")
|
||||
}
|
||||
if got := m.sessionModelPick["deepseek"]; got != "deepseek-v4-pro" {
|
||||
t.Errorf("sessionModelPick = %q, want deepseek-v4-pro", got)
|
||||
}
|
||||
if _, err := os.Stat(configPath); err == nil {
|
||||
t.Fatal("config file should not be written before wizard confirm")
|
||||
}
|
||||
if cfg.Model != "deepseek-v4-flash" {
|
||||
t.Errorf("cfg.Model = %q, want deepseek-v4-flash", cfg.Model)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSessionModelSelection_CrossCustomProviderNoPersist(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "config.json")
|
||||
cfg := &Config{
|
||||
Provider: "stepfun",
|
||||
Model: "step-3.5-flash",
|
||||
CustomProviders: map[string]ProviderEntry{
|
||||
"stepfun": {
|
||||
URL: "https://api.stepfun.com/v1",
|
||||
Model: "step-3.5-flash",
|
||||
Models: []string{"step-3.5-flash", "step-3.7-flash"},
|
||||
},
|
||||
"other": {
|
||||
URL: "https://example.com/v1",
|
||||
Model: "step-3.7-flash",
|
||||
Models: []string{"step-3.7-flash"},
|
||||
},
|
||||
},
|
||||
}
|
||||
m := newProviderTUI(cfg, configPath)
|
||||
m.activeTab = tabCustom
|
||||
for i, cp := range m.customProviders {
|
||||
if cp.name == "other" {
|
||||
m.customIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
m.modelIdx = 0
|
||||
|
||||
if err := m.syncSessionModelSelection(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if m.savedInSession {
|
||||
t.Error("savedInSession should be false when browsing a non-active custom provider")
|
||||
}
|
||||
if _, err := os.Stat(configPath); err == nil {
|
||||
t.Fatal("config file should not be written for cross-provider navigation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSessionModelSelection_RecordsSessionPickForInactiveOfficialProvider(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "config.json")
|
||||
cfg := &Config{
|
||||
Provider: "deepseek",
|
||||
Model: "deepseek-v4-flash",
|
||||
Providers: map[string]ProviderEntry{
|
||||
"deepseek": {Model: "deepseek-v4-flash"},
|
||||
},
|
||||
}
|
||||
m := newProviderTUI(cfg, configPath)
|
||||
m.activeTab = tabOfficial
|
||||
for i, p := range m.providers {
|
||||
if p.Name == "baidu-qianfan" {
|
||||
m.officialIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
for i, name := range m.models() {
|
||||
if name == "glm-5" {
|
||||
m.modelIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if err := m.syncSessionModelSelection(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if m.savedInSession {
|
||||
t.Error("savedInSession should be false for inactive provider")
|
||||
}
|
||||
if got := m.sessionModelPick["baidu-qianfan"]; got != "glm-5" {
|
||||
t.Errorf("sessionModelPick = %q, want glm-5", got)
|
||||
}
|
||||
if _, err := os.Stat(configPath); err == nil {
|
||||
t.Fatal("config file should not be written for inactive provider")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderTUI_ResultUsesSessionModelPickWhenSelectionEmpty(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Provider: "deepseek",
|
||||
Model: "deepseek-v4-flash",
|
||||
Providers: map[string]ProviderEntry{
|
||||
"deepseek": {Model: "deepseek-v4-flash"},
|
||||
},
|
||||
}
|
||||
m := newProviderTUI(cfg, "")
|
||||
m.activeTab = tabOfficial
|
||||
for i, p := range m.providers {
|
||||
if p.Name == "baidu-qianfan" {
|
||||
m.officialIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
m.sessionModelPick = map[string]string{"baidu-qianfan": "glm-5"}
|
||||
m.modelIdx = 9999 // force selectedModelFromState() empty
|
||||
|
||||
r := m.result()
|
||||
if r.model != "glm-5" {
|
||||
t.Errorf("result().model = %q, want glm-5", r.model)
|
||||
}
|
||||
if got := r.resolvedModel(); got != "glm-5" {
|
||||
t.Errorf("resolvedModel() = %q, want glm-5", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiKeyStepCanConfirm_OfficialEmptyWithoutEnv(t *testing.T) {
|
||||
t.Setenv("DEEPSEEK_API_KEY", "")
|
||||
cfg := &Config{
|
||||
Provider: "deepseek",
|
||||
Model: "deepseek-v4-flash",
|
||||
Providers: map[string]ProviderEntry{
|
||||
"deepseek": {Model: "deepseek-v4-flash"},
|
||||
},
|
||||
}
|
||||
m := newProviderTUI(cfg, "")
|
||||
m.activeTab = tabOfficial
|
||||
m.step = stepAPIKey
|
||||
|
||||
ok, errMsg := m.apiKeyStepCanConfirm()
|
||||
if ok {
|
||||
t.Fatal("expected confirmation to be blocked")
|
||||
}
|
||||
if errMsg != "API key is required (or set $DEEPSEEK_API_KEY)" {
|
||||
t.Errorf("errMsg = %q", errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiKeyStepCanConfirm_OfficialEmptyWithEnv(t *testing.T) {
|
||||
t.Setenv("DEEPSEEK_API_KEY", "sk-from-env")
|
||||
cfg := &Config{
|
||||
Provider: "deepseek",
|
||||
Model: "deepseek-v4-flash",
|
||||
Providers: map[string]ProviderEntry{
|
||||
"deepseek": {Model: "deepseek-v4-flash"},
|
||||
},
|
||||
}
|
||||
m := newProviderTUI(cfg, "")
|
||||
m.activeTab = tabOfficial
|
||||
m.step = stepAPIKey
|
||||
|
||||
ok, errMsg := m.apiKeyStepCanConfirm()
|
||||
if !ok {
|
||||
t.Fatalf("expected confirmation allowed, errMsg = %q", errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiKeyStepCanConfirm_CustomEmpty(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Provider: "stepfun",
|
||||
CustomProviders: map[string]ProviderEntry{
|
||||
"stepfun": {APIKey: ""},
|
||||
},
|
||||
}
|
||||
m := newProviderTUI(cfg, "")
|
||||
m.activeTab = tabCustom
|
||||
m.customIdx = 0
|
||||
m.step = stepAPIKey
|
||||
|
||||
ok, errMsg := m.apiKeyStepCanConfirm()
|
||||
if ok {
|
||||
t.Fatal("expected confirmation to be blocked")
|
||||
}
|
||||
if errMsg != "API key is required" {
|
||||
t.Errorf("errMsg = %q", errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiKeyStepCanConfirm_MaskedSavedKey(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Provider: "deepseek",
|
||||
Providers: map[string]ProviderEntry{
|
||||
"deepseek": {APIKey: "keep-me"},
|
||||
},
|
||||
}
|
||||
m := newProviderTUI(cfg, "")
|
||||
m.activeTab = tabOfficial
|
||||
m.step = stepAPIKey
|
||||
m.loadExistingAPIKey()
|
||||
|
||||
ok, errMsg := m.apiKeyStepCanConfirm()
|
||||
if !ok {
|
||||
t.Fatalf("expected confirmation allowed, errMsg = %q", errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// --- viewCustomProviderForm field steps ---
|
||||
|
||||
func TestProviderTUIView_CustomForm_AllSteps(t *testing.T) {
|
||||
|
|
@ -1146,15 +1925,23 @@ func TestProviderTUIView_ManualTab_WithExistingConfig(t *testing.T) {
|
|||
func TestProviderTUIView_StepModel_CustomTabDeleteHelp(t *testing.T) {
|
||||
cfg := &Config{
|
||||
CustomProviders: map[string]ProviderEntry{
|
||||
"cp": {URL: "http://cp", Models: []string{"m1"}},
|
||||
"cp": {URL: "http://cp", Models: []string{"m1", "m2"}},
|
||||
},
|
||||
}
|
||||
m := newProviderTUI(cfg, "")
|
||||
m.step = stepModel
|
||||
m.activeTab = tabCustom
|
||||
m.customIdx = 0
|
||||
v := m.View()
|
||||
if !strings.Contains(v.Content, "Delete") {
|
||||
t.Errorf("expected delete help for custom tab model view")
|
||||
|
||||
m.modelIdx = len(m.models())
|
||||
got := stripANSI(m.View().Content)
|
||||
if strings.Contains(got, "d Delete") {
|
||||
t.Errorf("custom input row should not show d Delete hint; got:\n%s", got)
|
||||
}
|
||||
|
||||
m.modelIdx = 0
|
||||
got = stripANSI(m.View().Content)
|
||||
if !strings.Contains(got, "d Delete") {
|
||||
t.Errorf("custom model row should show d Delete hint; got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -11,8 +11,11 @@ import (
|
|||
|
||||
"github.com/open-code-review/open-code-review/internal/agent"
|
||||
"github.com/open-code-review/open-code-review/internal/mcp"
|
||||
"github.com/open-code-review/open-code-review/internal/session"
|
||||
"github.com/open-code-review/open-code-review/internal/telemetry"
|
||||
"github.com/open-code-review/open-code-review/internal/tool"
|
||||
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
)
|
||||
|
||||
func runReview(args []string) error {
|
||||
|
|
@ -44,10 +47,26 @@ func runReview(args []string) error {
|
|||
}
|
||||
}
|
||||
|
||||
// Only touch the background when --background-file is set, so the existing
|
||||
// --background behaviour (raw, unsanitised) is preserved for users who do
|
||||
// not opt into the file-based context.
|
||||
if opts.backgroundFile != "" {
|
||||
fileBackground, err := loadBackgroundFile(opts.backgroundFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opts.background = mergeBackground(opts.background, fileBackground)
|
||||
}
|
||||
|
||||
if opts.preview {
|
||||
return runPreview(cc, opts)
|
||||
}
|
||||
|
||||
resumeState, err := loadReviewResumeState(cc.RepoDir, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rt, err := loadLLMRuntime(cc.Template, opts.toolConfigPath, opts.model)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -81,6 +100,7 @@ func runReview(args []string) error {
|
|||
From: opts.from,
|
||||
To: opts.to,
|
||||
Commit: opts.commit,
|
||||
ReviewMode: reviewModeFromOptions(opts),
|
||||
Template: *cc.Template,
|
||||
SystemRule: cc.Resolver,
|
||||
FileFilter: cc.FileFilter,
|
||||
|
|
@ -95,6 +115,7 @@ func runReview(args []string) error {
|
|||
Model: rt.Model,
|
||||
Background: opts.background,
|
||||
GitRunner: cc.GitRunner,
|
||||
Resume: resumeState,
|
||||
})
|
||||
|
||||
// Silence progress output during execution; restored before the trace
|
||||
|
|
@ -104,34 +125,75 @@ func runReview(args []string) error {
|
|||
|
||||
ctx, span := telemetry.StartSpan(context.Background(), "review.run")
|
||||
defer span.End()
|
||||
telemetry.SetAttr(span, "review.repo", cc.RepoDir)
|
||||
telemetry.SetAttr(span, "review.from", opts.from)
|
||||
telemetry.SetAttr(span, "review.to", opts.to)
|
||||
telemetry.SetAttr(span, "review.model", rt.Model)
|
||||
var traceID string
|
||||
if telemetry.IsEnabled() {
|
||||
traceID = telemetry.TraceIDFromContext(ctx)
|
||||
if opts.outputFormat != "json" {
|
||||
fmt.Fprintf(os.Stderr, "[ocr] TraceID: %s\n", traceID)
|
||||
}
|
||||
}
|
||||
startTime := time.Now()
|
||||
|
||||
comments, err := ag.Run(ctx)
|
||||
if err != nil {
|
||||
telemetry.SetAttr(span, "error", err.Error())
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
span.RecordError(err)
|
||||
if id := ag.SessionID(); id != "" {
|
||||
fmt.Fprintf(os.Stderr, "[ocr] Session: %s (retry with: --resume %s)\n", id, id)
|
||||
}
|
||||
return fmt.Errorf("review failed: %w", err)
|
||||
}
|
||||
|
||||
return emitRunResult(ctx, ag, comments, startTime, opts.outputFormat, opts.audience, q)
|
||||
}
|
||||
|
||||
func resolveRepoDir(input string) (string, error) {
|
||||
if input == "" {
|
||||
var err error
|
||||
input, err = os.Getwd()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get working directory: %w", err)
|
||||
}
|
||||
func loadReviewResumeState(repoDir string, opts reviewOptions) (*session.ResumeState, error) {
|
||||
if opts.resume == "" {
|
||||
return nil, nil
|
||||
}
|
||||
absPath, err := filepath.Abs(input)
|
||||
current := session.SessionOptions{
|
||||
ReviewMode: reviewModeFromOptions(opts),
|
||||
DiffFrom: opts.from,
|
||||
DiffTo: opts.to,
|
||||
DiffCommit: opts.commit,
|
||||
}
|
||||
if current.ReviewMode == session.ReviewModeWorkspace {
|
||||
return nil, fmt.Errorf("resume requires --from/--to or --commit; workspace resume is not supported")
|
||||
}
|
||||
state, err := session.LoadResumeState(repoDir, opts.resume)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve absolute path: %w", err)
|
||||
return nil, fmt.Errorf("load resume session: %w (run 'ocr session list' to see available sessions)", err)
|
||||
}
|
||||
out, err := runGitCmd(absPath, "rev-parse", "--git-dir")
|
||||
if err != nil || len(out) == 0 {
|
||||
return "", fmt.Errorf("%s is not a git repository", absPath)
|
||||
if err := state.ValidateOptions(current); err != nil {
|
||||
return nil, fmt.Errorf("%w (run 'ocr session list' to see available sessions)", err)
|
||||
}
|
||||
return absPath, nil
|
||||
if state.CompletedCount() == 0 {
|
||||
return nil, fmt.Errorf("resume session %q has no completed review items (run 'ocr session list' to see available sessions)", opts.resume)
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func reviewModeFromOptions(opts reviewOptions) string {
|
||||
if opts.commit != "" {
|
||||
return session.ReviewModeCommit
|
||||
}
|
||||
if opts.from != "" && opts.to != "" {
|
||||
return session.ReviewModeRange
|
||||
}
|
||||
return session.ReviewModeWorkspace
|
||||
}
|
||||
|
||||
// resolveRepoDir resolves the repo dir for `ocr rules check`. It delegates to
|
||||
// resolveWorkingDir(requireGit=true) so it anchors at the git top-level just
|
||||
// like the review path — keeping rule resolution consistent when run from a
|
||||
// monorepo subdirectory (#287).
|
||||
func resolveRepoDir(input string) (string, error) {
|
||||
absPath, _, err := resolveWorkingDir(input, true)
|
||||
return absPath, err
|
||||
}
|
||||
|
||||
// requireGitRepo validates that the given directory is part of a git repository.
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package main
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
|
|
@ -11,6 +12,8 @@ import (
|
|||
"github.com/open-code-review/open-code-review/internal/scan"
|
||||
"github.com/open-code-review/open-code-review/internal/telemetry"
|
||||
"github.com/open-code-review/open-code-review/internal/tool"
|
||||
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
)
|
||||
|
||||
// scanOptions mirrors reviewOptions for the full-scan subcommand. The two
|
||||
|
|
@ -207,11 +210,22 @@ func runScan(args []string) error {
|
|||
|
||||
ctx, span := telemetry.StartSpan(context.Background(), "scan.run")
|
||||
defer span.End()
|
||||
var traceID string
|
||||
if telemetry.IsEnabled() {
|
||||
traceID = telemetry.TraceIDFromContext(ctx)
|
||||
if opts.outputFormat != "json" {
|
||||
fmt.Fprintf(os.Stderr, "[ocr] TraceID: %s\n", traceID)
|
||||
}
|
||||
}
|
||||
startTime := time.Now()
|
||||
|
||||
comments, err := ag.Run(ctx)
|
||||
if err != nil {
|
||||
telemetry.SetAttr(span, "error", err.Error())
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
span.RecordError(err)
|
||||
if id := ag.SessionID(); id != "" {
|
||||
fmt.Fprintf(os.Stderr, "[ocr] Session: %s\n", id)
|
||||
}
|
||||
return fmt.Errorf("scan failed: %w", err)
|
||||
}
|
||||
|
||||
|
|
|
|||
299
cmd/opencodereview/session_cmd.go
Normal file
299
cmd/opencodereview/session_cmd.go
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/open-code-review/open-code-review/internal/session"
|
||||
)
|
||||
|
||||
func runSession(args []string) error {
|
||||
if len(args) == 0 {
|
||||
printSessionUsage()
|
||||
return nil
|
||||
}
|
||||
switch args[0] {
|
||||
case "list", "ls":
|
||||
return runSessionList(args[1:])
|
||||
case "show":
|
||||
return runSessionShow(args[1:])
|
||||
case "-h", "--help":
|
||||
printSessionUsage()
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("unknown session sub-command: %s\nRun 'ocr session -h' for usage", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
func runSessionList(args []string) error {
|
||||
a := newOcrFlagSet("ocr session list")
|
||||
var repoDir string
|
||||
var asJSON bool
|
||||
var limit int
|
||||
a.StringVar(&repoDir, "repo", "", "root directory of the git repository (default: current dir)")
|
||||
a.BoolVar(&asJSON, "json", false, "emit JSON instead of a table")
|
||||
a.IntVar(&limit, "limit", 20, "cap the number of listed sessions (0 = unlimited)")
|
||||
if err := a.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if a.showHelp {
|
||||
printSessionListUsage()
|
||||
return nil
|
||||
}
|
||||
|
||||
resolvedRepo, err := resolveWorkingDirForSession(repoDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
summaries, err := session.ListSessions(resolvedRepo)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list sessions: %w", err)
|
||||
}
|
||||
if limit > 0 && len(summaries) > limit {
|
||||
summaries = summaries[:limit]
|
||||
}
|
||||
|
||||
if asJSON {
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(summaries)
|
||||
}
|
||||
|
||||
if len(summaries) == 0 {
|
||||
fmt.Printf("No sessions found for %s\n", resolvedRepo)
|
||||
return nil
|
||||
}
|
||||
printSessionTable(os.Stdout, summaries)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runSessionShow(args []string) error {
|
||||
a := newOcrFlagSet("ocr session show")
|
||||
var repoDir string
|
||||
var asJSON bool
|
||||
a.StringVar(&repoDir, "repo", "", "root directory of the git repository (default: current dir)")
|
||||
a.BoolVar(&asJSON, "json", false, "emit JSON instead of a table")
|
||||
if err := a.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if a.showHelp {
|
||||
printSessionShowUsage()
|
||||
return nil
|
||||
}
|
||||
|
||||
rest := a.fs.Args()
|
||||
if len(rest) == 0 {
|
||||
printSessionShowUsage()
|
||||
return fmt.Errorf("session show requires a session ID")
|
||||
}
|
||||
sessionID := rest[0]
|
||||
|
||||
resolvedRepo, err := resolveWorkingDirForSession(repoDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
summary, items, err := session.LoadDetail(resolvedRepo, sessionID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load session %q: %w", sessionID, err)
|
||||
}
|
||||
|
||||
if asJSON {
|
||||
payload := struct {
|
||||
Summary *session.Summary `json:"summary"`
|
||||
Items []session.ItemDetail `json:"items"`
|
||||
}{Summary: summary, Items: items}
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(payload)
|
||||
}
|
||||
|
||||
printSessionDetail(os.Stdout, summary, items)
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveWorkingDirForSession accepts an explicit --repo flag value and falls
|
||||
// back to the current working directory. Unlike resolveRepoDir it does not
|
||||
// require the target to be a git repository, so users can inspect sessions
|
||||
// even after archiving a checkout.
|
||||
func resolveWorkingDirForSession(input string) (string, error) {
|
||||
dir, _, err := resolveWorkingDir(input, false)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
func printSessionTable(w io.Writer, summaries []session.Summary) {
|
||||
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "SESSION ID\tMODE\tRANGE\tFILES\tCOMMENTS\tSTATUS\tSTARTED")
|
||||
for _, s := range summaries {
|
||||
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%d\t%s\t%s\n",
|
||||
s.SessionID,
|
||||
displayMode(s.ReviewMode),
|
||||
describeRange(s),
|
||||
describeFiles(s),
|
||||
s.TotalComments,
|
||||
describeStatus(s),
|
||||
describeStart(s),
|
||||
)
|
||||
}
|
||||
tw.Flush()
|
||||
}
|
||||
|
||||
func printSessionDetail(w io.Writer, s *session.Summary, items []session.ItemDetail) {
|
||||
fmt.Fprintf(w, "Session: %s\n", s.SessionID)
|
||||
fmt.Fprintf(w, " File: %s\n", s.FilePath)
|
||||
fmt.Fprintf(w, " Repo: %s\n", s.RepoDir)
|
||||
if s.GitBranch != "" {
|
||||
fmt.Fprintf(w, " Branch: %s\n", s.GitBranch)
|
||||
}
|
||||
if s.Model != "" {
|
||||
fmt.Fprintf(w, " Model: %s\n", s.Model)
|
||||
}
|
||||
fmt.Fprintf(w, " Mode: %s\n", displayMode(s.ReviewMode))
|
||||
if r := describeRange(*s); r != "" && r != "-" {
|
||||
fmt.Fprintf(w, " Range: %s\n", r)
|
||||
}
|
||||
if s.ResumedFrom != "" {
|
||||
fmt.Fprintf(w, " Resumed: from session %s\n", s.ResumedFrom)
|
||||
}
|
||||
fmt.Fprintf(w, " Started: %s\n", describeStart(*s))
|
||||
if !s.EndTime.IsZero() {
|
||||
fmt.Fprintf(w, " Ended: %s\n", s.EndTime.Local().Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
if s.Duration > 0 {
|
||||
fmt.Fprintf(w, " Duration: %s\n", s.Duration.Round(time.Second))
|
||||
}
|
||||
fmt.Fprintf(w, " Status: %s\n", describeStatus(*s))
|
||||
fmt.Fprintf(w, " Files: %d completed, %d reused, %d failed\n",
|
||||
s.CompletedFiles, s.ReusedFiles, s.FailedFiles)
|
||||
fmt.Fprintf(w, " Comments: %d\n", s.TotalComments)
|
||||
if s.LLMFailures > 0 {
|
||||
fmt.Fprintf(w, " LLM err: %d\n", s.LLMFailures)
|
||||
}
|
||||
|
||||
if len(items) == 0 {
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintln(w, "Files:")
|
||||
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, " TYPE\tFILE\tCOMMENTS\tNOTE")
|
||||
for _, it := range items {
|
||||
note := ""
|
||||
switch it.Type {
|
||||
case "reused":
|
||||
note = "from " + shortSessionID(it.SourceSessionID)
|
||||
case "failed":
|
||||
note = truncate(it.Error, 60)
|
||||
}
|
||||
fmt.Fprintf(tw, " %s\t%s\t%d\t%s\n", it.Type, it.FilePath, it.Comments, note)
|
||||
}
|
||||
tw.Flush()
|
||||
}
|
||||
|
||||
func displayMode(m string) string {
|
||||
if m == "" {
|
||||
return "-"
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func describeRange(s session.Summary) string {
|
||||
switch s.ReviewMode {
|
||||
case session.ReviewModeRange:
|
||||
if s.DiffFrom != "" || s.DiffTo != "" {
|
||||
return fmt.Sprintf("%s..%s", s.DiffFrom, s.DiffTo)
|
||||
}
|
||||
case session.ReviewModeCommit:
|
||||
if s.DiffCommit != "" {
|
||||
return s.DiffCommit
|
||||
}
|
||||
}
|
||||
return "-"
|
||||
}
|
||||
|
||||
func describeFiles(s session.Summary) string {
|
||||
total := s.CompletedFiles + s.ReusedFiles
|
||||
if s.ReusedFiles > 0 {
|
||||
return fmt.Sprintf("%d (reused %d)", total, s.ReusedFiles)
|
||||
}
|
||||
return fmt.Sprintf("%d", total)
|
||||
}
|
||||
|
||||
func describeStatus(s session.Summary) string {
|
||||
if s.Aborted {
|
||||
return "aborted"
|
||||
}
|
||||
if s.FailedFiles > 0 {
|
||||
return fmt.Sprintf("completed (%d fail)", s.FailedFiles)
|
||||
}
|
||||
return "completed"
|
||||
}
|
||||
|
||||
func describeStart(s session.Summary) string {
|
||||
if s.StartTime.IsZero() {
|
||||
return "-"
|
||||
}
|
||||
return s.StartTime.Local().Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
func shortSessionID(id string) string {
|
||||
if len(id) > 8 {
|
||||
return id[:8]
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
s = strings.ReplaceAll(strings.ReplaceAll(s, "\n", " "), "\t", " ")
|
||||
runes := []rune(s)
|
||||
if len(runes) <= n {
|
||||
return s
|
||||
}
|
||||
if n <= 1 {
|
||||
return "…"
|
||||
}
|
||||
return string(runes[:n-1]) + "…"
|
||||
}
|
||||
|
||||
func printSessionUsage() {
|
||||
fmt.Println(`Usage:
|
||||
ocr session <sub-command>
|
||||
|
||||
Sub-commands:
|
||||
list, ls List recent review sessions for the current repo
|
||||
show <id> 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 <id>'.
|
||||
|
||||
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] <session-id>
|
||||
|
||||
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`)
|
||||
}
|
||||
170
cmd/opencodereview/session_cmd_test.go
Normal file
170
cmd/opencodereview/session_cmd_test.go
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/open-code-review/open-code-review/internal/model"
|
||||
"github.com/open-code-review/open-code-review/internal/session"
|
||||
)
|
||||
|
||||
func TestRunSessionList_TextIncludesSessionID(t *testing.T) {
|
||||
tmpHome := t.TempDir()
|
||||
t.Setenv("HOME", tmpHome)
|
||||
repoDir := t.TempDir()
|
||||
|
||||
sh := session.New(repoDir, "main", "test-model", session.SessionOptions{
|
||||
ReviewMode: session.ReviewModeCommit,
|
||||
DiffCommit: "abc123",
|
||||
})
|
||||
sh.RecordReviewItemDone("a.go", "a.go", "a.go", "fp-a", []model.LlmComment{{Path: "a.go", Content: "note"}})
|
||||
sh.Finalize()
|
||||
|
||||
got := captureStdout(t, func() {
|
||||
if err := runSessionList([]string{"--repo", repoDir}); err != nil {
|
||||
t.Fatalf("runSessionList: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
if !strings.Contains(got, sh.SessionID) {
|
||||
t.Errorf("expected list output to contain session id %s, got %q", sh.SessionID, got)
|
||||
}
|
||||
if !strings.Contains(got, "abc123") {
|
||||
t.Errorf("expected list output to contain commit range, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "SESSION ID") {
|
||||
t.Errorf("expected header, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSessionList_JSON(t *testing.T) {
|
||||
tmpHome := t.TempDir()
|
||||
t.Setenv("HOME", tmpHome)
|
||||
repoDir := t.TempDir()
|
||||
|
||||
sh := session.New(repoDir, "main", "test-model", session.SessionOptions{
|
||||
ReviewMode: session.ReviewModeCommit,
|
||||
DiffCommit: "abc123",
|
||||
})
|
||||
sh.RecordReviewItemDone("a.go", "a.go", "a.go", "fp-a", nil)
|
||||
sh.Finalize()
|
||||
|
||||
got := captureStdout(t, func() {
|
||||
if err := runSessionList([]string{"--repo", repoDir, "--json"}); err != nil {
|
||||
t.Fatalf("runSessionList: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
var decoded []session.Summary
|
||||
if err := json.Unmarshal([]byte(got), &decoded); err != nil {
|
||||
t.Fatalf("unmarshal: %v (out=%q)", err, got)
|
||||
}
|
||||
if len(decoded) != 1 || decoded[0].SessionID != sh.SessionID {
|
||||
t.Fatalf("decoded = %+v", decoded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSessionList_EmptyRepo(t *testing.T) {
|
||||
tmpHome := t.TempDir()
|
||||
t.Setenv("HOME", tmpHome)
|
||||
repoDir := t.TempDir()
|
||||
|
||||
got := captureStdout(t, func() {
|
||||
if err := runSessionList([]string{"--repo", repoDir}); err != nil {
|
||||
t.Fatalf("runSessionList: %v", err)
|
||||
}
|
||||
})
|
||||
if !strings.Contains(got, "No sessions found") {
|
||||
t.Errorf("expected empty message, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSessionShow_Text(t *testing.T) {
|
||||
tmpHome := t.TempDir()
|
||||
t.Setenv("HOME", tmpHome)
|
||||
repoDir := t.TempDir()
|
||||
|
||||
sh := session.New(repoDir, "main", "test-model", session.SessionOptions{
|
||||
ReviewMode: session.ReviewModeCommit,
|
||||
DiffCommit: "abc123",
|
||||
})
|
||||
sh.RecordReviewItemDone("a.go", "a.go", "a.go", "fp-a", []model.LlmComment{{Path: "a.go", Content: "note"}})
|
||||
sh.RecordReviewItemFailed("bad.go", "bad.go", "bad.go", "fp-bad", "boom")
|
||||
sh.Finalize()
|
||||
|
||||
got := captureStdout(t, func() {
|
||||
if err := runSessionShow([]string{"--repo", repoDir, sh.SessionID}); err != nil {
|
||||
t.Fatalf("runSessionShow: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
for _, want := range []string{sh.SessionID, "abc123", "a.go", "bad.go", "boom", "Files:"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("expected output to contain %q, got %q", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSessionShow_JSON(t *testing.T) {
|
||||
tmpHome := t.TempDir()
|
||||
t.Setenv("HOME", tmpHome)
|
||||
repoDir := t.TempDir()
|
||||
|
||||
sh := session.New(repoDir, "main", "test-model", session.SessionOptions{
|
||||
ReviewMode: session.ReviewModeCommit,
|
||||
DiffCommit: "abc123",
|
||||
})
|
||||
sh.RecordReviewItemDone("a.go", "a.go", "a.go", "fp-a", nil)
|
||||
sh.Finalize()
|
||||
|
||||
got := captureStdout(t, func() {
|
||||
if err := runSessionShow([]string{"--repo", repoDir, "--json", sh.SessionID}); err != nil {
|
||||
t.Fatalf("runSessionShow: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
var payload struct {
|
||||
Summary *session.Summary `json:"summary"`
|
||||
Items []session.ItemDetail `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(got), &payload); err != nil {
|
||||
t.Fatalf("unmarshal: %v (out=%q)", err, got)
|
||||
}
|
||||
if payload.Summary == nil || payload.Summary.SessionID != sh.SessionID {
|
||||
t.Fatalf("summary mismatch: %+v", payload.Summary)
|
||||
}
|
||||
if len(payload.Items) != 1 || payload.Items[0].FilePath != "a.go" {
|
||||
t.Fatalf("items = %+v", payload.Items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSessionShow_MissingID(t *testing.T) {
|
||||
tmpHome := t.TempDir()
|
||||
t.Setenv("HOME", tmpHome)
|
||||
got := captureStdout(t, func() {
|
||||
if err := runSessionShow([]string{}); err == nil {
|
||||
t.Fatal("expected error for missing session id")
|
||||
}
|
||||
})
|
||||
if !strings.Contains(got, "session show") {
|
||||
t.Errorf("expected usage output, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateUnicode(t *testing.T) {
|
||||
got := truncate("错误原因:超过限制", 6)
|
||||
if !strings.HasSuffix(got, "…") {
|
||||
t.Fatalf("expected ellipsis suffix, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "错误") {
|
||||
t.Fatalf("expected valid truncated unicode text, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSession_UnknownSubcommand(t *testing.T) {
|
||||
err := runSession([]string{"bogus"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown sub-command")
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/open-code-review/open-code-review/internal/agent"
|
||||
|
|
@ -100,6 +101,25 @@ func resolveWorkingDir(input string, requireGit bool) (string, bool, error) {
|
|||
if !isGit && requireGit {
|
||||
return "", false, fmt.Errorf("%s is not a git repository", absPath)
|
||||
}
|
||||
// #287: git reports diff and `git show HEAD:<path>` paths relative to the
|
||||
// repository root, not the current directory. When `ocr review` runs from a
|
||||
// subdirectory of a monorepo, anchor RepoDir at the git top-level so those
|
||||
// root-relative paths resolve for both disk reads and git-show reads.
|
||||
// requireGit is true only for the review path; scan (requireGit=false) keeps
|
||||
// the CWD so its `git ls-files` walk stays scoped to the subdirectory.
|
||||
if isGit && requireGit {
|
||||
// runGitCmdStdout captures stdout only so git stderr notices can't
|
||||
// pollute the resolved path. --show-toplevel fails (or is empty) when
|
||||
// there is no work tree — e.g. a bare repo, where --git-dir succeeds so
|
||||
// isGit is true. Fail loudly there instead of silently reusing the
|
||||
// subdir, which would reproduce the #287 root-relative-path bug.
|
||||
top, topErr := runGitCmdStdout(absPath, "rev-parse", "--show-toplevel")
|
||||
t := strings.TrimSpace(string(top))
|
||||
if topErr != nil || t == "" {
|
||||
return "", false, fmt.Errorf("%s is a git repository without a work tree (bare repo?); cannot resolve its top level for review", absPath)
|
||||
}
|
||||
absPath = t
|
||||
}
|
||||
return absPath, isGit, nil
|
||||
}
|
||||
|
||||
|
|
@ -231,6 +251,14 @@ type ResultProvider interface {
|
|||
// that skipped / failed the summary phase.
|
||||
ProjectSummary() string
|
||||
ToolCalls() map[string]int64
|
||||
// SessionID returns the persisted session identifier so callers can show it
|
||||
// in JSON output or failure diagnostics. Returns "" when no session was
|
||||
// created.
|
||||
SessionID() string
|
||||
}
|
||||
|
||||
type resumeInfoProvider interface {
|
||||
ResumeInfo() *agent.ResumeInfo
|
||||
}
|
||||
|
||||
// emitRunResult is the post-LLM-run finalization shared by `ocr review` and
|
||||
|
|
@ -256,8 +284,10 @@ func emitRunResult(
|
|||
telemetry.RecordCommentsGenerated(ctx, int64(len(comments)))
|
||||
}
|
||||
|
||||
traceID := telemetry.TraceIDFromContext(ctx)
|
||||
|
||||
if outputFormat == "json" && len(comments) == 0 && ag.FilesReviewed() == 0 {
|
||||
return outputJSONNoFiles()
|
||||
return outputJSONNoFiles(traceID)
|
||||
}
|
||||
|
||||
// Agent-text audiences need stdout back before PrintTraceSummary so the
|
||||
|
|
@ -273,10 +303,14 @@ func emitRunResult(
|
|||
}
|
||||
|
||||
if outputFormat == "json" {
|
||||
var resumeInfo *agent.ResumeInfo
|
||||
if p, ok := ag.(resumeInfoProvider); ok {
|
||||
resumeInfo = p.ResumeInfo()
|
||||
}
|
||||
return outputJSONWithWarnings(comments, ag.Warnings(), ag.FilesReviewed(),
|
||||
ag.TotalInputTokens(), ag.TotalOutputTokens(), ag.TotalTokensUsed(),
|
||||
ag.TotalCacheReadTokens(), ag.TotalCacheWriteTokens(), duration,
|
||||
ag.ProjectSummary(), ag.ToolCalls())
|
||||
ag.ProjectSummary(), ag.ToolCalls(), traceID, resumeInfo, ag.SessionID())
|
||||
}
|
||||
outputTextWithWarnings(comments, ag.Warnings())
|
||||
if summary := ag.ProjectSummary(); summary != "" {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package main
|
|||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
|
|
@ -78,9 +79,18 @@ func TestQuietHandle_IdempotentRestore(t *testing.T) {
|
|||
|
||||
func TestResolveWorkingDir_CurrentDir(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
origDir, _ := os.Getwd()
|
||||
defer os.Chdir(origDir)
|
||||
os.Chdir(dir)
|
||||
origDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("getwd: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := os.Chdir(origDir); err != nil {
|
||||
t.Errorf("restore chdir: %v", err)
|
||||
}
|
||||
}()
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatalf("chdir: %v", err)
|
||||
}
|
||||
|
||||
absPath, isGit, err := resolveWorkingDir("", false)
|
||||
if err != nil {
|
||||
|
|
@ -109,6 +119,89 @@ func TestResolveWorkingDir_NonExistent(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestResolveWorkingDir_MonorepoSubdir reproduces #287: running `ocr review`
|
||||
// from a subdirectory of a git repo must anchor RepoDir at the git top-level
|
||||
// (git reports diff / `git show HEAD:<path>` 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")
|
||||
|
|
|
|||
|
|
@ -6,5 +6,6 @@ This directory contains examples for integrating OpenCodeReview (OCR) into vario
|
|||
|
||||
- **[github_actions/](./github_actions/)** - GitHub Actions integration example
|
||||
- **[gitlab_ci/](./gitlab_ci/)** - GitLab CI integration example
|
||||
- **[gitflic_ci/](./gitflic_ci/)** - GitFlic CI integration example
|
||||
|
||||
Each subdirectory contains its own README with detailed setup instructions.
|
||||
Each subdirectory contains its own README with detailed setup instructions.
|
||||
|
|
|
|||
80
examples/gitflic_ci/README.md
Normal file
80
examples/gitflic_ci/README.md
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
# OpenCodeReview - GitFlic CI Demo
|
||||
|
||||
This demo shows how to integrate OpenCodeReview into a [GitFlic](https://gitflic.ru) CI/CD pipeline to automatically review Merge Requests and post the findings as MR discussions — inline on the changed lines where possible.
|
||||
|
||||
Like the GitHub Actions and GitLab CI examples, the posting glue lives in the CI layer rather than in the `ocr` binary. Here it is a small, dependency-free Python script — [`post_review.py`](post_review.py) — that reads `ocr review --format json` and posts to the GitFlic Discussions API. The only GitFlic-specific wrinkle it handles is the **old-side line**: GitFlic requires it even for a comment on the new side of the diff, and `ocr review` reports new-side positions only, so the script recomputes it from the same merge-base diff the review ran on.
|
||||
|
||||
## How It Works
|
||||
|
||||
```
|
||||
MR Created/Updated → Merge Request Pipeline → ocr review → post_review.py → Discussions on MR
|
||||
```
|
||||
|
||||
1. A Merge Request Pipeline triggers the `code-review` job
|
||||
2. It installs OCR via npm in a `node:20` image (which also ships `python3` and `git`)
|
||||
3. Runs `ocr review --from origin/<target> --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 <owner> --project <project> --mr <id> \
|
||||
--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`.
|
||||
84
examples/gitflic_ci/gitflic-ci.yaml
Normal file
84
examples/gitflic_ci/gitflic-ci.yaml
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
# OpenCodeReview - GitFlic CI Merge Request Auto-Review Demo
|
||||
#
|
||||
# Reviews Merge Requests with OpenCodeReview and posts the findings onto the
|
||||
# MR as discussions (inline where possible). The posting glue lives in the CI
|
||||
# layer, in post_review.py next to this file -- consistent with the GitHub and
|
||||
# GitLab examples, which keep platform-specific publishing out of the `ocr`
|
||||
# binary.
|
||||
#
|
||||
# Setup:
|
||||
# - Commit BOTH this file and post_review.py into your repository (adjust the
|
||||
# `python3 post_review.py` path below if you place the script elsewhere).
|
||||
# - Enable "Merge Request Pipeline" in Project Settings -> CI/CD Settings.
|
||||
# - Use a runner able to run the node:20 image (it ships node, python3 and git),
|
||||
# or any shell runner with node 20+, python3 and git available.
|
||||
#
|
||||
# Required CI/CD Variables (Settings -> CI/CD -> Variables):
|
||||
# OCR_LLM_URL - LLM API endpoint (e.g., https://api.openai.com/v1/chat/completions)
|
||||
# OCR_LLM_AUTH_TOKEN - Authentication token for the LLM API
|
||||
# GITFLIC_TOKEN - GitFlic access token used to post MR discussions
|
||||
#
|
||||
# Optional CI/CD Variables:
|
||||
# OCR_LLM_MODEL - Model name (e.g., gpt-4o)
|
||||
# GITFLIC_API_URL - GitFlic REST API base URL; only needed for self-hosted
|
||||
# instances (defaults to https://api.gitflic.ru)
|
||||
#
|
||||
# post_review.py picks up the MR context automatically from the predefined
|
||||
# GitFlic CI variables: CI_PROJECT_NAMESPACE, CI_PROJECT_NAME,
|
||||
# CI_MERGE_REQUEST_LOCAL_ID, CI_MERGE_REQUEST_TARGET_BRANCH_NAME, CI_COMMIT_SHA.
|
||||
|
||||
stages:
|
||||
- review
|
||||
|
||||
code-review:
|
||||
stage: review
|
||||
image: node:20
|
||||
script:
|
||||
# Run only in merge request pipelines
|
||||
- |
|
||||
if [ -z "$CI_MERGE_REQUEST_LOCAL_ID" ]; then
|
||||
echo "Not a merge request pipeline, skipping review."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Install OpenCodeReview
|
||||
- npm install -g @alibaba-group/open-code-review
|
||||
|
||||
# Configure OCR
|
||||
- |
|
||||
ocr config set llm.url $OCR_LLM_URL
|
||||
ocr config set llm.auth_token $OCR_LLM_AUTH_TOKEN
|
||||
if [ -n "$OCR_LLM_MODEL" ]; then
|
||||
ocr config set llm.model "$OCR_LLM_MODEL"
|
||||
fi
|
||||
ocr config set llm.use_anthropic false
|
||||
ocr config set llm.extra_body '{"thinking": {"type": "disabled"}}'
|
||||
|
||||
# Make sure the target branch and full history are available for merge-base diff
|
||||
- git fetch --unshallow 2>/dev/null || true
|
||||
- git fetch origin "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME"
|
||||
|
||||
# Run OCR review (CI_COMMIT_SHA as head supports forked MRs as well)
|
||||
- |
|
||||
ocr review \
|
||||
--from "origin/${CI_MERGE_REQUEST_TARGET_BRANCH_NAME}" \
|
||||
--to "${CI_COMMIT_SHA}" \
|
||||
--format json \
|
||||
--audience agent \
|
||||
> /tmp/ocr-result.json || true
|
||||
echo "OCR review completed."
|
||||
|
||||
# Post review comments onto the MR (inline discussions + summary note).
|
||||
# post_review.py recomputes the old-side line for each comment from the
|
||||
# merge-base diff, which the GitFlic Discussions API requires for inline
|
||||
# (code) comments.
|
||||
#
|
||||
# The review step above ends with `|| true`, so a failed `ocr review` (bad
|
||||
# token, network error) leaves an empty or partial file. Skip posting in
|
||||
# that case instead of feeding invalid JSON to post_review.py.
|
||||
- |
|
||||
if [ ! -s /tmp/ocr-result.json ]; then
|
||||
echo "OCR review produced no output, skipping post."
|
||||
exit 0
|
||||
fi
|
||||
python3 post_review.py /tmp/ocr-result.json
|
||||
480
examples/gitflic_ci/post_review.py
Normal file
480
examples/gitflic_ci/post_review.py
Normal file
|
|
@ -0,0 +1,480 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Post an OpenCodeReview result onto a GitFlic merge request.
|
||||
|
||||
This is the CI-layer "glue" for GitFlic, mirroring examples/gitlab_ci: it keeps
|
||||
platform-specific publishing out of the `ocr` binary and lives entirely in the
|
||||
pipeline. It reads the JSON emitted by `ocr review --format json` and posts it
|
||||
onto the merge request as discussions:
|
||||
|
||||
- one inline discussion per comment that maps onto the diff,
|
||||
- a single fallback note collecting the comments that do not,
|
||||
- a final summary note.
|
||||
|
||||
GitFlic's Discussions API needs an *old-side* line even for a comment on the new
|
||||
side of the diff: an inline (code) discussion requires all four of
|
||||
newLine/oldLine/newPath/oldPath, otherwise GitFlic silently records a plain
|
||||
comment. `ocr review` only reports new-side positions, so this script computes
|
||||
the old-side line itself by parsing the same merge-base diff the review ran on
|
||||
(`git diff merge-base(from, to)..to`).
|
||||
|
||||
Standard library only (json, urllib, subprocess) so it runs on the stock
|
||||
node:20 / python image used by the pipeline.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from urllib.parse import quote
|
||||
|
||||
# GitFlic SaaS REST API endpoint; override with --api-url / $GITFLIC_API_URL for
|
||||
# self-hosted instances (e.g. http://gitflic.example/rest-api).
|
||||
DEFAULT_API_URL = "https://api.gitflic.ru"
|
||||
|
||||
# Context lines around each hunk; must match what `ocr review` diffs with so the
|
||||
# new-side line numbers in the comments align with the hunks parsed here.
|
||||
DIFF_CONTEXT_LINES = 3
|
||||
|
||||
|
||||
def log(msg):
|
||||
print(msg, file=sys.stderr)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Diff parsing
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
HUNK_CONTEXT, HUNK_ADDED, HUNK_DELETED = range(3)
|
||||
|
||||
_HUNK_HEADER_RE = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@")
|
||||
_DIFF_HEADER_RE = re.compile(r"^diff --git a/(.+?) b/(.+)$")
|
||||
|
||||
|
||||
class Hunk:
|
||||
"""One @@ ... @@ block of a unified diff."""
|
||||
|
||||
__slots__ = ("old_start", "old_count", "new_start", "new_count", "lines")
|
||||
|
||||
def __init__(self, old_start, old_count, new_start, new_count):
|
||||
self.old_start = old_start
|
||||
self.old_count = old_count
|
||||
self.new_start = new_start
|
||||
self.new_count = new_count
|
||||
self.lines = [] # list of (type, content)
|
||||
|
||||
|
||||
class FileDiff:
|
||||
"""A single file's section of a unified diff."""
|
||||
|
||||
__slots__ = ("old_path", "new_path", "is_new", "is_deleted", "is_binary", "text")
|
||||
|
||||
def __init__(self, old_path="", new_path=""):
|
||||
self.old_path = old_path
|
||||
self.new_path = new_path
|
||||
self.is_new = False
|
||||
self.is_deleted = False
|
||||
self.is_binary = False
|
||||
self.text = "" # raw diff body, fed to parse_hunks() on demand
|
||||
|
||||
|
||||
def parse_hunks(raw):
|
||||
"""Parse one file's unified diff text into a list of Hunks.
|
||||
|
||||
Lines before the first @@ header (diff --git, ---, +++) are ignored.
|
||||
"""
|
||||
hunks = []
|
||||
current = None
|
||||
for line in raw.split("\n"):
|
||||
m = _HUNK_HEADER_RE.match(line)
|
||||
if m:
|
||||
if current is not None:
|
||||
hunks.append(current)
|
||||
old_start = int(m.group(1))
|
||||
old_count = int(m.group(2)) if m.group(2) else 1
|
||||
new_start = int(m.group(3))
|
||||
new_count = int(m.group(4)) if m.group(4) else 1
|
||||
current = Hunk(old_start, old_count, new_start, new_count)
|
||||
continue
|
||||
if current is None:
|
||||
continue
|
||||
if line.startswith("\\ No newline at end of file"):
|
||||
continue
|
||||
if line.startswith("diff --git "):
|
||||
break
|
||||
if line.startswith("+"):
|
||||
current.lines.append((HUNK_ADDED, line[1:]))
|
||||
elif line.startswith("-"):
|
||||
current.lines.append((HUNK_DELETED, line[1:]))
|
||||
else:
|
||||
content = line[1:] if line[:1] == " " else line
|
||||
current.lines.append((HUNK_CONTEXT, content))
|
||||
if current is not None:
|
||||
hunks.append(current)
|
||||
return hunks
|
||||
|
||||
|
||||
def parse_diff(diff_text):
|
||||
"""Split combined unified diff text into per-file FileDiff sections."""
|
||||
files = []
|
||||
current = None
|
||||
buf = []
|
||||
|
||||
def flush():
|
||||
if current is not None:
|
||||
current.text = "\n".join(buf)
|
||||
files.append(current)
|
||||
|
||||
for line in diff_text.split("\n"):
|
||||
m = _DIFF_HEADER_RE.match(line)
|
||||
if m:
|
||||
flush()
|
||||
buf = []
|
||||
current = FileDiff(old_path=m.group(1), new_path=m.group(2))
|
||||
if current is None:
|
||||
continue
|
||||
if line.startswith("Binary files ") or line.startswith("GIT binary patch"):
|
||||
current.is_binary = True
|
||||
elif line.startswith("new file mode"):
|
||||
current.is_new = True
|
||||
elif line.startswith("deleted file mode"):
|
||||
current.is_deleted = True
|
||||
elif line.startswith("--- "):
|
||||
path = line[4:]
|
||||
if path == "/dev/null":
|
||||
current.is_new = True
|
||||
current.old_path = "/dev/null"
|
||||
elif path.startswith("a/"):
|
||||
current.old_path = path[2:]
|
||||
elif line.startswith("+++ "):
|
||||
path = line[4:]
|
||||
if path == "/dev/null":
|
||||
current.is_deleted = True
|
||||
current.new_path = "/dev/null"
|
||||
elif path.startswith("b/"):
|
||||
current.new_path = path[2:]
|
||||
buf.append(line)
|
||||
|
||||
flush()
|
||||
return files
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Line mapping (new file side -> old file side)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def clamp_line(n):
|
||||
return 1 if n < 1 else n
|
||||
|
||||
|
||||
def old_line_for(hunks, new_line):
|
||||
"""Map a new-side line number to the corresponding old-side line.
|
||||
|
||||
Lines added by the diff have no old counterpart, so they are anchored to the
|
||||
closest preceding old line -- GitFlic only needs a plausible old-side
|
||||
position to render the code comment next to the insertion point. The result
|
||||
is always >= 1.
|
||||
"""
|
||||
delta = 0 # cumulative (new - old) line-count shift from preceding hunks
|
||||
for h in hunks:
|
||||
if new_line < h.new_start:
|
||||
break
|
||||
if new_line < h.new_start + h.new_count:
|
||||
return _old_line_in_hunk(h, new_line)
|
||||
delta += h.new_count - h.old_count
|
||||
return clamp_line(new_line - delta)
|
||||
|
||||
|
||||
def _old_line_in_hunk(h, new_line):
|
||||
"""Walk a hunk's lines tracking both counters until reaching new_line."""
|
||||
old_ln, new_ln = h.old_start, h.new_start
|
||||
last_old = h.old_start - 1 # last old line seen before the current position
|
||||
for line_type, _content in h.lines:
|
||||
if line_type == HUNK_CONTEXT:
|
||||
if new_ln == new_line:
|
||||
return clamp_line(old_ln)
|
||||
last_old = old_ln
|
||||
old_ln += 1
|
||||
new_ln += 1
|
||||
elif line_type == HUNK_DELETED:
|
||||
last_old = old_ln
|
||||
old_ln += 1
|
||||
elif line_type == HUNK_ADDED:
|
||||
if new_ln == new_line:
|
||||
return clamp_line(last_old)
|
||||
new_ln += 1
|
||||
return clamp_line(last_old)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Comment formatting
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def format_comment(c):
|
||||
"""Render an inline discussion body."""
|
||||
body = c.get("content", "")
|
||||
suggestion = c.get("suggestion_code", "")
|
||||
existing = c.get("existing_code", "")
|
||||
if suggestion and existing:
|
||||
body += "\n\n**Suggestion:**\n```\n" + suggestion + "\n```"
|
||||
return body
|
||||
|
||||
|
||||
def format_comment_fallback(c):
|
||||
"""Render a comment for the fallback (non-inline) note."""
|
||||
md = "### 📄 `%s`" % c.get("path", "")
|
||||
start_line = c.get("start_line", 0)
|
||||
end_line = c.get("end_line", 0)
|
||||
if start_line and end_line:
|
||||
md += " (L%d-L%d)" % (start_line, end_line)
|
||||
md += "\n\n" + c.get("content", "")
|
||||
suggestion = c.get("suggestion_code", "")
|
||||
existing = c.get("existing_code", "")
|
||||
if suggestion and existing:
|
||||
md += "\n\n**Before:**\n```\n" + existing + "\n```\n\n**After:**\n```\n" + suggestion + "\n```"
|
||||
return md
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Publishing (transport-agnostic; `post` does the actual API call)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def publish(result, diffs_by_path, post):
|
||||
"""Post the review result via the `post(discussion)` callable.
|
||||
|
||||
`post` receives a discussion dict and must raise on failure. A general
|
||||
comment carries only "message"; an inline comment also carries
|
||||
newLine/oldLine/newPath/oldPath. Returns {"inline": int, "fallback": int}.
|
||||
"""
|
||||
comments = result.get("comments") or []
|
||||
if not comments:
|
||||
message = result.get("message") or "No comments generated. Looks good to me."
|
||||
post({"message": "✅ **OpenCodeReview**: " + message})
|
||||
return {"inline": 0, "fallback": 0}
|
||||
|
||||
inline = 0
|
||||
failed = []
|
||||
hunks_cache = {}
|
||||
|
||||
for c in comments:
|
||||
path = c.get("path", "")
|
||||
end_line = c.get("end_line", 0) or 0
|
||||
fd = diffs_by_path.get(path)
|
||||
if fd is None:
|
||||
log("no diff for %s; folding comment into the summary note" % path)
|
||||
failed.append(c)
|
||||
continue
|
||||
if fd.is_binary or fd.is_deleted or end_line <= 0:
|
||||
failed.append(c)
|
||||
continue
|
||||
|
||||
old_path = fd.old_path
|
||||
old_line = 1
|
||||
if fd.is_new or old_path == "" or old_path == "/dev/null":
|
||||
# GitFlic has no old side for a new file; anchor to the new path.
|
||||
old_path = fd.new_path
|
||||
else:
|
||||
hunks = hunks_cache.get(path)
|
||||
if hunks is None:
|
||||
hunks = parse_hunks(fd.text)
|
||||
hunks_cache[path] = hunks
|
||||
old_line = old_line_for(hunks, end_line)
|
||||
|
||||
discussion = {
|
||||
"message": format_comment(c),
|
||||
"newLine": end_line,
|
||||
"oldLine": old_line,
|
||||
"newPath": path,
|
||||
"oldPath": old_path,
|
||||
}
|
||||
try:
|
||||
post(discussion)
|
||||
except Exception as e: # noqa: BLE001 - any transport error falls back
|
||||
log("inline comment failed for %s:%d: %s" % (path, end_line, e))
|
||||
failed.append(c)
|
||||
continue
|
||||
inline += 1
|
||||
|
||||
if failed:
|
||||
note = "🔍 **OpenCodeReview** found issues that could not be posted inline:\n\n---\n\n"
|
||||
for c in failed:
|
||||
note += format_comment_fallback(c) + "\n\n---\n\n"
|
||||
post({"message": note})
|
||||
|
||||
summary = "🔍 **OpenCodeReview** found **%d** issue(s) in this MR." % len(comments)
|
||||
summary += "\n- ✅ %d posted as inline comment(s)" % inline
|
||||
summary += "\n- 📝 %d posted as summary (could not be placed inline)" % len(failed)
|
||||
warnings = result.get("warnings") or []
|
||||
if warnings:
|
||||
summary += "\n\n⚠️ %d warning(s) occurred during review." % len(warnings)
|
||||
post({"message": summary})
|
||||
|
||||
return {"inline": inline, "fallback": len(failed)}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# GitFlic REST transport
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def make_poster(api_url, token, owner, project, mr):
|
||||
"""Return a post(discussion) that POSTs to the GitFlic Discussions API."""
|
||||
endpoint = "%s/project/%s/%s/merge-request/%s/discussions/create" % (
|
||||
api_url.rstrip("/"),
|
||||
quote(owner, safe=""),
|
||||
quote(project, safe=""),
|
||||
quote(mr, safe=""),
|
||||
)
|
||||
|
||||
def post(discussion):
|
||||
body = json.dumps(discussion).encode("utf-8")
|
||||
req = urllib.request.Request(endpoint, data=body, method="POST")
|
||||
req.add_header("Authorization", "token " + token)
|
||||
req.add_header("Content-Type", "application/json")
|
||||
try:
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
resp.read()
|
||||
except urllib.error.HTTPError as e:
|
||||
snippet = e.read(512).decode("utf-8", "replace").strip()
|
||||
# Some APIs echo request details back in error bodies; never let the
|
||||
# token reach the CI log if GitFlic does that.
|
||||
if token:
|
||||
snippet = snippet.replace(token, "***")
|
||||
raise RuntimeError("gitflic API %s %s: %s" % (e.code, e.reason, snippet))
|
||||
|
||||
return post
|
||||
|
||||
|
||||
def make_dry_run_poster():
|
||||
"""Return a post(discussion) that prints instead of calling the API."""
|
||||
|
||||
def post(discussion):
|
||||
if discussion.get("newPath") and "newLine" in discussion and "oldLine" in discussion:
|
||||
position = "%s:%d (old %s:%d)" % (
|
||||
discussion["newPath"],
|
||||
discussion["newLine"],
|
||||
discussion.get("oldPath", ""),
|
||||
discussion["oldLine"],
|
||||
)
|
||||
else:
|
||||
position = "general"
|
||||
print("--- dry-run discussion [%s] ---\n%s\n" % (position, discussion["message"]))
|
||||
|
||||
return post
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# git / IO
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _git(repo, *args):
|
||||
return subprocess.run(
|
||||
["git", *args], cwd=repo, check=True, capture_output=True, text=True
|
||||
).stdout
|
||||
|
||||
|
||||
def load_diffs_by_path(repo, from_ref, to_ref):
|
||||
"""Build {new_path: FileDiff} for the merge-base diff `ocr review` ran on."""
|
||||
base = _git(repo, "merge-base", from_ref, to_ref).strip()
|
||||
out = _git(
|
||||
repo, "diff", "--no-ext-diff", "--no-textconv",
|
||||
"--src-prefix=a/", "--dst-prefix=b/", "--no-color",
|
||||
"-U%d" % DIFF_CONTEXT_LINES, base, to_ref, "--",
|
||||
)
|
||||
return {fd.new_path: fd for fd in parse_diff(out)}
|
||||
|
||||
|
||||
def load_review_result(path):
|
||||
"""Read the JSON produced by `ocr review --format json` (path '-' = stdin)."""
|
||||
if path == "-":
|
||||
data = sys.stdin.read()
|
||||
else:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
data = f.read()
|
||||
return json.loads(data)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# CLI
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def parse_args(argv):
|
||||
target = os.environ.get("CI_MERGE_REQUEST_TARGET_BRANCH_NAME", "")
|
||||
default_from = "origin/" + target if target else ""
|
||||
|
||||
p = argparse.ArgumentParser(
|
||||
description="Post `ocr review --format json` output onto a GitFlic merge request."
|
||||
)
|
||||
p.add_argument("file", nargs="?", default="-",
|
||||
help="review result JSON ('-' = stdin, default)")
|
||||
p.add_argument("--owner", default=os.environ.get("CI_PROJECT_NAMESPACE", ""),
|
||||
help="project owner alias (default: $CI_PROJECT_NAMESPACE)")
|
||||
p.add_argument("--project", default=os.environ.get("CI_PROJECT_NAME", ""),
|
||||
help="project alias (default: $CI_PROJECT_NAME)")
|
||||
p.add_argument("--mr", default=os.environ.get("CI_MERGE_REQUEST_LOCAL_ID", ""),
|
||||
help="merge request local id (default: $CI_MERGE_REQUEST_LOCAL_ID)")
|
||||
p.add_argument("--api-url", default=os.environ.get("GITFLIC_API_URL", "") or DEFAULT_API_URL,
|
||||
help="GitFlic REST API base URL (default: $GITFLIC_API_URL or %s)" % DEFAULT_API_URL)
|
||||
p.add_argument("--from", dest="from_ref", default=default_from,
|
||||
help="base ref of the reviewed range (default: origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME)")
|
||||
p.add_argument("--to", dest="to_ref", default=os.environ.get("CI_COMMIT_SHA", ""),
|
||||
help="head ref of the reviewed range (default: $CI_COMMIT_SHA)")
|
||||
p.add_argument("--repo", default=".", help="git repository root (default: .)")
|
||||
p.add_argument("--dry-run", action="store_true",
|
||||
help="print discussions instead of posting them")
|
||||
return p.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
args = parse_args(sys.argv[1:] if argv is None else argv)
|
||||
|
||||
missing = [name for name, value in (
|
||||
("--owner", args.owner), ("--project", args.project), ("--mr", args.mr),
|
||||
("--from", args.from_ref), ("--to", args.to_ref),
|
||||
) if not value]
|
||||
if missing:
|
||||
log("error: %s required (set via flag or CI environment)" % ", ".join(missing))
|
||||
return 2
|
||||
|
||||
token = os.environ.get("GITFLIC_TOKEN", "")
|
||||
if not token and not args.dry_run:
|
||||
log("error: GITFLIC_TOKEN environment variable is required")
|
||||
return 2
|
||||
|
||||
try:
|
||||
result = load_review_result(args.file)
|
||||
except (OSError, ValueError) as e:
|
||||
log("error: cannot read review result %s: %s" % (args.file, e))
|
||||
return 1
|
||||
|
||||
try:
|
||||
diffs_by_path = load_diffs_by_path(args.repo, args.from_ref, args.to_ref)
|
||||
except (subprocess.CalledProcessError, OSError) as e:
|
||||
# Without the diff, inline positions cannot be computed; comments still
|
||||
# go out via the fallback note.
|
||||
log("warning: cannot read diff %s..%s, posting all comments as fallback: %s"
|
||||
% (args.from_ref, args.to_ref, e))
|
||||
diffs_by_path = {}
|
||||
|
||||
if args.dry_run:
|
||||
post = make_dry_run_poster()
|
||||
else:
|
||||
post = make_poster(args.api_url, token, args.owner, args.project, args.mr)
|
||||
|
||||
stats = publish(result, diffs_by_path, post)
|
||||
total = len(result.get("comments") or [])
|
||||
print("Posted %d inline comment(s), %d via fallback note (%d total)."
|
||||
% (stats["inline"], stats["fallback"], total))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
240
examples/gitflic_ci/post_review_test.py
Normal file
240
examples/gitflic_ci/post_review_test.py
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Tests for post_review.py.
|
||||
|
||||
Standard-library unittest only (no pytest, no network, no git): run with
|
||||
|
||||
python3 post_review_test.py # from examples/gitflic_ci/
|
||||
python3 -m unittest discover examples/gitflic_ci
|
||||
|
||||
The line-mapping cases are ported 1:1 from the upstream Go test
|
||||
internal/publish/gitflic/linemap_test.go and publisher_test.go, so the script
|
||||
keeps proven parity with the binary it replaces.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
|
||||
import post_review as pr
|
||||
|
||||
|
||||
# old file lines 1..10; line 3 modified, a line inserted after old line 5,
|
||||
# old line 8 deleted. (from linemap_test.go)
|
||||
SAMPLE_DIFF = """diff --git a/main.go b/main.go
|
||||
--- a/main.go
|
||||
+++ b/main.go
|
||||
@@ -1,10 +1,10 @@
|
||||
line1
|
||||
line2
|
||||
-line3 old
|
||||
+line3 new
|
||||
line4
|
||||
line5
|
||||
+inserted after5
|
||||
line6
|
||||
line7
|
||||
-line8
|
||||
line9
|
||||
line10
|
||||
"""
|
||||
|
||||
NEW_FILE_DIFF = """diff --git a/added.go b/added.go
|
||||
new file mode 100644
|
||||
--- /dev/null
|
||||
+++ b/added.go
|
||||
@@ -0,0 +1,3 @@
|
||||
+package main
|
||||
+
|
||||
+func main() {}
|
||||
"""
|
||||
|
||||
DELETED_FILE_DIFF = """diff --git a/gone.go b/gone.go
|
||||
deleted file mode 100644
|
||||
--- a/gone.go
|
||||
+++ /dev/null
|
||||
@@ -1,2 +0,0 @@
|
||||
-package main
|
||||
-
|
||||
"""
|
||||
|
||||
BINARY_DIFF = """diff --git a/logo.png b/logo.png
|
||||
index 1111111..2222222 100644
|
||||
Binary files a/logo.png and b/logo.png differ
|
||||
"""
|
||||
|
||||
|
||||
class OldLineForTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.hunks = pr.parse_hunks(SAMPLE_DIFF)
|
||||
|
||||
def test_single_hunk_positions(self):
|
||||
self.assertEqual(len(self.hunks), 1)
|
||||
cases = [
|
||||
("context before changes", 1, 1),
|
||||
("modified line maps to deleted position anchor", 3, 3),
|
||||
("context after modification", 4, 4),
|
||||
("added line anchors to preceding old line", 6, 5),
|
||||
("context shifted by insertion", 7, 6),
|
||||
("context after deletion", 9, 9),
|
||||
("last context line", 10, 10),
|
||||
]
|
||||
for name, new_line, want in cases:
|
||||
with self.subTest(name):
|
||||
self.assertEqual(pr.old_line_for(self.hunks, new_line), want)
|
||||
|
||||
def test_outside_hunks(self):
|
||||
# one line added and one deleted -> cumulative delta 0
|
||||
self.assertEqual(pr.old_line_for(self.hunks, 42), 42)
|
||||
|
||||
def test_multiple_hunks(self):
|
||||
multi = (
|
||||
"@@ -1,2 +1,4 @@\n"
|
||||
" line1\n"
|
||||
"+added2\n"
|
||||
"+added3\n"
|
||||
" line2\n"
|
||||
"@@ -10,3 +12,3 @@\n"
|
||||
" line10\n"
|
||||
"-line11 old\n"
|
||||
"+line11 new\n"
|
||||
" line12\n"
|
||||
)
|
||||
hunks = pr.parse_hunks(multi)
|
||||
self.assertEqual(len(hunks), 2)
|
||||
# between hunks: new 8 = old 6 (two lines added by hunk 1)
|
||||
self.assertEqual(pr.old_line_for(hunks, 8), 6)
|
||||
# inside second hunk: modified new 13 anchors to old 11
|
||||
self.assertEqual(pr.old_line_for(hunks, 13), 11)
|
||||
|
||||
def test_pure_addition_at_top(self):
|
||||
hunks = pr.parse_hunks("@@ -0,0 +1,2 @@\n+first\n+second\n")
|
||||
self.assertEqual(pr.old_line_for(hunks, 1), 1)
|
||||
|
||||
|
||||
class ParseDiffTest(unittest.TestCase):
|
||||
def test_modified_file(self):
|
||||
fd = pr.parse_diff(SAMPLE_DIFF)[0]
|
||||
self.assertEqual((fd.old_path, fd.new_path), ("main.go", "main.go"))
|
||||
self.assertFalse(fd.is_new or fd.is_deleted or fd.is_binary)
|
||||
|
||||
def test_new_file(self):
|
||||
fd = pr.parse_diff(NEW_FILE_DIFF)[0]
|
||||
self.assertTrue(fd.is_new)
|
||||
self.assertEqual(fd.new_path, "added.go")
|
||||
|
||||
def test_deleted_file(self):
|
||||
fd = pr.parse_diff(DELETED_FILE_DIFF)[0]
|
||||
self.assertTrue(fd.is_deleted)
|
||||
self.assertEqual(fd.new_path, "/dev/null")
|
||||
|
||||
def test_binary_file(self):
|
||||
fd = pr.parse_diff(BINARY_DIFF)[0]
|
||||
self.assertTrue(fd.is_binary)
|
||||
|
||||
def test_multiple_files(self):
|
||||
files = pr.parse_diff(SAMPLE_DIFF + NEW_FILE_DIFF)
|
||||
self.assertEqual([f.new_path for f in files], ["main.go", "added.go"])
|
||||
|
||||
|
||||
class Recorder:
|
||||
"""A post() that records discussions; optionally fails the first inline."""
|
||||
|
||||
def __init__(self, fail_first_inline=False):
|
||||
self.calls = []
|
||||
self.fail_first_inline = fail_first_inline
|
||||
self._inline_seen = 0
|
||||
|
||||
def __call__(self, discussion):
|
||||
if self.fail_first_inline and "newPath" in discussion:
|
||||
self._inline_seen += 1
|
||||
if self._inline_seen == 1:
|
||||
raise RuntimeError("simulated 403")
|
||||
self.calls.append(discussion)
|
||||
|
||||
|
||||
def diffs_from(diff_text):
|
||||
return {fd.new_path: fd for fd in pr.parse_diff(diff_text)}
|
||||
|
||||
|
||||
class PublishTest(unittest.TestCase):
|
||||
def test_inline_and_summary(self):
|
||||
result = {
|
||||
"comments": [{
|
||||
"path": "main.go", "content": "possible nil dereference",
|
||||
"start_line": 6, "end_line": 6,
|
||||
"existing_code": "x := y.Field",
|
||||
"suggestion_code": "if y != nil { x = y.Field }",
|
||||
}],
|
||||
}
|
||||
rec = Recorder()
|
||||
stats = pr.publish(result, diffs_from(SAMPLE_DIFF), rec)
|
||||
|
||||
self.assertEqual(stats, {"inline": 1, "fallback": 0})
|
||||
self.assertEqual(len(rec.calls), 2) # inline + summary
|
||||
|
||||
inline = rec.calls[0]
|
||||
self.assertEqual(inline["newLine"], 6)
|
||||
self.assertEqual(inline["oldLine"], 5)
|
||||
self.assertEqual((inline["newPath"], inline["oldPath"]), ("main.go", "main.go"))
|
||||
self.assertIn("possible nil dereference", inline["message"])
|
||||
self.assertIn("**Suggestion:**", inline["message"])
|
||||
|
||||
summary = rec.calls[1]
|
||||
self.assertNotIn("newPath", summary)
|
||||
self.assertIn("**1** issue(s)", summary["message"])
|
||||
|
||||
def test_fallback_for_unmapped_comment(self):
|
||||
result = {
|
||||
"comments": [{
|
||||
"path": "missing.go", "content": "issue in file absent from diff",
|
||||
"start_line": 1, "end_line": 1,
|
||||
}],
|
||||
"warnings": [{"file": "a.go", "message": "skipped", "type": "subtask_error"}],
|
||||
}
|
||||
rec = Recorder()
|
||||
stats = pr.publish(result, {}, rec)
|
||||
|
||||
self.assertEqual(stats, {"inline": 0, "fallback": 1})
|
||||
self.assertEqual(len(rec.calls), 2) # fallback + summary
|
||||
self.assertIn("could not be posted inline", rec.calls[0]["message"])
|
||||
self.assertIn("`missing.go`", rec.calls[0]["message"])
|
||||
self.assertIn("1 warning(s)", rec.calls[1]["message"])
|
||||
|
||||
def test_inline_error_falls_back(self):
|
||||
result = {
|
||||
"comments": [{
|
||||
"path": "main.go", "content": "finding",
|
||||
"start_line": 1, "end_line": 1,
|
||||
}],
|
||||
}
|
||||
rec = Recorder(fail_first_inline=True)
|
||||
stats = pr.publish(result, diffs_from(SAMPLE_DIFF), rec)
|
||||
|
||||
self.assertEqual(stats, {"inline": 0, "fallback": 1})
|
||||
self.assertEqual(len(rec.calls), 2) # fallback + summary after inline failure
|
||||
|
||||
def test_no_comments(self):
|
||||
rec = Recorder()
|
||||
stats = pr.publish({"message": "No comments generated. Looks good to me."}, {}, rec)
|
||||
|
||||
self.assertEqual(stats, {"inline": 0, "fallback": 0})
|
||||
self.assertEqual(len(rec.calls), 1)
|
||||
self.assertIn("Looks good to me", rec.calls[0]["message"])
|
||||
|
||||
def test_new_file_anchors_to_new_path(self):
|
||||
result = {
|
||||
"comments": [{
|
||||
"path": "added.go", "content": "empty main",
|
||||
"start_line": 3, "end_line": 3,
|
||||
}],
|
||||
}
|
||||
rec = Recorder()
|
||||
stats = pr.publish(result, diffs_from(NEW_FILE_DIFF), rec)
|
||||
|
||||
self.assertEqual(stats["inline"], 1)
|
||||
inline = rec.calls[0]
|
||||
self.assertEqual(inline["oldPath"], "added.go")
|
||||
self.assertEqual(inline["oldLine"], 1)
|
||||
self.assertEqual(inline["newLine"], 3)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -1,6 +1,40 @@
|
|||
# OpenCodeReview - GitHub Actions Demo
|
||||
# OpenCodeReview - GitHub Actions Workflow
|
||||
|
||||
This demo shows how to integrate OpenCodeReview into your GitHub Actions workflow to automatically review Pull Requests and post review comments.
|
||||
This directory provides a ready-to-use GitHub Actions workflow demo that integrates OpenCodeReview into your repository to automatically review Pull Requests and post inline review comments. Copy it into `.github/workflows/` and configure the required secrets/vars.
|
||||
|
||||
## Quick Start: `ocr-review.yml`
|
||||
|
||||
The simplest adoption path: this demo delegates every step — checkout, OCR install, review, comment posting, artifact upload — to the official reusable composite action at [`action.yml`](../../action.yml) via a single `uses: alibaba/open-code-review@main` step. It covers both automatic PR review (`pull_request_target: opened/synchronize/reopened`) and on-demand re-review via comments (`/open-code-review` or `@open-code-review`). No inline scripts to maintain — `@main` always runs the latest action; pin to a version tag or commit SHA when reproducibility matters.
|
||||
|
||||
```bash
|
||||
mkdir -p .github/workflows
|
||||
cp ocr-review.yml .github/workflows/ocr-review.yml
|
||||
```
|
||||
|
||||
The core of the demo is a single action step:
|
||||
|
||||
```yaml
|
||||
- uses: alibaba/open-code-review@main
|
||||
with:
|
||||
llm_url: ${{ secrets.OCR_LLM_URL }}
|
||||
llm_auth_token: ${{ secrets.OCR_LLM_AUTH_TOKEN }}
|
||||
llm_model: ${{ vars.OCR_LLM_MODEL }}
|
||||
llm_use_anthropic: ${{ vars.OCR_LLM_USE_ANTHROPIC }}
|
||||
```
|
||||
|
||||
See [`action.yml`](../../action.yml) for the full list of inputs, outputs, security guidance, and the four comment-posting modes (sticky summary + incremental).
|
||||
|
||||
## Running on a self-hosted runner
|
||||
|
||||
The demo above runs on GitHub-hosted runners (`runs-on: ubuntu-latest`) and pulls the action from `alibaba/open-code-review@main`. If you prefer to run OCR on your own self-hosted runner — to reach private network resources, keep LLM traffic on-prem, or avoid runner-minute costs — the OCR project itself does exactly this in its own CI.
|
||||
|
||||
See [`.github/workflows/ocr-review.yml`](../../.github/workflows/ocr-review.yml) for that workflow. It runs on `runs-on: self-hosted` inside a `node:24` container. One important caveat: it invokes the action with `uses: ./` only because `action.yml` lives in that same repository — that is an internal shortcut and will not resolve in your repo. As an external user, keep `uses: alibaba/open-code-review@main` (the runner fetches the action automatically); only the runner environment needs to change. What is worth borrowing from it:
|
||||
|
||||
- `runs-on: self-hosted`, optionally with a `container:` image such as `node:24` (the action needs Node.js; git is installed automatically if missing).
|
||||
- Marking the workspace as a trusted git `safe.directory` when running inside a container (e.g. `git config --global --replace-all safe.directory '*'`) to avoid "dubious ownership" errors. Use `--replace-all` (not `--add`) so repeated runs across multiple self-hosted actions replace rather than accumulate entries in the global git config.
|
||||
- Pinning action inputs explicitly (`sticky_summary`, `incremental`, `upload_artifacts`, `llm_extra_body`, etc.).
|
||||
|
||||
The action performs its own full `fetch-depth: 0` checkout of the PR internally, so no extra checkout step is needed for the review diff. Adapt the runner settings to your environment and secret layout.
|
||||
|
||||
## How It Works
|
||||
|
||||
|
|
@ -10,40 +44,39 @@ PR Created/Updated → GitHub Actions Triggered → OCR Reviews Diff → Comment
|
|||
Comment with trigger keyword ↗
|
||||
```
|
||||
|
||||
1. When a PR is opened, the workflow triggers (uses `pull_request_target` for fork secret access)
|
||||
2. Alternatively, when a comment containing `/open-code-review` or `@open-code-review` is posted on a PR, the workflow triggers
|
||||
3. It installs OCR via `npm install -g @alibaba-group/open-code-review`
|
||||
4. Runs `ocr review --from origin/<base> --to <head_sha> --format json` to analyze the diff (uses commit SHA to support fork PRs)
|
||||
5. Parses the JSON output and posts inline review comments on the PR using GitHub's Pull Request Review API
|
||||
1. When a PR is opened, the workflow triggers (uses `pull_request_target` for fork secret access).
|
||||
2. Alternatively, when a comment containing `/open-code-review` or `@open-code-review` is posted on a PR, the workflow triggers.
|
||||
3. The reusable action installs OCR, fetches the PR head blobs, computes `git merge-base`, and runs `ocr review --from <merge-base> --to <head> --format json`.
|
||||
4. It parses the JSON output and posts inline review comments on the PR via the Pull Request Review API, plus a summary comment (an issue comment on the PR).
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Copy the workflow file
|
||||
### Configure secrets and variables
|
||||
|
||||
Copy `ocr-review.yml` to your repository's `.github/workflows/` directory:
|
||||
Go to your repository's **Settings → Secrets and variables → Actions**.
|
||||
|
||||
```bash
|
||||
mkdir -p .github/workflows
|
||||
cp ocr-review.yml .github/workflows/ocr-review.yml
|
||||
```
|
||||
|
||||
### 2. Configure secrets
|
||||
|
||||
Go to your repository's **Settings → Secrets and variables → Actions** and add:
|
||||
**Secrets:**
|
||||
|
||||
| Secret | Required | Description |
|
||||
|--------|----------|-------------|
|
||||
| `OCR_LLM_URL` | Yes | LLM API endpoint URL (e.g., `https://api.openai.com/v1/chat/completions`) |
|
||||
| `OCR_LLM_AUTH_TOKEN` | Yes | API authentication token |
|
||||
| `OCR_LLM_MODEL` | No | Model name (defaults to `gpt-4o`) |
|
||||
| `OCR_LLM_USE_ANTHROPIC` | No | Set to `true` if using Anthropic Claude models |
|
||||
| `OCR_LLM_AUTH_TOKEN` | Yes | API authentication token (mapped to env `OCR_LLM_TOKEN` internally) |
|
||||
|
||||
> **Note:** `GITHUB_TOKEN` is automatically provided by GitHub Actions with the required `pull-requests: write` permission.
|
||||
>
|
||||
> The workflow also configures `llm.extra_body` to disable thinking mode for compatibility with various LLM providers.
|
||||
**Variables:**
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `OCR_LLM_MODEL` | Yes | Model name |
|
||||
| `OCR_LLM_USE_ANTHROPIC` | Yes | `true` for Anthropic Claude, `false` for OpenAI-compatible |
|
||||
|
||||
> **Note:** `GITHUB_TOKEN` is automatically provided by GitHub Actions with the required `pull-requests: write` permission. The action also sets `llm.extra_body` to disable thinking mode for compatibility with various LLM providers.
|
||||
|
||||
## Customization
|
||||
|
||||
> These knobs are action inputs — they apply to the demo workflow and any workflow calling `alibaba/open-code-review@main`.
|
||||
|
||||
See [`action.yml`](../../action.yml) for the full input list. Workflow-level settings (triggers, keywords) are edited in the workflow file itself.
|
||||
|
||||
### Change the trigger events
|
||||
|
||||
Modify the `on.pull_request_target.types` array in the workflow file:
|
||||
|
|
@ -56,76 +89,152 @@ on:
|
|||
|
||||
### Customize comment trigger keywords
|
||||
|
||||
By default, the workflow triggers when a PR comment starts with `/open-code-review` or `@open-code-review`. You can customize these keywords by modifying the `if` condition in the workflow:
|
||||
By default the workflow also re-reviews on demand when a PR comment starts with `/open-code-review` or `@open-code-review`. The `if` condition is more defensive than a bare keyword check — it gates comment triggers so only authorized humans can spend LLM quota:
|
||||
|
||||
```yaml
|
||||
if: |
|
||||
github.event_name == 'pull_request_target' ||
|
||||
(github.event_name == 'issue_comment' && github.event.issue.pull_request && startsWith(github.event.comment.body, '/review')) ||
|
||||
(github.event_name == 'issue_comment' && github.event.issue.pull_request && startsWith(github.event.comment.body, '@mybot'))
|
||||
github.event_name == 'pull_request_target'
|
||||
|| (
|
||||
github.event_name == 'issue_comment'
|
||||
&& github.event.issue.pull_request
|
||||
&& github.event.comment.user.type != 'Bot'
|
||||
&& (
|
||||
github.event.comment.author_association == 'MEMBER'
|
||||
|| github.event.comment.author_association == 'OWNER'
|
||||
|| github.event.comment.author_association == 'COLLABORATOR'
|
||||
)
|
||||
&& (
|
||||
startsWith(github.event.comment.body, '/open-code-review')
|
||||
|| startsWith(github.event.comment.body, '@open-code-review')
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
Or use a more flexible pattern with `contains` to trigger on any comment containing the keyword:
|
||||
Each clause guards against a different abuse vector:
|
||||
|
||||
```yaml
|
||||
if: |
|
||||
github.event_name == 'pull_request_target' ||
|
||||
(github.event_name == 'issue_comment' && github.event.issue.pull_request && contains(github.event.comment.body, '/review'))
|
||||
```
|
||||
- `github.event.issue.pull_request` — the comment must be on a PR, not a regular issue.
|
||||
- `github.event.comment.user.type != 'Bot'` — ignore bot comments. `GITHUB_TOKEN` already suppresses events from comments it posted, but a PAT or GitHub App token would not, so this is a safety net against self-triggering loops.
|
||||
- `author_association == 'MEMBER' | 'OWNER' | 'COLLABORATOR'` — only repository collaborators can trigger a (billable) re-review, preventing arbitrary commenters from draining LLM quota.
|
||||
- The `startsWith(...)` pair — the actual trigger keywords.
|
||||
|
||||
> **Note:** The condition `github.event.issue.pull_request` ensures the comment is on a PR, not a regular issue.
|
||||
To change the keywords, edit only that final pair (e.g. `/review` and `@mybot`), or swap `startsWith` for `contains` to match a substring anywhere in the comment body. Keep the preceding guards intact.
|
||||
|
||||
The same predicate is mirrored in the workflow's `concurrency.group`: matching events share a per-PR group (`ocr-<pr_number>`) so a new review cancels any stale one, while non-matching comments land in a unique `noop-<run_id>` group and are skipped instantly without disrupting a running review. If you change the keywords in `if`, mirror the change in `concurrency.group` too.
|
||||
|
||||
### Use a specific OCR version
|
||||
|
||||
```yaml
|
||||
- name: Install OpenCodeReview
|
||||
run: npm install -g @alibaba-group/open-code-review@1.0.0
|
||||
- uses: alibaba/open-code-review@main
|
||||
with:
|
||||
ocr_version: 1.0.0
|
||||
```
|
||||
|
||||
### Add custom review rules
|
||||
|
||||
Use the `--rule` flag to pass a custom rules JSON file:
|
||||
```yaml
|
||||
- uses: alibaba/open-code-review@main
|
||||
with:
|
||||
rule: ./my-rules.json
|
||||
```
|
||||
|
||||
> Security: do not point `rule` at a file sourced from the PR branch when secrets are in scope; use a trusted rules file from your base branch.
|
||||
|
||||
### Control comment posting (sticky summary & incremental)
|
||||
|
||||
The action posts a summary issue comment plus inline review comments. Two inputs select the posting mode (combined, they give the four modes referenced above); a third tunes the incremental overlap test:
|
||||
|
||||
| Input | Default | Description |
|
||||
|-------|---------|-------------|
|
||||
| `sticky_summary` | `'true'` | Update an existing summary comment in place instead of posting a new one each run. |
|
||||
| `incremental` | `'false'` | Only append inline comments whose `(path, line range)` does not overlap an existing bot review comment. History is never deleted (non-destructive). |
|
||||
| `incremental_overlap_threshold` | `'0.6'` | IoU threshold `incremental` uses to decide whether a multi-line comment overlaps an existing one. Two single-line comments match on the same line; single- vs multi-line never match. Ignored unless `incremental` is `'true'`. |
|
||||
|
||||
```yaml
|
||||
- name: Run OCR review
|
||||
run: ocr review --rule ./my-rules.json --from origin/${{ github.base_ref }} --to origin/${{ github.head_ref }}
|
||||
- uses: alibaba/open-code-review@main
|
||||
with:
|
||||
sticky_summary: 'true'
|
||||
incremental: 'true'
|
||||
incremental_overlap_threshold: '0.75'
|
||||
```
|
||||
|
||||
> `sticky_summary` and `incremental` must be quoted strings (`'true'`/`'false'`); the action compares them as strings, so an unquoted YAML boolean will not match.
|
||||
|
||||
### Adjust retry and delay settings
|
||||
|
||||
When posting review comments individually (fallback mode), the workflow includes rate-limit handling with exponential backoff. The retry strategy follows GitHub's documented guidance for REST API rate limits — see [Rate limits for the REST API](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api?apiVersion=2026-03-10) for details on primary/secondary rate limits and recommended retry behavior. You can configure the retry and delay behavior via **repository variables** (Settings → Secrets and variables → Actions → Variables):
|
||||
When posting review comments individually (fallback mode), the action honors GitHub rate-limit headers (`retry-after`, `x-ratelimit-*`) with exponential backoff. The retry strategy follows GitHub's documented guidance for REST API rate limits — see [Rate limits for the REST API](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api?apiVersion=2026-03-10) for details on primary/secondary rate limits and recommended retry behavior:
|
||||
|
||||
- **Primary rate limit exhausted** (`x-ratelimit-remaining=0`): wait until `x-ratelimit-reset`.
|
||||
- **Secondary rate limit with a `retry-after` header**: wait exactly that long.
|
||||
- **Secondary rate limit with no header**: wait at least one minute, then use exponential backoff on continued failures.
|
||||
|
||||
These are environment variables read by the posting module with sensible defaults; set them at the **job `env:` level** to tune (they propagate into the action):
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `OCR_RETRY_BASE_DELAY` | `60000` | Base delay (ms) for exponential backoff when no retry header is present (per GitHub's "at least one minute" recommendation for secondary limits) |
|
||||
| `OCR_RETRY_MAX_DELAY` | `300000` | Maximum delay (ms) cap applied to every computed wait, including retry-after and x-ratelimit-reset, so a far-future reset cannot stall the job past its timeout |
|
||||
| `OCR_RETRY_BASE_DELAY` | `60000` | Base delay (ms) for exponential backoff when no retry header is present |
|
||||
| `OCR_RETRY_MAX_DELAY` | `300000` | Maximum delay (ms) cap applied to every computed wait |
|
||||
| `OCR_MAX_RETRIES` | `3` | Maximum retry attempts per comment when rate-limited |
|
||||
| `OCR_SUCCESS_DELAY` | `2000` | Delay (ms) after a successful comment post to pace subsequent requests |
|
||||
| `OCR_FAILURE_DELAY` | `1000` | Delay (ms) after a non-rate-limit failure to pace subsequent requests |
|
||||
| `OCR_LOW_REMAINING_THRESHOLD` | `3` | When x-ratelimit-remaining is at or below this value, proactively increase request spacing to avoid hitting the limit |
|
||||
| `OCR_SUCCESS_DELAY` | `2000` | Delay (ms) after a successful comment post |
|
||||
| `OCR_FAILURE_DELAY` | `1000` | Delay (ms) after a non-retryable failure |
|
||||
| `OCR_LOW_REMAINING_THRESHOLD` | `3` | When x-ratelimit-remaining is at or below this value, proactively increase request spacing |
|
||||
| `OCR_LOW_REMAINING_SPACING` | `10000` | Request spacing (ms) used when remaining quota is low |
|
||||
| `OCR_READ_SUCCESS_DELAY` | `500` | Delay (ms) after a successful read API call (`listReviews` / `listReviewComments` / `listIssueComments`) used for the idempotency check. Reads are cheaper than writes, so the default is shorter |
|
||||
| `OCR_READ_LOW_REMAINING_SPACING` | `5000` | Request spacing (ms) for read calls when remaining quota is low |
|
||||
|
||||
These variables are optional — if not configured, sensible defaults are used. Consider increasing delays for repositories with many concurrent workflows or large PRs that generate numerous review comments.
|
||||
|
||||
### Limit concurrency
|
||||
|
||||
Adjust the `--concurrency` flag for large PRs to control the number of concurrent LLM requests:
|
||||
For example, to raise the per-comment retry count to 5, set `OCR_MAX_RETRIES` on the **job's** `env:` — not on the `uses:` step. A composite action does not forward the caller's step-level `env:` into its internal steps' process environment, so a step-level value would be silently ignored; the job-level value is inherited by the action's comment-posting step and read via `process.env`:
|
||||
|
||||
```yaml
|
||||
- name: Run OCR review
|
||||
run: ocr review --concurrency 5 --from origin/${{ github.base_ref }} --to origin/${{ github.head_ref }}
|
||||
jobs:
|
||||
code-review:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
OCR_MAX_RETRIES: 5
|
||||
steps:
|
||||
- uses: alibaba/open-code-review@main
|
||||
with:
|
||||
llm_url: ${{ secrets.OCR_LLM_URL }}
|
||||
# ...other inputs
|
||||
```
|
||||
|
||||
These variables are optional. See GitHub's [Rate limits for the REST API](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api).
|
||||
|
||||
#### Idempotency: avoiding duplicate review comments
|
||||
|
||||
When the batch `createReview` call fails with a `5xx` error, the request may still have landed on the GitHub server (the response was simply lost). Before retrying per-comment, the action queries existing reviews and review comments — each tagged with a per-run HTML comment (e.g. `<!-- ocr-<runId>-<attempt>-<token> -->`) — and only retries the comments that are actually missing. This prevents duplicate review posts.
|
||||
|
||||
The summary comment is deduplicated too: in sticky mode (the default) the action finds the existing summary by its persistent marker and updates it in place rather than posting a new one; in non-sticky mode it reuses this run's summary if it already exists. If the read API is unavailable, it skips posting the summary rather than risking a duplicate.
|
||||
|
||||
If the read API itself is unavailable (rate-limited or `5xx`), the check returns *unknown* rather than assuming the comment was not posted. In that case the action **skips retrying** to avoid risking a duplicate, and surfaces the uncertainty in the summary instead of silently producing duplicates.
|
||||
|
||||
### Limit LLM concurrency
|
||||
|
||||
```yaml
|
||||
- uses: alibaba/open-code-review@main
|
||||
with:
|
||||
review_concurrency: 5
|
||||
```
|
||||
|
||||
### Provide background context
|
||||
|
||||
Use the `--background` flag to pass additional context that helps OCR better understand the purpose of the changes:
|
||||
|
||||
```yaml
|
||||
- name: Run OCR review
|
||||
run: ocr review --background "${{ github.event.pull_request.title }}" --from origin/${{ github.base_ref }} --to origin/${{ github.head_ref }}
|
||||
- uses: alibaba/open-code-review@main
|
||||
with:
|
||||
background: ${{ github.event.pull_request.title }}
|
||||
```
|
||||
|
||||
This is particularly useful when your PR titles follow semantic conventions (e.g., `feat(auth): add OAuth2 support`) that clearly summarize what the PR implements. The background information helps OCR provide more relevant and context-aware review comments.
|
||||
Particularly useful when PR titles follow semantic conventions (e.g., `feat(auth): add OAuth2 support`).
|
||||
|
||||
> Note: `github.event.pull_request.title` is only present on `pull_request_target` events, so it is empty for comment-triggered re-reviews. To cover both trigger types, have the pr-context step also output the title and fall back to it:
|
||||
>
|
||||
> ```yaml
|
||||
> # inside the pr-context script (which only runs for issue_comment):
|
||||
> core.setOutput('title', pullRequest.title);
|
||||
> ```
|
||||
> ```yaml
|
||||
> - uses: alibaba/open-code-review@main
|
||||
> with:
|
||||
> background: ${{ steps.pr-context.outputs.title || github.event.pull_request.title }}
|
||||
> ```
|
||||
|
||||
### Customize the review comment author with GitHub App
|
||||
|
||||
|
|
@ -168,40 +277,53 @@ Add the following secrets to your repository (**Settings → Secrets and variabl
|
|||
|--------|-------------|
|
||||
| `GITHUB_APP_ID` | Your GitHub App's ID |
|
||||
| `GITHUB_APP_PRIVATE_KEY` | Contents of the `.pem` file (including `-----BEGIN RSA PRIVATE KEY-----` and `-----END RSA PRIVATE KEY-----`) |
|
||||
| `GITHUB_APP_INSTALLATION_ID` | The Installation ID from Step 3 |
|
||||
| `GITHUB_APP_INSTALLATION_ID` | (Optional) The Installation ID from Step 3 — only needed for apps with multiple installations |
|
||||
|
||||
#### Step 5: Update the Workflow
|
||||
#### Step 5: Pass the App token to the action
|
||||
|
||||
Add a step to obtain a token from the GitHub App, then use it in the "Post review comments to PR" step:
|
||||
Mint a token with `actions/create-github-app-token` and pass it via the `github_token` input:
|
||||
|
||||
```yaml
|
||||
- name: Get GitHub App Token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v1
|
||||
uses: actions/create-github-app-token@main
|
||||
with:
|
||||
app-id: ${{ secrets.GITHUB_APP_ID }}
|
||||
private-key: ${{ secrets.GITHUB_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Post review comments to PR
|
||||
uses: actions/github-script@v7
|
||||
- uses: alibaba/open-code-review@main
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
# ... existing script
|
||||
github_token: ${{ steps.app-token.outputs.token }}
|
||||
llm_url: ${{ secrets.OCR_LLM_URL }}
|
||||
llm_auth_token: ${{ secrets.OCR_LLM_AUTH_TOKEN }}
|
||||
llm_model: ${{ vars.OCR_LLM_MODEL }}
|
||||
llm_use_anthropic: ${{ vars.OCR_LLM_USE_ANTHROPIC }}
|
||||
```
|
||||
|
||||
Now review comments will be posted with your custom GitHub App identity (e.g., `OpenCodeReview Bot`), providing a more professional and distinguishable appearance in your PRs.
|
||||
|
||||
## Example Output
|
||||
|
||||
When a PR is reviewed, comments appear directly in the PR's "Files changed" tab:
|
||||
The action posts two kinds of output on the PR: a **summary issue comment** (in the PR conversation) and **inline review comments** (in the "Files changed" tab).
|
||||
|
||||
- ✅ If no issues found: A comment saying "No comments generated. Looks good to me."
|
||||
- 🔍 If issues found: Inline review comments with suggestions using GitHub's native suggestion syntax
|
||||
### Summary comment
|
||||
|
||||
### Inline Comment Example
|
||||
A single comment — updated in place on each run when `sticky_summary` is `'true'` (the default) — carries the review outcome and posting statistics.
|
||||
|
||||
The workflow uses GitHub's `suggestion` code block syntax, so reviewers can apply fixes with one click:
|
||||
- ✅ No issues: `✅ **OpenCodeReview**: No comments generated. Looks good to me.`
|
||||
- 🔍 Issues found: a header line plus per-outcome counts, for example:
|
||||
|
||||
```markdown
|
||||
🔍 **OpenCodeReview** found **3** issue(s) in this PR.
|
||||
- ✅ Successfully posted inline: 2 comment(s)
|
||||
- 📝 In summary (no line info): 1 comment(s)
|
||||
```
|
||||
|
||||
The counts are mutually exclusive and sum to the total: `inline` (landed as review inline comments), `summary` (no line info, rendered in the summary body), `skipped` (suppressed by incremental overlap filtering), and `failed` (had line info but could not be posted). Any warnings are appended as a bulleted list.
|
||||
|
||||
### Inline comments
|
||||
|
||||
Comments with valid line info are posted as PR review comments in "Files changed". Each carries the review content plus, when a fix is available, a GitHub-native `suggestion` block so reviewers can apply it with one click:
|
||||
|
||||
````markdown
|
||||
**Suggestion:**
|
||||
|
|
@ -210,6 +332,8 @@ The workflow uses GitHub's `suggestion` code block syntax, so reviewers can appl
|
|||
```
|
||||
````
|
||||
|
||||
Comments that have no line info, or that could not be posted inline (e.g. their line fell outside the current diff), are rendered in the summary body instead — each under a `### 📄 <path>` heading, with a collapsible `<details>` "💡 Suggested Change" (Before/After) when a fix is available.
|
||||
|
||||
## Supported LLM Providers
|
||||
|
||||
OCR supports both OpenAI and Anthropic API formats:
|
||||
|
|
@ -218,22 +342,25 @@ OCR supports both OpenAI and Anthropic API formats:
|
|||
- OpenAI (GPT-4o, GPT-4, etc.)
|
||||
- Azure OpenAI
|
||||
- Self-hosted models (vLLM, Ollama, etc.)
|
||||
- **Anthropic APIs** (set `OCR_LLM_USE_ANTHROPIC: true`):
|
||||
- **Anthropic APIs** (set variable `OCR_LLM_USE_ANTHROPIC=true`, i.e. `llm_use_anthropic: true`):
|
||||
- Anthropic Claude models
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **"Failed to parse OCR output"**: Check that `OCR_LLM_URL` and `OCR_LLM_AUTH_TOKEN` secrets are correctly set
|
||||
2. **"Cannot find merge-base"**: Ensure `fetch-depth: 0` is set in the checkout step
|
||||
3. **Review comments not appearing on correct lines**: This can happen when the diff has changed since the review started; the workflow handles this gracefully with a fallback to issue comments
|
||||
1. **Job fails / "Failed to parse OCR output"**: When `ocr review` exits non-zero the action fails the job with that exit code (the comment-posting step is skipped); a zero exit with malformed JSON surfaces as a parse error in the summary. In both cases, check that `OCR_LLM_URL` and `OCR_LLM_AUTH_TOKEN` are set correctly, then inspect the uploaded `ocr-stderr.log` artifact (also printed in the "Run OpenCodeReview" step log) for the underlying error.
|
||||
2. **"Cannot find merge-base"**: The action fetches full history (`fetch-depth: 0`) and the PR head (`git fetch origin pull/<n>/head`); if this still fails, ensure `permissions: contents: read` is set and the base branch is accessible (e.g., not deleted).
|
||||
3. **Review comments not on the expected lines**: Comments are attached to the PR head commit. If a comment's line falls outside the current diff (the PR was force-pushed or updated mid-review), GitHub rejects the inline post and the comment is rendered in the summary instead. The workflow's concurrency group cancels stale runs on new pushes.
|
||||
4. **No summary or comments at all**: Confirm the job's `permissions` include `pull-requests: write`, and that `github_token` (defaults to `${{ github.token }}`) is not overridden with a token lacking those scopes.
|
||||
|
||||
### Debugging
|
||||
|
||||
Enable debug logging by adding to the OCR review step:
|
||||
The action does not use an `OCR_DEBUG` flag. To diagnose a run:
|
||||
|
||||
```yaml
|
||||
env:
|
||||
OCR_DEBUG: "1"
|
||||
```
|
||||
- **Artifacts**: with `upload_artifacts: 'true'` (the default), the raw `ocr-result.json` and `ocr-stderr.log` are uploaded as workflow artifacts named `ocr-review-result-<run_id>-<run_attempt>`. 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'`.
|
||||
|
|
|
|||
|
|
@ -1,59 +1,64 @@
|
|||
# OpenCodeReview - GitHub Actions PR Auto-Review Demo
|
||||
#
|
||||
# This workflow automatically reviews pull requests using OpenCodeReview
|
||||
# and posts review comments directly on the PR.
|
||||
# Demonstrates invoking the reusable action for both automatic PR review
|
||||
# (pull_request_target: opened/synchronize/reopened) and on-demand re-review
|
||||
# via comments starting with '/open-code-review' or '@open-code-review'.
|
||||
#
|
||||
# Triggers:
|
||||
# - PR opened (uses pull_request_target for fork secret access)
|
||||
# - Comment on PR containing '/open-code-review' or '@open-code-review'
|
||||
# Required secrets/vars (Settings -> Secrets and variables -> Actions):
|
||||
# secret OCR_LLM_URL LLM API endpoint
|
||||
# secret OCR_LLM_AUTH_TOKEN LLM auth token (mapped to OCR_LLM_TOKEN)
|
||||
# variable OCR_LLM_MODEL model name
|
||||
# variable OCR_LLM_USE_ANTHROPIC 'true' for Anthropic, 'false' for OpenAI-compatible
|
||||
#
|
||||
# Required secrets:
|
||||
# OCR_LLM_URL - LLM API endpoint (e.g., https://api.openai.com/v1/chat/completions)
|
||||
# OCR_LLM_AUTH_TOKEN - Authentication token for the LLM API
|
||||
#
|
||||
# Optional secrets:
|
||||
# OCR_LLM_MODEL - Model name (default: gpt-4o)
|
||||
# OCR_LLM_USE_ANTHROPIC - Set to 'true' if using Anthropic Claude models
|
||||
#
|
||||
# Optional variables (for retry/delay tuning):
|
||||
# The retry strategy follows GitHub's documented guidance for REST API rate limits:
|
||||
# https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api
|
||||
# - Primary rate limit exhausted (x-ratelimit-remaining=0): wait until x-ratelimit-reset.
|
||||
# - Secondary rate limit with a retry-after header: wait exactly that long.
|
||||
# - Secondary rate limit with NO header: wait at least one minute, then use
|
||||
# exponential backoff on continued failures.
|
||||
#
|
||||
# OCR_RETRY_BASE_DELAY - Base delay (ms) for exponential backoff when no retry
|
||||
# header is present (default: 60000, per GitHub's
|
||||
# "at least one minute" recommendation for secondary limits).
|
||||
# OCR_RETRY_MAX_DELAY - Maximum delay (ms) cap applied to EVERY computed wait,
|
||||
# including retry-after and x-ratelimit-reset, so a far-future
|
||||
# reset cannot stall the job past its timeout (default: 300000 = 5 min).
|
||||
# OCR_MAX_RETRIES - Max retry attempts per comment when rate-limited (default: 3).
|
||||
# OCR_SUCCESS_DELAY - Delay (ms) between successful comment posts to pace requests (default: 2000).
|
||||
# OCR_FAILURE_DELAY - Delay (ms) after a non-retryable failure to pace subsequent requests (default: 1000).
|
||||
# OCR_LOW_REMAINING_THRESHOLD - When x-ratelimit-remaining is at or below this value,
|
||||
# proactively increase request spacing to avoid hitting the limit
|
||||
# (default: 3; GitHub best practice is to watch the header and slow down).
|
||||
# OCR_LOW_REMAINING_SPACING - Request spacing (ms) used when remaining quota is low
|
||||
# (default: 10000 = 10s).
|
||||
#
|
||||
# Note: GITHUB_TOKEN is automatically provided by GitHub Actions.
|
||||
# Note: The workflow also configures llm.extra_body to '{"thinking": {"type": "disabled"}}'
|
||||
# to disable thinking mode for compatibility with various LLM providers.
|
||||
# For the full list of action inputs/outputs and the four comment-posting modes
|
||||
# (sticky / incremental), see action.yml at the repo root.
|
||||
|
||||
name: OpenCodeReview PR Review
|
||||
|
||||
# Conditional concurrency group.
|
||||
#
|
||||
# GitHub Actions evaluates concurrency BEFORE job-level if-conditions. With a
|
||||
# flat group (ocr-<pr_number>), 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-<run_id> group that can never collide with a
|
||||
# real review, so they are skipped instantly without disrupting anything.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
group: >-
|
||||
${{
|
||||
(
|
||||
github.event_name == 'pull_request_target'
|
||||
|| (
|
||||
github.event_name == 'issue_comment'
|
||||
&& github.event.issue.pull_request
|
||||
&& github.event.comment.user.type != 'Bot'
|
||||
&& (
|
||||
github.event.comment.author_association == 'MEMBER'
|
||||
|| github.event.comment.author_association == 'OWNER'
|
||||
|| github.event.comment.author_association == 'COLLABORATOR'
|
||||
)
|
||||
&& (
|
||||
startsWith(github.event.comment.body, '/open-code-review')
|
||||
|| startsWith(github.event.comment.body, '@open-code-review')
|
||||
)
|
||||
)
|
||||
)
|
||||
&& format('ocr-{0}', github.event.pull_request.number || github.event.issue.number)
|
||||
|| format('noop-{0}', github.run_id)
|
||||
}}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
# Use pull_request_target instead of pull_request so that secrets are
|
||||
# available even for PRs from forks. This is safe because OCR only reads
|
||||
# the diff and does not execute any code from the PR.
|
||||
# available even for PRs from forks. This is safe because the reusable
|
||||
# action only reads the diff and does not execute any code from the PR.
|
||||
pull_request_target:
|
||||
types: [opened]
|
||||
types: [opened, synchronize, reopened]
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
|
|
@ -64,19 +69,38 @@ permissions:
|
|||
jobs:
|
||||
code-review:
|
||||
runs-on: ubuntu-latest
|
||||
# Run on PR events, or on comments starting with trigger keywords
|
||||
timeout-minutes: 30
|
||||
# Run on PR events, or on human-authored comments starting with trigger
|
||||
# keywords. Bot comments are excluded as a safety net: GITHUB_TOKEN already
|
||||
# suppresses events from bot-posted comments, but a PAT/App token would not.
|
||||
# issue_comment triggers are further gated on author_association so only
|
||||
# MEMBER/OWNER/COLLABORATOR users can spend LLM quota via re-review.
|
||||
if: |
|
||||
github.event_name == 'pull_request_target' ||
|
||||
(github.event_name == 'issue_comment' && github.event.issue.pull_request && startsWith(github.event.comment.body, '/open-code-review')) ||
|
||||
(github.event_name == 'issue_comment' && github.event.issue.pull_request && startsWith(github.event.comment.body, '@open-code-review'))
|
||||
github.event_name == 'pull_request_target'
|
||||
|| (
|
||||
github.event_name == 'issue_comment'
|
||||
&& github.event.issue.pull_request
|
||||
&& github.event.comment.user.type != 'Bot'
|
||||
&& (
|
||||
github.event.comment.author_association == 'MEMBER'
|
||||
|| github.event.comment.author_association == 'OWNER'
|
||||
|| github.event.comment.author_association == 'COLLABORATOR'
|
||||
)
|
||||
&& (
|
||||
startsWith(github.event.comment.body, '/open-code-review')
|
||||
|| startsWith(github.event.comment.body, '@open-code-review')
|
||||
)
|
||||
)
|
||||
steps:
|
||||
- name: Get PR context
|
||||
id: pr-context
|
||||
if: github.event_name != 'pull_request_target'
|
||||
if: github.event_name == 'issue_comment'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
// For issue_comment events, get PR info
|
||||
// For issue_comment events, resolve PR base/head so the action
|
||||
// can review the right diff (issue_comment has no top-level
|
||||
// pull_request payload fields).
|
||||
const prNumber = context.issue.number;
|
||||
const { data: pullRequest } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
|
|
@ -84,469 +108,16 @@ jobs:
|
|||
pull_number: prNumber
|
||||
});
|
||||
core.setOutput('base_ref', pullRequest.base.ref);
|
||||
core.setOutput('head_ref', pullRequest.head.ref);
|
||||
core.setOutput('head_sha', pullRequest.head.sha);
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # Full history needed for merge-base diff
|
||||
ref: ${{ github.event.pull_request.head.sha || steps.pr-context.outputs.head_sha }}
|
||||
|
||||
- name: Fetch PR head ref (ensures fork commits are available)
|
||||
run: git fetch origin pull/${{ github.event.pull_request.number || github.event.issue.number }}/head
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
- name: Install OpenCodeReview
|
||||
run: |
|
||||
npm install -g @alibaba-group/open-code-review
|
||||
echo "OpenCodeReview installed with version:"
|
||||
ocr version || true
|
||||
|
||||
- name: Configure OCR
|
||||
run: |
|
||||
ocr config set llm.url ${{ secrets.OCR_LLM_URL }}
|
||||
ocr config set llm.auth_token ${{ secrets.OCR_LLM_AUTH_TOKEN }}
|
||||
ocr config set llm.model ${{ secrets.OCR_LLM_MODEL }}
|
||||
ocr config set llm.use_anthropic ${{ secrets.OCR_LLM_USE_ANTHROPIC }}
|
||||
ocr config set llm.extra_body '{"thinking": {"type": "disabled"}}'
|
||||
|
||||
- name: Run OpenCodeReview
|
||||
id: review
|
||||
run: |
|
||||
# Get base ref and head SHA from PR context (different for comment triggers)
|
||||
# Note: We use HEAD_SHA instead of origin/${HEAD_REF} to support fork PRs,
|
||||
# because fork branches don't exist on the origin remote.
|
||||
if [ "${{ github.event_name }}" = "pull_request_target" ]; then
|
||||
BASE_REF="${{ github.event.pull_request.base.ref }}"
|
||||
HEAD_SHA="${{ github.event.pull_request.head.sha }}"
|
||||
else
|
||||
BASE_REF="${{ steps.pr-context.outputs.base_ref }}"
|
||||
HEAD_SHA="${{ steps.pr-context.outputs.head_sha }}"
|
||||
fi
|
||||
|
||||
echo "Reviewing PR: ${HEAD_SHA} against origin/${BASE_REF}"
|
||||
|
||||
# Run OCR in range mode with JSON output
|
||||
ocr review \
|
||||
--from "origin/${BASE_REF}" \
|
||||
--to "${HEAD_SHA}" \
|
||||
--format json \
|
||||
> /tmp/ocr-result.json 2>/tmp/ocr-stderr.log || true
|
||||
|
||||
echo "OCR review completed. Output:"
|
||||
cat /tmp/ocr-result.json
|
||||
echo "OCR review completed. Error log:"
|
||||
cat /tmp/ocr-stderr.log
|
||||
|
||||
- name: Post review comments to PR
|
||||
uses: actions/github-script@v7
|
||||
uses: alibaba/open-code-review@main
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const path = '/tmp/ocr-result.json';
|
||||
|
||||
// Read OCR output
|
||||
let result;
|
||||
try {
|
||||
const raw = fs.readFileSync(path, 'utf8');
|
||||
result = JSON.parse(raw);
|
||||
} catch (e) {
|
||||
console.log('Failed to parse OCR output:', e.message);
|
||||
// Post a simple comment if parsing fails
|
||||
const stderr = fs.readFileSync('/tmp/ocr-stderr.log', 'utf8').trim();
|
||||
if (stderr) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: `⚠️ **OpenCodeReview** encountered an error:\n${fencedBlock(stderr)}`
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const comments = result.comments || [];
|
||||
const warnings = result.warnings || [];
|
||||
|
||||
// If no comments, post a summary
|
||||
if (comments.length === 0) {
|
||||
const message = result.message || 'No comments generated. Looks good to me.';
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: `✅ **OpenCodeReview**: ${message}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare PR review with inline comments
|
||||
const prNumber = context.issue.number;
|
||||
let commitSha;
|
||||
|
||||
// Get commit SHA from event context
|
||||
if (context.eventName === 'pull_request_target') {
|
||||
commitSha = context.payload.pull_request.head.sha;
|
||||
} else {
|
||||
// For comment events, we need to fetch the PR
|
||||
const { data: pullRequest } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: prNumber
|
||||
});
|
||||
commitSha = pullRequest.head.sha;
|
||||
}
|
||||
|
||||
// Build review comments array for the PR review API
|
||||
// Only inline comments with line info can be posted via createReview
|
||||
const reviewComments = [];
|
||||
const commentsWithoutLine = [];
|
||||
|
||||
for (const comment of comments) {
|
||||
const body = formatComment(comment);
|
||||
|
||||
// Check if comment has valid line information for inline comment (line >= 1)
|
||||
const hasValidLine = (comment.start_line >= 1) || (comment.end_line >= 1);
|
||||
if (!hasValidLine) {
|
||||
commentsWithoutLine.push({ comment, body });
|
||||
continue;
|
||||
}
|
||||
|
||||
const reviewComment = {
|
||||
path: comment.path,
|
||||
body: body
|
||||
};
|
||||
|
||||
// Use line range if available
|
||||
if (comment.start_line >= 1 && comment.end_line >= 1 && comment.start_line !== comment.end_line) {
|
||||
reviewComment.start_line = comment.start_line;
|
||||
reviewComment.line = comment.end_line;
|
||||
reviewComment.start_side = 'RIGHT';
|
||||
reviewComment.side = 'RIGHT';
|
||||
} else if (comment.end_line >= 1) {
|
||||
reviewComment.line = comment.end_line;
|
||||
reviewComment.side = 'RIGHT';
|
||||
} else if (comment.start_line >= 1) {
|
||||
reviewComment.line = comment.start_line;
|
||||
reviewComment.side = 'RIGHT';
|
||||
}
|
||||
|
||||
reviewComments.push({ comment, reviewComment });
|
||||
}
|
||||
|
||||
// Submit as a single PR review with all comments
|
||||
const totalCount = comments.length;
|
||||
const inlineCount = reviewComments.length;
|
||||
const summaryCount = commentsWithoutLine.length;
|
||||
let summaryBody = buildSummaryBody(totalCount, inlineCount, summaryCount, warnings);
|
||||
|
||||
// Add comments without line info to summary body
|
||||
summaryBody += formatSummaryComments(commentsWithoutLine);
|
||||
|
||||
// Statistics tracking
|
||||
let successCount = 0;
|
||||
let failedCount = 0;
|
||||
const failedComments = [];
|
||||
|
||||
try {
|
||||
const batchRes = await github.rest.pulls.createReview({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: prNumber,
|
||||
commit_id: commitSha,
|
||||
body: summaryBody,
|
||||
event: 'COMMENT',
|
||||
comments: reviewComments.map(({ reviewComment }) => reviewComment)
|
||||
});
|
||||
successCount = reviewComments.length;
|
||||
console.log(`Successfully posted review with ${successCount} inline comments (${commentsWithoutLine.length} in summary)`);
|
||||
logRateLimitQuota(batchRes, 'after batch createReview');
|
||||
} catch (e) {
|
||||
console.log('Failed to post review with inline comments:', e.message);
|
||||
console.log('Falling back to posting comments individually with rate-limit-aware retry...');
|
||||
|
||||
// Fallback: post comments one by one with delay to avoid secondary rate limits.
|
||||
// GitHub enforces ~80 content-generating requests per minute; spacing calls
|
||||
// helps stay under that threshold. Retry/wait durations are derived from the
|
||||
// rate-limit response headers per GitHub's documented strategy.
|
||||
const MAX_RETRIES = parseInt(process.env.OCR_MAX_RETRIES, 10) || 3;
|
||||
const SUCCESS_DELAY = parseInt(process.env.OCR_SUCCESS_DELAY, 10) || 2000; // delay after successful post
|
||||
const FAILURE_DELAY = parseInt(process.env.OCR_FAILURE_DELAY, 10) || 1000; // delay after non-retryable failure
|
||||
const LOW_REMAINING_THRESHOLD = parseInt(process.env.OCR_LOW_REMAINING_THRESHOLD, 10) || 3;
|
||||
const LOW_REMAINING_SPACING = parseInt(process.env.OCR_LOW_REMAINING_SPACING, 10) || 10000;
|
||||
|
||||
// If the batch itself was rate-limited, honor its rate-limit headers
|
||||
// (retry-after / x-ratelimit-reset) before retrying per-comment,
|
||||
// otherwise the first per-comment call re-hits the same wall immediately.
|
||||
const batchRetry = computeRetryDelayMs(e, 0);
|
||||
if (batchRetry != null) {
|
||||
const secs = (batchRetry.delayMs / 1000).toFixed(1);
|
||||
console.log(
|
||||
`Batch createReview was rate-limited (HTTP ${e.status}). ` +
|
||||
`Cooling down ${secs}s via '${batchRetry.source}' (${batchRetry.detail}) before per-comment retry.`
|
||||
);
|
||||
await sleep(batchRetry.delayMs);
|
||||
}
|
||||
|
||||
for (const { comment, reviewComment } of reviewComments) {
|
||||
let posted = false;
|
||||
for (let attempt = 0; attempt <= MAX_RETRIES && !posted; attempt++) {
|
||||
try {
|
||||
const res = await github.rest.pulls.createReview({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: prNumber,
|
||||
commit_id: commitSha,
|
||||
body: '',
|
||||
event: 'COMMENT',
|
||||
comments: [reviewComment]
|
||||
});
|
||||
successCount++;
|
||||
posted = true;
|
||||
console.log(`Successfully posted comment for ${reviewComment.path}`);
|
||||
// Proactive throttle: if remaining quota is low, slow down to
|
||||
// avoid hitting the limit (GitHub best practice: watch the header).
|
||||
const remaining = logRateLimitQuota(res, `after ${reviewComment.path}`);
|
||||
const lowQuota = remaining != null && remaining <= LOW_REMAINING_THRESHOLD;
|
||||
if (lowQuota) {
|
||||
console.log(`[rate-limit] quota low (remaining=${remaining} <= ${LOW_REMAINING_THRESHOLD}); increasing spacing to ${LOW_REMAINING_SPACING}ms.`);
|
||||
await sleep(LOW_REMAINING_SPACING);
|
||||
} else {
|
||||
await sleep(SUCCESS_DELAY);
|
||||
}
|
||||
} catch (innerE) {
|
||||
// Decide whether to retry and how long to wait, based on GitHub's
|
||||
// rate-limit documentation (retry-after / x-ratelimit-* headers).
|
||||
const retryInfo = computeRetryDelayMs(innerE, attempt);
|
||||
const willRetry = retryInfo != null && attempt < MAX_RETRIES;
|
||||
if (willRetry) {
|
||||
const secs = (retryInfo.delayMs / 1000).toFixed(1);
|
||||
console.log(
|
||||
`Rate-limited/transient error on ${reviewComment.path} ` +
|
||||
`(HTTP ${innerE.status}, attempt ${attempt + 1}/${MAX_RETRIES}). ` +
|
||||
`Waiting ${secs}s via '${retryInfo.source}' (${retryInfo.detail}). ` +
|
||||
`Error: ${innerE.message}`
|
||||
);
|
||||
await sleep(retryInfo.delayMs);
|
||||
} else {
|
||||
failedCount++;
|
||||
failedComments.push({ comment, error: innerE.message });
|
||||
const reason = retryInfo == null ? 'non-retryable error' : 'rate-limit retries exhausted';
|
||||
console.log(`Failed to post comment for ${reviewComment.path} (${reason}, HTTP ${innerE.status || 'n/a'}): ${innerE.message}`);
|
||||
// After exhausting retries use the success-style pace delay;
|
||||
// for other errors use the shorter failure pace delay.
|
||||
await sleep(retryInfo == null ? FAILURE_DELAY : SUCCESS_DELAY);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Post summary comment with statistics
|
||||
let finalBody = buildSummaryBody(totalCount, successCount, commentsWithoutLine.length + failedComments.length, warnings);
|
||||
finalBody += formatSummaryComments(commentsWithoutLine);
|
||||
finalBody += `\n\n---\n\n📊 **Posting Statistics:**`;
|
||||
finalBody += `\n- ✅ Successfully posted: ${successCount} comment(s)`;
|
||||
if (failedCount > 0) {
|
||||
finalBody += `\n- ❌ Failed to post: ${failedCount} comment(s)`;
|
||||
}
|
||||
|
||||
// Add failed comments as summary content so review feedback is not lost.
|
||||
if (failedComments.length > 0) {
|
||||
finalBody += '\n\n---\n\n### ⚠️ Inline comments shown in summary';
|
||||
for (const { comment, error } of failedComments) {
|
||||
finalBody += '\n\n---\n\n';
|
||||
finalBody += formatCommentMarkdown(comment, error);
|
||||
}
|
||||
}
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
body: finalBody
|
||||
});
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// Case-insensitive header lookup. Octokit normalizes response headers to
|
||||
// lowercase, but this defensive check also handles original casing so that
|
||||
// quota logging and retry delay computation never silently miss a header.
|
||||
function getHeader(headers, name) {
|
||||
const v = headers[name] != null ? headers[name] : headers[name.toLowerCase()];
|
||||
return v != null ? String(v).trim() : undefined;
|
||||
}
|
||||
|
||||
// Decide whether an error is worth retrying and, if so, how long to wait.
|
||||
// Implements GitHub's documented rate-limit retry strategy using the
|
||||
// response headers (retry-after, x-ratelimit-remaining, x-ratelimit-reset).
|
||||
// Returns { delayMs, source, detail } when retryable, or null otherwise.
|
||||
// See: https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api
|
||||
function computeRetryDelayMs(error, attempt) {
|
||||
if (!error) return null;
|
||||
const status = error.status;
|
||||
const message = String(error.message || '');
|
||||
const isRateLimit = status === 429 || (status === 403 && /rate limit|abuse|secondary/i.test(message));
|
||||
const isTransient = (status >= 500 && status < 600) || status === 408;
|
||||
if (!isRateLimit && !isTransient) return null;
|
||||
|
||||
const headers = ((error.response || {}).headers) || {};
|
||||
const header = (name) => getHeader(headers, name);
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
|
||||
// The absolute maximum wait for any single retry. Header-derived waits
|
||||
// (retry-after / x-ratelimit-reset) are GitHub's recommended durations,
|
||||
// but capping them prevents a far-future reset from stalling the CI job
|
||||
// past its timeout. When we cap, the next retry may re-hit the limit.
|
||||
const cap = parseInt(process.env.OCR_RETRY_MAX_DELAY, 10) || 300000;
|
||||
const base = parseInt(process.env.OCR_RETRY_BASE_DELAY, 10) || 60000;
|
||||
|
||||
// { rawMs, source, detail } describing the recommended wait before cap.
|
||||
let info = null;
|
||||
|
||||
if (isRateLimit) {
|
||||
// (1) Honor "retry-after" when present (seconds, or an HTTP-date).
|
||||
const retryAfter = header('retry-after');
|
||||
if (retryAfter) {
|
||||
const secs = Number(retryAfter);
|
||||
if (!isNaN(secs) && secs >= 0) {
|
||||
info = { rawMs: secs * 1000, source: 'retry-after', detail: `${secs}s (from header)` };
|
||||
} else {
|
||||
const dateMs = Date.parse(retryAfter);
|
||||
if (!isNaN(dateMs)) {
|
||||
info = { rawMs: Math.max(0, dateMs - Date.now()), source: 'retry-after (HTTP-date)', detail: retryAfter };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// (2) Primary limit exhausted (x-ratelimit-remaining=0): wait until reset.
|
||||
if (!info) {
|
||||
const remaining = header('x-ratelimit-remaining');
|
||||
const reset = header('x-ratelimit-reset');
|
||||
if (reset != null && Number(remaining) === 0) {
|
||||
const rawMs = Math.max(0, Number(reset) - nowSec) * 1000;
|
||||
info = { rawMs, source: 'x-ratelimit-reset', detail: `remaining=0, reset epoch=${reset} (in ${Math.ceil(rawMs / 1000)}s)` };
|
||||
}
|
||||
}
|
||||
|
||||
// (3) Secondary limit with no retry hint: docs say wait at least one
|
||||
// minute, then increase exponentially between retries.
|
||||
if (!info) {
|
||||
const backoff = Math.min(base * Math.pow(2, attempt), cap);
|
||||
const jitter = Math.floor(Math.random() * 1000);
|
||||
info = { rawMs: backoff + jitter, source: 'exponential-backoff', detail: `base=${base}ms*2^${attempt} (cap ${cap}ms) +${jitter}ms jitter` };
|
||||
}
|
||||
} else {
|
||||
// Transient server error (5xx / 408): back off without the 60s floor.
|
||||
// Use a shorter base than the rate-limit path: server hiccups are
|
||||
// typically short-lived, so a 2s initial wait (doubling per retry)
|
||||
// is sufficient and avoids stalling the CI job unnecessarily.
|
||||
const transientBase = 2000;
|
||||
const backoff = Math.min(transientBase * Math.pow(2, attempt), cap);
|
||||
const jitter = Math.floor(Math.random() * 1000);
|
||||
info = { rawMs: backoff + jitter, source: 'transient-backoff', detail: `base=${transientBase}ms*2^${attempt} (cap ${cap}ms) +${jitter}ms jitter (HTTP ${status})` };
|
||||
}
|
||||
|
||||
// Apply the universal cap to header-derived waits too.
|
||||
const delayMs = Math.min(info.rawMs, cap);
|
||||
if (delayMs < info.rawMs) {
|
||||
info.detail += ` [CAPPED to ${cap}ms; GitHub recommended ${Math.ceil(info.rawMs / 1000)}s]`;
|
||||
}
|
||||
return { delayMs, source: info.source, detail: info.detail };
|
||||
}
|
||||
|
||||
// Best-effort logging of remaining rate-limit quota from a successful response.
|
||||
// Returns the parsed x-ratelimit-remaining value (or null) for proactive throttling.
|
||||
function logRateLimitQuota(response, tag) {
|
||||
try {
|
||||
const h = (response && response.headers) || {};
|
||||
const header = (name) => getHeader(h, name);
|
||||
const remaining = header('x-ratelimit-remaining');
|
||||
const limit = header('x-ratelimit-limit');
|
||||
const reset = header('x-ratelimit-reset');
|
||||
if (remaining != null) {
|
||||
console.log(
|
||||
`[rate-limit] ${tag}: remaining=${remaining}/${limit != null ? limit : '?'}` +
|
||||
(reset != null ? `, reset epoch=${reset}` : '')
|
||||
);
|
||||
}
|
||||
return remaining != null ? Number(remaining) : null;
|
||||
} catch (_) { return null; }
|
||||
}
|
||||
|
||||
function formatComment(comment) {
|
||||
let body = comment.content || '';
|
||||
|
||||
// Add code suggestion if available
|
||||
if (comment.suggestion_code && comment.existing_code) {
|
||||
body += '\n\n**Suggestion:**\n';
|
||||
body += fencedBlock(comment.suggestion_code, 'suggestion');
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
function formatCommentMarkdown(comment, error) {
|
||||
let md = `### 📄 \`${comment.path}\``;
|
||||
if (comment.start_line && comment.end_line) {
|
||||
md += ` (L${comment.start_line}-L${comment.end_line})`;
|
||||
}
|
||||
md += '\n\n';
|
||||
if (error) {
|
||||
md += `⚠️ GitHub could not post this as an inline comment: ${error}\n\n`;
|
||||
}
|
||||
md += comment.content || '';
|
||||
|
||||
if (comment.suggestion_code && comment.existing_code) {
|
||||
md += '\n\n<details><summary>💡 Suggested Change</summary>\n\n';
|
||||
md += '**Before:**\n' + fencedBlock(comment.existing_code) + '\n\n';
|
||||
md += '**After:**\n' + fencedBlock(comment.suggestion_code) + '\n\n';
|
||||
md += '</details>';
|
||||
}
|
||||
|
||||
return md;
|
||||
}
|
||||
|
||||
function buildSummaryBody(totalCount, inlineCount, summaryCount, warnings) {
|
||||
let body = `🔍 **OpenCodeReview** found **${totalCount}** issue(s) in this PR.`;
|
||||
if (totalCount > 0) {
|
||||
body += `\n- ✅ ${inlineCount} posted as inline comment(s)`;
|
||||
body += `\n- 📝 ${summaryCount} posted as summary`;
|
||||
}
|
||||
if (warnings.length > 0) {
|
||||
body += `\n\n⚠️ ${warnings.length} warning(s) occurred during review.`;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
function formatSummaryComments(summaryComments) {
|
||||
let body = '';
|
||||
for (const { comment } of summaryComments) {
|
||||
body += '\n\n---\n\n';
|
||||
body += formatCommentMarkdown(comment);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
function fencedBlock(content, language = '') {
|
||||
const text = String(content || '');
|
||||
const fence = safeFence(text);
|
||||
let block = fence + language + '\n' + text;
|
||||
if (!text.endsWith('\n')) block += '\n';
|
||||
return block + fence;
|
||||
}
|
||||
|
||||
function safeFence(content) {
|
||||
const matches = String(content || '').match(/`+/g) || [];
|
||||
const maxTicks = matches.reduce((max, ticks) => Math.max(max, ticks.length), 0);
|
||||
return '`'.repeat(Math.max(3, maxTicks + 1));
|
||||
}
|
||||
llm_url: ${{ secrets.OCR_LLM_URL }}
|
||||
llm_auth_token: ${{ secrets.OCR_LLM_AUTH_TOKEN }}
|
||||
llm_model: ${{ vars.OCR_LLM_MODEL }}
|
||||
llm_use_anthropic: ${{ vars.OCR_LLM_USE_ANTHROPIC }}
|
||||
# For issue_comment triggers, pass the resolved refs; for
|
||||
# pull_request_target the action resolves them from the event.
|
||||
base_ref: ${{ steps.pr-context.outputs.base_ref }}
|
||||
head_sha: ${{ steps.pr-context.outputs.head_sha }}
|
||||
|
|
|
|||
3
extensions/vscode/.gitignore
vendored
3
extensions/vscode/.gitignore
vendored
|
|
@ -2,4 +2,5 @@
|
|||
.superpowers/
|
||||
node_modules/
|
||||
reference
|
||||
out/
|
||||
out/
|
||||
*.vsix
|
||||
|
|
@ -13,3 +13,4 @@ jest.config.js
|
|||
.superpowers/**
|
||||
prototype.html
|
||||
resources/icon-old.svg
|
||||
__mocks__/**
|
||||
|
|
|
|||
201
extensions/vscode/LICENSE
Normal file
201
extensions/vscode/LICENSE
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this definition, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to the Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a complaint) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that you distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act on
|
||||
Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "{}"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the same
|
||||
"printed page" as the copyright notice for easier identification within
|
||||
third-party archives.
|
||||
|
||||
Copyright 2026 Alibaba
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
Binary file not shown.
|
|
@ -2,12 +2,12 @@
|
|||
"name": "open-code-review-vscode",
|
||||
"displayName": "Open Code Review",
|
||||
"description": "%ocr.description%",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"publisher": "open-code-review",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/nigulasikk/open-code-review.git",
|
||||
"url": "git+https://github.com/alibaba/open-code-review.git",
|
||||
"directory": "extensions/vscode"
|
||||
},
|
||||
"engines": {
|
||||
|
|
@ -16,6 +16,7 @@
|
|||
"categories": [
|
||||
"Other"
|
||||
],
|
||||
"icon": "resources/icon.png",
|
||||
"main": "./out/extension.js",
|
||||
"activationEvents": [
|
||||
"onStartupFinished"
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><circle cx="12" cy="12" r="6" fill="#45e6a4"/></svg>
|
||||
|
Before Width: | Height: | Size: 136 B |
BIN
extensions/vscode/resources/icon.png
Normal file
BIN
extensions/vscode/resources/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
|
|
@ -1,21 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" role="img" aria-label="Code review">
|
||||
<!-- 活动栏图标按单色蒙版渲染:只有不透明形状会被填充为主题色,
|
||||
因此背景必须透明,且用 fill-rule:evenodd 挖出负空间。 -->
|
||||
<path fill="#000000" fill-rule="evenodd" d="
|
||||
M 12 2
|
||||
L 18.5 4.4
|
||||
Q 19.5 4.8 19.5 5.9
|
||||
L 19.5 16.8
|
||||
Q 19.5 17.6 18.9 18.1
|
||||
L 12 23
|
||||
L 5.1 18.1
|
||||
Q 4.5 17.6 4.5 16.8
|
||||
L 4.5 5.9
|
||||
Q 4.5 4.8 5.5 4.4
|
||||
Z
|
||||
M 12 8.2
|
||||
A 3.8 3.8 0 1 0 12 15.8
|
||||
A 3.8 3.8 0 1 0 12 8.2
|
||||
Z"/>
|
||||
<circle cx="12" cy="12" r="1.7" fill="#000000"/>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="64" height="64" viewBox="0 0 64 64"><defs><clipPath id="master_svg0_28_08612"><rect x="0" y="0" width="64" height="64" rx="16"/></clipPath></defs><g clip-path="url(#master_svg0_28_08612)"><path d="M16.011564759375,24.651045500000002C14.199107859375001,26.048609499999998,12.604812959375,27.6949075,11.290276049375,29.5567475C11.202752649375,29.6807115,10.984475776375,29.5737765,11.051337435375,29.4375555C12.365951259375,26.7591675,14.005819559375,24.3629835,15.886684859375,22.313341100000002L15.886684859375,14.0910532C15.886684859375,13.28568906,16.539561259375,12.6328125,17.344925359375,12.6328125L46.644012359375,12.6328125C47.449375359375,12.6328125,48.102253359375,13.28568906,48.102253359375,14.0910536L48.102253359375,24.7461315C43.604339359375,21.2891407,37.941707359375,19.4149718,31.994632359375,19.4149718C26.104784359375,19.4149718,20.491793659375,21.2537947,16.011564759375,24.651045500000002ZM31.994556359375,36.8882465C35.140150359375,36.8882465,37.690157359375,34.3323655,37.690157359375,31.1795235C37.690157359375,30.5937725,37.600223359375,30.0115585,37.423504359375,29.4532315C36.963556359375005,30.4290525,35.982988359375,31.0517195,34.906236359375,31.0517195C33.368648359375,31.0517195,32.122188359375,29.8023895,32.122188359375,28.2612605C32.122188359375,27.1816535,32.743179359375,26.199264499999998,33.717109359375,25.738142500000002C33.160028359375005,25.5609735,32.579012359375,25.4707995,31.994556359375,25.4707995C28.848966359375,25.4707995,26.298958359375,28.026681500000002,26.298958359375,31.1795235C26.298958359375,34.3323655,28.848966359375,36.8882465,31.994556359375,36.8882465ZM47.152950359375,40.7200105C49.430202359375,38.5980795,51.405811359375,36.0023425,52.946987359375,33.029174499999996C53.017212359375,32.893690500000005,52.805626359375,32.7626725,52.716545359375,32.886579499999996C51.366592359375,34.7642615,49.730171359375,36.4189315,47.871692359375,37.8158765C47.873394359375,37.8053205,47.875091359375,37.7947615,47.876773359375,37.7842025C43.466114359375,41.092451499999996,37.971618359375,42.9006615,32.199829359375,42.9434035C32.191536359375,42.9434665,32.183244359374996,42.9435235,32.174951359375,42.9435765C32.114826359375,42.943948500000005,32.054660359375,42.9441415,31.994453359375,42.9441415C26.150606359375,42.9441185,20.577616659375,41.1334405,16.112119659375,37.7839225C17.329820159375,45.4193075,23.367437359375,51.7093845,31.457872359375,54.2405735C31.808345359375,54.3502275,32.180591359375,54.3502275,32.531065359375,54.2405775C39.557903359375004,52.0421445,45.036186359375,47.0081745,47.152950359375,40.7200105Z" fill-rule="evenodd" fill="#000000" fill-opacity="1"/></g></svg>
|
||||
|
Before Width: | Height: | Size: 664 B After Width: | Height: | Size: 2.7 KiB |
|
|
@ -16,7 +16,7 @@ export function activate(context: vscode.ExtensionContext): void {
|
|||
const cli = new CliService('ocr');
|
||||
const config = new ConfigService(cli);
|
||||
const git = new GitService(output);
|
||||
const comments = new CommentProvider(extensionUri);
|
||||
const comments = new CommentProvider(extensionUri, git);
|
||||
|
||||
const sidebar = new SidebarProvider(extensionUri, cli, config, git, comments);
|
||||
const configPanel = new ConfigPanelProvider(extensionUri, cli, config, (cfg) => sidebar.pushConfig(cfg));
|
||||
|
|
|
|||
|
|
@ -1,22 +1,34 @@
|
|||
import { t, resolveLocale, SupportedLocale } from '../../shared/i18n';
|
||||
import * as vscode from 'vscode';
|
||||
import { ReviewComment, CommentStatus, CommentSyncState } from '../../shared/types';
|
||||
import { ReviewComment, CommentStatus, CommentSyncState, ReviewContext, ReviewMode } from '../../shared/types';
|
||||
import { COMMENT_CONTROLLER_ID } from '../../shared/constants';
|
||||
import { LineOffsetTracker } from './lineOffset';
|
||||
import { GitService } from '../services/GitService';
|
||||
import {
|
||||
MountableCommentAnchor,
|
||||
resolveCommentAnchor,
|
||||
CommentAnchorDeps,
|
||||
SidebarOnlyReason,
|
||||
} from './commentAnchor';
|
||||
|
||||
export class CommentProvider {
|
||||
private controller: vscode.CommentController;
|
||||
// 以 comment 在 result.comments 中的原始下标为 key,与 webview 共用同一索引空间。
|
||||
// 打不开的文件(如目录)没有 thread,但下标依旧保留,避免错位。
|
||||
private threads = new Map<number, vscode.CommentThread>();
|
||||
private mounts = new Map<number, MountableCommentAnchor>();
|
||||
private jumpable = new Set<number>();
|
||||
private jumpBlockReasons = new Map<number, SidebarOnlyReason | 'mount-failed'>();
|
||||
private comments: ReviewComment[] = [];
|
||||
private status = new Map<number, CommentStatus>();
|
||||
private offsets = new LineOffsetTracker();
|
||||
private syncListeners: Array<(s: CommentSyncState[]) => void> = [];
|
||||
private reviewContext: ReviewContext = { mode: ReviewMode.Workspace };
|
||||
|
||||
private locale: SupportedLocale;
|
||||
|
||||
constructor(private extensionUri: vscode.Uri) {
|
||||
constructor(
|
||||
private extensionUri: vscode.Uri,
|
||||
private git: GitService,
|
||||
) {
|
||||
this.locale = resolveLocale(vscode.env.language);
|
||||
this.controller = vscode.comments.createCommentController(COMMENT_CONTROLLER_ID, t(this.locale, 'ext.commentController'));
|
||||
}
|
||||
|
|
@ -27,62 +39,92 @@ export class CommentProvider {
|
|||
|
||||
private emitSync(): void {
|
||||
const states: CommentSyncState[] = this.comments.map((_, i) => ({
|
||||
index: i, status: this.status.get(i) ?? 'pending',
|
||||
index: i,
|
||||
status: this.status.get(i) ?? 'pending',
|
||||
jumpable: this.jumpable.has(i),
|
||||
}));
|
||||
this.syncListeners.forEach((fn) => fn(states));
|
||||
}
|
||||
|
||||
/**
|
||||
* 展示审查评论。
|
||||
* @param inEditor 是否在编辑器内创建 CommentThread。仅工作区模式为 true;
|
||||
* 分支对比/单次提交模式下被审查代码不在当前工作区,行号会错位,故只在侧边栏展示。
|
||||
*/
|
||||
async show(comments: ReviewComment[], inEditor = true): Promise<void> {
|
||||
/** 展示审查评论:能解析到 git/工作区快照的挂 thread,否则仅侧边栏。 */
|
||||
async show(comments: ReviewComment[], ctx: ReviewContext): Promise<void> {
|
||||
this.clear();
|
||||
// 不重排:保持与 webview(result.comments)相同的顺序与下标
|
||||
this.reviewContext = ctx;
|
||||
this.comments = comments;
|
||||
|
||||
const root = vscode.workspace.workspaceFolders?.[0].uri.fsPath;
|
||||
if (!root) return;
|
||||
|
||||
// 非工作区模式:只登记评论与状态供侧边栏同步,不在编辑器内放置 thread。
|
||||
if (!inEditor) {
|
||||
for (let i = 0; i < this.comments.length; i++) this.status.set(i, 'pending');
|
||||
this.emitSync();
|
||||
return;
|
||||
}
|
||||
await this.git.prepareReviewFileStatus(ctx);
|
||||
const deps = this.buildAnchorDeps(root, ctx);
|
||||
|
||||
let firstShown = -1;
|
||||
for (let i = 0; i < this.comments.length; i++) {
|
||||
const c = this.comments[i];
|
||||
this.status.set(i, 'pending');
|
||||
|
||||
const anchor = await resolveCommentAnchor(c, ctx, deps);
|
||||
if (anchor.kind === 'sidebar') {
|
||||
this.jumpBlockReasons.set(i, anchor.reason);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const uri = vscode.Uri.file(`${root}/${c.path}`);
|
||||
const doc = await vscode.workspace.openTextDocument(uri);
|
||||
const range = new vscode.Range(Math.max(0, c.startLine - 1), 0, Math.max(0, c.endLine - 1), 0);
|
||||
const body = this.renderBody(c, i, 'pending');
|
||||
const thread = this.controller.createCommentThread(doc.uri, range, [{
|
||||
body, mode: vscode.CommentMode.Preview,
|
||||
await vscode.workspace.openTextDocument(anchor.uri);
|
||||
const body = this.renderBody(c, i, 'pending', anchor.locateNote);
|
||||
const thread = this.controller.createCommentThread(anchor.uri, anchor.range, [{
|
||||
body,
|
||||
mode: vscode.CommentMode.Preview,
|
||||
author: { name: t(this.locale, 'ext.comment.pending') },
|
||||
}]);
|
||||
thread.canReply = false;
|
||||
thread.label = `${t(this.locale, 'ext.comment.threadLabel')} (${i + 1} / ${this.comments.length})`;
|
||||
// 有代码建议 → 'pending'(显示应用+忽略);无建议 → 'pendingNoSuggestion'(仅忽略)
|
||||
thread.contextValue = this.hasSuggestion(c) ? 'pending' : 'pendingNoSuggestion';
|
||||
thread.contextValue = this.threadContextValue(c, ctx);
|
||||
thread.collapsibleState = vscode.CommentThreadCollapsibleState.Expanded;
|
||||
this.threads.set(i, thread);
|
||||
this.mounts.set(i, anchor);
|
||||
this.jumpable.add(i);
|
||||
if (firstShown < 0) firstShown = i;
|
||||
} catch { /* 文件打不开(如目录)则无 thread,但保留下标 */ }
|
||||
} catch {
|
||||
this.jumpBlockReasons.set(i, 'mount-failed');
|
||||
}
|
||||
}
|
||||
|
||||
if (firstShown >= 0) await this.jumpTo(firstShown);
|
||||
this.emitSync();
|
||||
}
|
||||
|
||||
private buildAnchorDeps(root: string, ctx: ReviewContext): CommentAnchorDeps {
|
||||
return {
|
||||
repoRoot: root,
|
||||
fileStatus: (path) => this.git.getReviewFileStatus(path),
|
||||
readAtRef: (ref, path) => this.git.readFileAtRef(ref, path),
|
||||
readWorkspace: (path) => this.git.readWorkspaceFile(path),
|
||||
buildDiffUris: (path, status) => this.git.buildCommentDiffUris(path, status, ctx),
|
||||
toGitUri: async (path, ref) => {
|
||||
const uri = await this.git.createGitFileUri(path, ref);
|
||||
if (!uri) throw new Error('git uri unavailable');
|
||||
return uri;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private threadContextValue(c: ReviewComment, ctx: ReviewContext): string {
|
||||
if (ctx.mode !== ReviewMode.Workspace) return 'pendingNoSuggestion';
|
||||
return this.hasSuggestion(c) ? 'pending' : 'pendingNoSuggestion';
|
||||
}
|
||||
|
||||
private hasSuggestion(c: ReviewComment): boolean {
|
||||
return !!(c.suggestionCode && c.suggestionCode.trim());
|
||||
}
|
||||
|
||||
private renderBody(c: ReviewComment, _index: number, _status: CommentStatus): vscode.MarkdownString {
|
||||
let md = c.content;
|
||||
private renderBody(
|
||||
c: ReviewComment,
|
||||
_index: number,
|
||||
_status: CommentStatus,
|
||||
locateNote?: string,
|
||||
): vscode.MarkdownString {
|
||||
let md = locateNote ? `${locateNote}\n\n${c.content}` : c.content;
|
||||
if (this.hasSuggestion(c)) {
|
||||
md += `\n***\n\`\`\`diff\n${c.suggestionCode}\n\`\`\``;
|
||||
} else {
|
||||
|
|
@ -94,6 +136,10 @@ export class CommentProvider {
|
|||
}
|
||||
|
||||
async apply(index: number): Promise<void> {
|
||||
if (this.reviewContext.mode !== ReviewMode.Workspace) {
|
||||
vscode.window.showWarningMessage(t(this.locale, 'ext.comment.applyWorkspaceOnly'));
|
||||
return;
|
||||
}
|
||||
const c = this.comments[index];
|
||||
if (!c) return;
|
||||
const root = vscode.workspace.workspaceFolders?.[0].uri.fsPath;
|
||||
|
|
@ -110,8 +156,6 @@ export class CommentProvider {
|
|||
const range = new vscode.Range(start, 0, end, doc.lineAt(end).text.length);
|
||||
const hasSuggestion = !!(c.suggestionCode && c.suggestionCode.trim());
|
||||
|
||||
// 用 WorkspaceEdit 而非 editor.edit:后者要求目标编辑器为活动编辑器,
|
||||
// 从评论标题栏按钮触发时焦点在评论控件上,会静默返回 false 导致“点不动”。
|
||||
const edit = new vscode.WorkspaceEdit();
|
||||
if (hasSuggestion) edit.replace(uri, range, c.suggestionCode!);
|
||||
else edit.delete(uri, range);
|
||||
|
|
@ -132,6 +176,7 @@ export class CommentProvider {
|
|||
private setStatus(index: number, status: CommentStatus): void {
|
||||
this.status.set(index, status);
|
||||
const thread = this.threads.get(index);
|
||||
const mount = this.mounts.get(index);
|
||||
if (thread) {
|
||||
const label = {
|
||||
applied: t(this.locale, 'ext.comment.statusApplied'),
|
||||
|
|
@ -139,7 +184,11 @@ export class CommentProvider {
|
|||
falsePositive: t(this.locale, 'ext.comment.statusFalsePositive'),
|
||||
pending: t(this.locale, 'ext.comment.pending'),
|
||||
}[status];
|
||||
thread.comments = [{ ...thread.comments[0], author: { name: label }, body: this.renderBody(this.comments[index], index, status) }] as any;
|
||||
thread.comments = [{
|
||||
...thread.comments[0],
|
||||
author: { name: label },
|
||||
body: this.renderBody(this.comments[index], index, status, mount?.locateNote),
|
||||
}] as any;
|
||||
thread.contextValue = status;
|
||||
thread.collapsibleState = vscode.CommentThreadCollapsibleState.Collapsed;
|
||||
}
|
||||
|
|
@ -147,27 +196,128 @@ export class CommentProvider {
|
|||
}
|
||||
|
||||
async jumpTo(index: number): Promise<void> {
|
||||
const mount = this.mounts.get(index);
|
||||
const thread = this.threads.get(index);
|
||||
if (!thread) {
|
||||
const c = this.comments[index];
|
||||
if (c) vscode.window.showWarningMessage(`${t(this.locale, 'ext.comment.jumpFailed')}${c.path}${t(this.locale, 'ext.comment.jumpNotAFile')}`);
|
||||
if (!mount || !thread) {
|
||||
this.showJumpFailed(index);
|
||||
return;
|
||||
}
|
||||
await vscode.window.showTextDocument(thread.uri, { selection: thread.range, preview: false });
|
||||
|
||||
if (mount.diff) {
|
||||
const opened = await this.openDiffEditor(mount);
|
||||
if (opened) {
|
||||
await this.revealInDiffSide(mount.uri, mount.range, mount.side);
|
||||
} else {
|
||||
await vscode.window.showTextDocument(mount.uri, { selection: mount.range, preview: false });
|
||||
}
|
||||
} else {
|
||||
await vscode.window.showTextDocument(mount.uri, { selection: mount.range, preview: false });
|
||||
}
|
||||
thread.collapsibleState = vscode.CommentThreadCollapsibleState.Expanded;
|
||||
}
|
||||
|
||||
private showJumpFailed(index: number): void {
|
||||
const c = this.comments[index];
|
||||
if (!c) return;
|
||||
|
||||
const reason = this.jumpBlockReasons.get(index) ?? this.inferJumpBlockReason(c);
|
||||
const key = reason === 'missing-file' || reason === 'mount-failed'
|
||||
? 'ext.comment.jumpFileMissing'
|
||||
: 'ext.comment.jumpLineUnresolved';
|
||||
vscode.window.showWarningMessage(
|
||||
t(this.locale, key).replace('{path}', c.path),
|
||||
);
|
||||
}
|
||||
|
||||
/** 未记录原因时根据评论元数据推断(如 L0 多为行号未解析)。 */
|
||||
private inferJumpBlockReason(c: ReviewComment): SidebarOnlyReason | 'mount-failed' {
|
||||
if (c.startLine <= 0 && c.endLine <= 0) return 'unresolved';
|
||||
return 'missing-file';
|
||||
}
|
||||
|
||||
private async openDiffEditor(mount: MountableCommentAnchor): Promise<boolean> {
|
||||
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<void> {
|
||||
for (let attempt = 0; attempt < 8; attempt++) {
|
||||
const editor = this.findEditorForUri(uri, side);
|
||||
if (editor) {
|
||||
editor.selection = new vscode.Selection(range.start, range.end);
|
||||
editor.revealRange(range, vscode.TextEditorRevealType.InCenter);
|
||||
return;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 40));
|
||||
}
|
||||
}
|
||||
|
||||
private findEditorForUri(
|
||||
uri: vscode.Uri,
|
||||
side?: MountableCommentAnchor['side'],
|
||||
): vscode.TextEditor | undefined {
|
||||
for (const editor of vscode.window.visibleTextEditors) {
|
||||
if (this.urisMatch(editor.document.uri, uri)) return editor;
|
||||
}
|
||||
|
||||
const tab = vscode.window.tabGroups.activeTabGroup.activeTab;
|
||||
if (tab?.input instanceof vscode.TabInputTextDiff) {
|
||||
const { original, modified } = tab.input;
|
||||
const prefer = side === 'left' ? original : side === 'right' ? modified : undefined;
|
||||
const candidates = prefer ? [prefer, original, modified] : [original, modified];
|
||||
for (const candidate of candidates) {
|
||||
if (!this.urisMatch(candidate, uri)) continue;
|
||||
const editor = vscode.window.visibleTextEditors.find(
|
||||
(ed) => this.urisMatch(ed.document.uri, candidate),
|
||||
);
|
||||
if (editor) return editor;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private urisMatch(a: vscode.Uri, b: vscode.Uri): boolean {
|
||||
if (a.toString() === b.toString()) return true;
|
||||
if (a.scheme !== b.scheme) return false;
|
||||
if (a.scheme === 'git') {
|
||||
const refA = new URLSearchParams(a.query).get('ref');
|
||||
const refB = new URLSearchParams(b.query).get('ref');
|
||||
return a.path === b.path && refA === refB;
|
||||
}
|
||||
return a.fsPath === b.fsPath;
|
||||
}
|
||||
|
||||
indexOfThread(thread: vscode.CommentThread): number {
|
||||
for (const [i, t] of this.threads) if (t === thread) return i;
|
||||
for (const [i, th] of this.threads) if (th === thread) return i;
|
||||
return -1;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.threads.forEach((t) => t.dispose());
|
||||
this.threads.forEach((th) => th.dispose());
|
||||
this.threads.clear();
|
||||
this.mounts.clear();
|
||||
this.jumpable.clear();
|
||||
this.jumpBlockReasons.clear();
|
||||
this.comments = [];
|
||||
this.status.clear();
|
||||
this.offsets.clear();
|
||||
this.reviewContext = { mode: ReviewMode.Workspace };
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { resolveLocale, toHtmlLang } from '../../shared/i18n';
|
|||
import * as vscode from 'vscode';
|
||||
import { ConfigPanelFocus } from '../../shared/configUtils';
|
||||
import { HostToWebview, WebviewToHost } from '../../shared/messages';
|
||||
import { FileChange } from '../../shared/types';
|
||||
import { FileChange, ReviewMode } from '../../shared/types';
|
||||
import { CliService } from '../services/CliService';
|
||||
import { ConfigService } from '../services/ConfigService';
|
||||
import { GitService } from '../services/GitService';
|
||||
|
|
@ -13,6 +13,7 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
|
|||
private view?: vscode.WebviewView;
|
||||
private session?: ReviewSession;
|
||||
private openConfigPanel?: (focus?: ConfigPanelFocus) => void;
|
||||
private gitWatchDisposable?: vscode.Disposable;
|
||||
|
||||
constructor(
|
||||
private extensionUri: vscode.Uri,
|
||||
|
|
@ -37,6 +38,16 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
|
|||
view.webview.options = { enableScripts: true, localResourceRoots: [this.extensionUri] };
|
||||
view.webview.html = this.html(view.webview);
|
||||
view.webview.onDidReceiveMessage((msg: WebviewToHost) => this.handle(msg));
|
||||
|
||||
this.gitWatchDisposable?.dispose();
|
||||
this.gitWatchDisposable = this.git.watchWorkspaceChanges((gitState) => {
|
||||
this.post({ type: 'gitState', gitState });
|
||||
});
|
||||
view.onDidDispose(() => {
|
||||
this.gitWatchDisposable?.dispose();
|
||||
this.gitWatchDisposable = undefined;
|
||||
this.view = undefined;
|
||||
});
|
||||
}
|
||||
|
||||
private post(msg: HostToWebview): void {
|
||||
|
|
@ -48,7 +59,7 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
|
|||
switch (msg.type) {
|
||||
case 'ready': {
|
||||
const config = this.config.read();
|
||||
const gitState = await this.git.getState('workspace');
|
||||
const gitState = await this.git.getState(ReviewMode.Workspace);
|
||||
const locale = resolveLocale(vscode.env.language);
|
||||
this.post({ type: 'init', config, gitState, locale });
|
||||
break;
|
||||
|
|
@ -59,9 +70,9 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
|
|||
}
|
||||
case 'getModeFiles': {
|
||||
let files: FileChange[] = [];
|
||||
if (msg.mode === 'branch' && msg.from && msg.to) {
|
||||
if (msg.mode === ReviewMode.Branch && msg.from && msg.to) {
|
||||
files = await this.git.getBranchDiff(msg.from, msg.to);
|
||||
} else if (msg.mode === 'commit' && msg.commit) {
|
||||
} else if (msg.mode === ReviewMode.Commit && msg.commit) {
|
||||
files = await this.git.getCommitFiles(msg.commit);
|
||||
}
|
||||
this.post({ type: 'modeFiles', mode: msg.mode, files });
|
||||
|
|
@ -75,14 +86,21 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
|
|||
break;
|
||||
case 'startReview': {
|
||||
this.session = new ReviewSession(this.cli, cwd);
|
||||
// 仅工作区模式在编辑器内放置评论 thread;分支/提交模式代码不在工作区,会错位。
|
||||
const inEditor = msg.options.mode === 'workspace';
|
||||
await this.session.run(msg.options, {
|
||||
onState: (state, error) => this.post({ type: 'stateChange', state, error }),
|
||||
onLog: (line) => this.post({ type: 'logLine', line }),
|
||||
onDone: (result) => {
|
||||
this.post({ type: 'reviewDone', result });
|
||||
if (result.comments.length) this.comments.show(result.comments, inEditor);
|
||||
void (async () => {
|
||||
if (result.comments.length) {
|
||||
await this.comments.show(result.comments, {
|
||||
mode: msg.options.mode,
|
||||
from: msg.options.from,
|
||||
to: msg.options.to,
|
||||
commit: msg.options.commit,
|
||||
});
|
||||
}
|
||||
this.post({ type: 'reviewDone', result });
|
||||
})();
|
||||
},
|
||||
});
|
||||
break;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
import {
|
||||
findLinesByExistingCode,
|
||||
normalizeLine,
|
||||
resolveLinesInContent,
|
||||
splitAndNormalize,
|
||||
} from '../commentAnchor';
|
||||
|
||||
describe('commentAnchor line resolution', () => {
|
||||
const content = ['line1', 'for (let i = 0; i <= 30, i++) {', ' console.log(i);', '}', 'line5'].join('\n');
|
||||
|
||||
it('normalizeLine strips diff markers', () => {
|
||||
expect(normalizeLine('+added')).toBe('added');
|
||||
expect(normalizeLine('-removed')).toBe('removed');
|
||||
});
|
||||
|
||||
it('splitAndNormalize skips blank lines', () => {
|
||||
expect(splitAndNormalize('a\n\n b ')).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('resolveLinesInContent uses explicit line numbers when in range', () => {
|
||||
expect(resolveLinesInContent(content, 2, 2)).toEqual({ start: 2, end: 2, relocated: false });
|
||||
});
|
||||
|
||||
it('resolveLinesInContent falls back to existingCode when line out of range', () => {
|
||||
const code = 'for (let i = 0; i <= 30, i++) {';
|
||||
const result = resolveLinesInContent(content, 99, 99, code);
|
||||
expect(result).toEqual({ start: 2, end: 2, relocated: true });
|
||||
});
|
||||
|
||||
it('findLinesByExistingCode matches consecutive non-blank lines', () => {
|
||||
const found = findLinesByExistingCode(content, 'console.log(i);');
|
||||
expect(found).toEqual({ start: 3, end: 3 });
|
||||
});
|
||||
|
||||
it('returns null when neither line nor existingCode resolves', () => {
|
||||
expect(resolveLinesInContent(content, 99, 99)).toBeNull();
|
||||
expect(resolveLinesInContent(content, 0, 0)).toBeNull();
|
||||
});
|
||||
});
|
||||
197
extensions/vscode/src/extension/providers/commentAnchor.ts
Normal file
197
extensions/vscode/src/extension/providers/commentAnchor.ts
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
import * as vscode from 'vscode';
|
||||
import { ReviewComment, ReviewContext, ReviewMode } from '../../shared/types';
|
||||
|
||||
export type CommentMountSide = 'left' | 'right' | 'workspace';
|
||||
|
||||
/** 可挂载到编辑器 / diff 的定位结果。 */
|
||||
export interface MountableCommentAnchor {
|
||||
kind: 'mountable';
|
||||
uri: vscode.Uri;
|
||||
range: vscode.Range;
|
||||
side: CommentMountSide;
|
||||
/** commit/branch 模式跳转时打开原生 diff。 */
|
||||
diff?: {
|
||||
left: vscode.Uri;
|
||||
right: vscode.Uri;
|
||||
title: string;
|
||||
};
|
||||
/** 行号经 existingCode 重新定位时附带说明。 */
|
||||
locateNote?: string;
|
||||
}
|
||||
|
||||
/** 无法在快照中定位,仅侧边栏展示。 */
|
||||
export interface SidebarOnlyCommentAnchor {
|
||||
kind: 'sidebar';
|
||||
reason: 'binary' | 'unresolved' | 'missing-file';
|
||||
}
|
||||
|
||||
export type SidebarOnlyReason = SidebarOnlyCommentAnchor['reason'];
|
||||
|
||||
export type CommentAnchorResult = MountableCommentAnchor | SidebarOnlyCommentAnchor;
|
||||
|
||||
export interface CommentAnchorDeps {
|
||||
repoRoot: string;
|
||||
fileStatus: (path: string) => Promise<'added' | 'modified' | 'deleted' | 'renamed' | 'binary' | null>;
|
||||
readAtRef: (ref: string, path: string) => Promise<string | null>;
|
||||
readWorkspace: (path: string) => Promise<string | null>;
|
||||
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<vscode.Uri>;
|
||||
}
|
||||
|
||||
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<CommentAnchorResult> {
|
||||
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.';
|
||||
}
|
||||
|
|
@ -1,11 +1,16 @@
|
|||
import { t, resolveLocale } from '../../shared/i18n';
|
||||
import * as vscode from 'vscode';
|
||||
import { readFile } from 'fs/promises';
|
||||
import { execFile } from 'child_process';
|
||||
import { GitState, CommitInfo, FileChange, ReviewMode } from '../../shared/types';
|
||||
import { parsePorcelain, parseNameStatus, pickRepoRoot } from './gitMap';
|
||||
import { GitState, FileChange, ReviewMode, ReviewContext } from '../../shared/types';
|
||||
import { buildWorkspaceFiles, branchRefCandidates, parseNameStatus, pickRepoRoot } from './gitMap';
|
||||
|
||||
const WORKSPACE_REFRESH_DEBOUNCE_MS = 300;
|
||||
|
||||
export class GitService {
|
||||
private api: any | null = null;
|
||||
private cache: GitState = { branches: [], currentBranch: '', recentCommits: [], workspaceFiles: [] };
|
||||
private reviewFileStatus = new Map<string, FileChange['status']>();
|
||||
|
||||
constructor(private log?: vscode.OutputChannel) {}
|
||||
|
||||
|
|
@ -61,57 +66,155 @@ export class GitService {
|
|||
|
||||
async getState(mode: ReviewMode): Promise<GitState> {
|
||||
const empty: GitState = { branches: [], currentBranch: '', recentCommits: [], workspaceFiles: [] };
|
||||
|
||||
if (mode === ReviewMode.Workspace) {
|
||||
await this.refreshWorkspaceFiles();
|
||||
return { ...this.cache };
|
||||
}
|
||||
|
||||
const repo = await this.waitForRepo();
|
||||
if (!repo) {
|
||||
this.trace(`getState(${mode}): no repo`);
|
||||
return empty;
|
||||
}
|
||||
|
||||
let currentBranch = '';
|
||||
try {
|
||||
currentBranch = repo.state.HEAD?.name || '';
|
||||
this.cache.currentBranch = repo.state.HEAD?.name || '';
|
||||
} catch { /* ignore */ }
|
||||
|
||||
let branches: string[] = [];
|
||||
if (mode === ReviewMode.Branch) {
|
||||
await this.refreshBranches(repo);
|
||||
} else if (mode === ReviewMode.Commit) {
|
||||
await this.refreshRecentCommits(repo);
|
||||
}
|
||||
|
||||
return { ...this.cache };
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅 VS Code Git 扩展的仓库状态变化,debounce 后刷新工作区文件列表。
|
||||
* 用于侧边栏工作区模式实时反映暂存/工作区/未跟踪变更。
|
||||
*/
|
||||
watchWorkspaceChanges(onUpdate: (state: GitState) => void): vscode.Disposable {
|
||||
const cleanups: vscode.Disposable[] = [];
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | 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<void> {
|
||||
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<string | null> {
|
||||
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<void> {
|
||||
try {
|
||||
const refs = await repo.getBranches({ remote: true });
|
||||
branches = refs.map((r: any) => r.name).filter(Boolean);
|
||||
} catch { /* ignore */ }
|
||||
this.cache.branches = refs.map((r: any) => r.name).filter(Boolean);
|
||||
} catch {
|
||||
this.cache.branches = [];
|
||||
}
|
||||
}
|
||||
|
||||
let recentCommits: CommitInfo[] = [];
|
||||
private async refreshRecentCommits(repo: any): Promise<void> {
|
||||
try {
|
||||
const commits = await repo.log({ maxEntries: 20 });
|
||||
recentCommits = commits.map((c: any) => ({
|
||||
this.cache.recentCommits = commits.map((c: any) => ({
|
||||
sha: c.hash.slice(0, 7),
|
||||
message: c.message.split('\n')[0],
|
||||
relativeTime: formatRelative(c.authorDate),
|
||||
}));
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// 工作区变更直接走 git status --porcelain,避免依赖扩展懒填充的 state 数组。
|
||||
let workspaceFiles: FileChange[] = [];
|
||||
try {
|
||||
const root: string = repo.rootUri?.fsPath
|
||||
?? vscode.workspace.workspaceFolders?.[0].uri.fsPath
|
||||
?? process.cwd();
|
||||
const out = await runGit(root, ['status', '--porcelain']);
|
||||
workspaceFiles = parsePorcelain(out);
|
||||
this.trace(`getState(${mode}): root=${root} porcelainBytes=${out.length} files=${workspaceFiles.length}`);
|
||||
} catch (e) {
|
||||
this.trace(`getState(${mode}): status failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
} catch {
|
||||
this.cache.recentCommits = [];
|
||||
}
|
||||
|
||||
return { branches, currentBranch, recentCommits, workspaceFiles };
|
||||
}
|
||||
|
||||
/** 分支对比:merge-base 三点 diff。 */
|
||||
async getBranchDiff(from: string, to: string): Promise<FileChange[]> {
|
||||
const root = await this.repoRoot();
|
||||
if (!root || !from || !to) return [];
|
||||
|
||||
const resolvedFrom = await this.resolveGitRef(root, from);
|
||||
const resolvedTo = await this.resolveGitRef(root, to);
|
||||
if (!resolvedFrom || !resolvedTo) {
|
||||
this.trace(`getBranchDiff: unresolved ref from=${from} to=${to}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const out = await runGit(root, ['diff', '--name-status', `${from}...${to}`]);
|
||||
const out = await runGit(root, ['diff', '--name-status', `${resolvedFrom}...${resolvedTo}`]);
|
||||
const files = parseNameStatus(out);
|
||||
this.trace(`getBranchDiff(${from}...${to}): files=${files.length}`);
|
||||
this.trace(`getBranchDiff(${resolvedFrom}...${resolvedTo}): files=${files.length}`);
|
||||
return files;
|
||||
} catch (e) {
|
||||
this.trace(`getBranchDiff failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
|
|
@ -119,12 +222,22 @@ export class GitService {
|
|||
}
|
||||
}
|
||||
|
||||
private async resolveGitRef(root: string, ref: string): Promise<string | null> {
|
||||
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<FileChange[]> {
|
||||
const root = await this.repoRoot();
|
||||
if (!root || !sha) return [];
|
||||
try {
|
||||
const out = await runGit(root, ['show', '--name-status', '--format=', '--', sha]);
|
||||
const out = await runGit(root, ['show', '--name-status', '--format=', sha]);
|
||||
const files = parseNameStatus(out);
|
||||
this.trace(`getCommitFiles(${sha}): files=${files.length}`);
|
||||
return files;
|
||||
|
|
@ -136,7 +249,7 @@ export class GitService {
|
|||
|
||||
private async repoRoot(): Promise<string | null> {
|
||||
const repo = await this.waitForRepo();
|
||||
if (!repo) return null;
|
||||
if (!repo) return this.repoRootFast();
|
||||
return repo.rootUri?.fsPath
|
||||
?? vscode.workspace.workspaceFolders?.[0].uri.fsPath
|
||||
?? process.cwd();
|
||||
|
|
@ -153,41 +266,125 @@ export class GitService {
|
|||
|
||||
const fileUri = vscode.Uri.file(`${root}/${opts.path}`);
|
||||
|
||||
// 二进制无法做文本 diff,直接打开文件本身。
|
||||
if (opts.status === 'binary') {
|
||||
try { await vscode.window.showTextDocument(fileUri, { preview: true }); } catch { /* ignore */ }
|
||||
if (opts.mode === ReviewMode.Workspace) {
|
||||
try { await vscode.window.showTextDocument(fileUri, { preview: true }); } catch { /* ignore */ }
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// toGitUri(uri, '') 返回空文档,用于新增/删除时缺失的一侧。
|
||||
const emptyRef = '';
|
||||
let left: vscode.Uri;
|
||||
let right: vscode.Uri;
|
||||
let label: string;
|
||||
|
||||
if (opts.mode === 'workspace') {
|
||||
left = api.toGitUri(fileUri, opts.status === 'added' ? emptyRef : 'HEAD');
|
||||
right = opts.status === 'deleted' ? api.toGitUri(fileUri, emptyRef) : fileUri;
|
||||
label = t(resolveLocale(vscode.env.language), 'ext.git.workspaceVsHead');
|
||||
} else if (opts.mode === 'commit' && opts.commit) {
|
||||
left = api.toGitUri(fileUri, opts.status === 'added' ? emptyRef : `${opts.commit}^`);
|
||||
right = opts.status === 'deleted' ? api.toGitUri(fileUri, emptyRef) : api.toGitUri(fileUri, opts.commit);
|
||||
label = `${opts.commit}^ ↔ ${opts.commit}`;
|
||||
} else if (opts.mode === 'branch' && opts.from && opts.to) {
|
||||
// 文件列表用三点 diff(merge-base),逐文件 diff 也应以 merge-base 为基准。
|
||||
const base = (await this.mergeBase(root, opts.from, opts.to)) || opts.from;
|
||||
left = api.toGitUri(fileUri, opts.status === 'added' ? emptyRef : base);
|
||||
right = opts.status === 'deleted' ? api.toGitUri(fileUri, emptyRef) : api.toGitUri(fileUri, opts.to);
|
||||
label = `${opts.from}...${opts.to}`;
|
||||
} else {
|
||||
if (opts.mode === ReviewMode.Workspace) {
|
||||
const emptyRef = '';
|
||||
const left = api.toGitUri(fileUri, opts.status === 'added' ? emptyRef : 'HEAD');
|
||||
const right = opts.status === 'deleted' ? api.toGitUri(fileUri, emptyRef) : fileUri;
|
||||
const label = t(resolveLocale(vscode.env.language), 'ext.git.workspaceVsHead');
|
||||
await this.presentDiff(left, right, `${opts.path} (${label})`);
|
||||
return;
|
||||
}
|
||||
|
||||
const title = `${opts.path} (${label})`;
|
||||
if (opts.mode === ReviewMode.Commit && opts.commit) {
|
||||
const parent = `${opts.commit}^`;
|
||||
const label = `${opts.commit}^ ↔ ${opts.commit}`;
|
||||
const leftRef = opts.status === 'added' ? null : parent;
|
||||
const rightRef = opts.status === 'deleted' ? null : opts.commit;
|
||||
await this.presentRefDiff(api, root, opts.path, opts.status, leftRef, rightRef, `${opts.path} (${label})`, {
|
||||
fallbackRange: `${parent}..${opts.commit}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (opts.mode === ReviewMode.Branch && opts.from && opts.to) {
|
||||
const resolvedFrom = await this.resolveGitRef(root, opts.from);
|
||||
const resolvedTo = await this.resolveGitRef(root, opts.to);
|
||||
if (!resolvedFrom || !resolvedTo) return;
|
||||
const base = (await this.mergeBase(root, resolvedFrom, resolvedTo)) || resolvedFrom;
|
||||
const label = `${resolvedFrom}...${resolvedTo}`;
|
||||
const leftRef = opts.status === 'added' ? null : base;
|
||||
const rightRef = opts.status === 'deleted' ? null : resolvedTo;
|
||||
await this.presentRefDiff(api, root, opts.path, opts.status, leftRef, rightRef, `${opts.path} (${label})`, {
|
||||
fallbackRange: `${resolvedFrom}...${resolvedTo}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** 分支/提交模式:用 git ref 或空文件构造两侧,走 VS Code 原生 diff 编辑器。 */
|
||||
private async presentRefDiff(
|
||||
api: any,
|
||||
root: string,
|
||||
relPath: string,
|
||||
status: FileChange['status'],
|
||||
leftRef: string | null,
|
||||
rightRef: string | null,
|
||||
title: string,
|
||||
opts: { fallbackRange: string },
|
||||
): Promise<void> {
|
||||
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<vscode.Uri> {
|
||||
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<boolean> {
|
||||
try {
|
||||
await vscode.commands.executeCommand('vscode.diff', left, right, title, { preview: true });
|
||||
return true;
|
||||
} catch (e) {
|
||||
this.trace(`openDiff failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
this.trace(`presentDiff failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 最后兜底:原生 diff 失败时才展示补丁文本。 */
|
||||
private async presentPatchDiff(
|
||||
root: string,
|
||||
range: string,
|
||||
relPath: string,
|
||||
title: string,
|
||||
): Promise<void> {
|
||||
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<boolean> {
|
||||
try {
|
||||
await runGit(root, ['cat-file', '-e', `${ref}:${relPath}`]);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -199,11 +396,102 @@ export class GitService {
|
|||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 审查开始前缓存 commit/branch 模式下的文件状态,供评论挂载查询。 */
|
||||
async prepareReviewFileStatus(ctx: ReviewContext): Promise<void> {
|
||||
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<FileChange['status'] | null> {
|
||||
return this.reviewFileStatus.get(path) ?? null;
|
||||
}
|
||||
|
||||
async readFileAtRef(ref: string, relPath: string): Promise<string | null> {
|
||||
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<string | null> {
|
||||
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<vscode.Uri | null> {
|
||||
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<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile('git', args, { cwd, maxBuffer: 10 * 1024 * 1024 }, (err, stdout) => {
|
||||
execFile('git', ['-c', 'core.quotepath=false', ...args], { cwd, maxBuffer: 10 * 1024 * 1024 }, (err, stdout) => {
|
||||
if (err) reject(err);
|
||||
else resolve(stdout);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,28 +1,29 @@
|
|||
import { buildReviewArgs, extractCliError, parseCliResult, parseLogLine } from '../cliParse';
|
||||
import { ReviewMode } from '../../../shared/types';
|
||||
|
||||
describe('buildReviewArgs', () => {
|
||||
it('workspace 模式加 --format json', () => {
|
||||
expect(buildReviewArgs({ mode: 'workspace' }))
|
||||
expect(buildReviewArgs({ mode: ReviewMode.Workspace }))
|
||||
.toEqual(['review', '--format', 'json']);
|
||||
});
|
||||
|
||||
it('branch 模式加 --from/--to', () => {
|
||||
expect(buildReviewArgs({ mode: 'branch', from: 'main', to: 'dev' }))
|
||||
expect(buildReviewArgs({ mode: ReviewMode.Branch, from: 'main', to: 'dev' }))
|
||||
.toEqual(['review', '--from', 'main', '--to', 'dev', '--format', 'json']);
|
||||
});
|
||||
|
||||
it('commit 模式加 --commit', () => {
|
||||
expect(buildReviewArgs({ mode: 'commit', commit: 'abc123' }))
|
||||
expect(buildReviewArgs({ mode: ReviewMode.Commit, commit: 'abc123' }))
|
||||
.toEqual(['review', '--commit', 'abc123', '--format', 'json']);
|
||||
});
|
||||
|
||||
it('customPrompt 追加 --background', () => {
|
||||
expect(buildReviewArgs({ mode: 'workspace', customPrompt: '关注安全' }))
|
||||
expect(buildReviewArgs({ mode: ReviewMode.Workspace, customPrompt: '关注安全' }))
|
||||
.toEqual(['review', '--format', 'json', '--background', '关注安全']);
|
||||
});
|
||||
|
||||
it('concurrency 追加 --concurrency', () => {
|
||||
expect(buildReviewArgs({ mode: 'workspace', concurrency: 4 }))
|
||||
expect(buildReviewArgs({ mode: ReviewMode.Workspace, concurrency: 4 }))
|
||||
.toEqual(['review', '--format', 'json', '--concurrency', '4']);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,18 @@
|
|||
// src/extension/services/__tests__/gitMap.test.ts
|
||||
import { mapStatusCode, parsePorcelain, parseNameStatus, pickRepoRoot } from '../gitMap';
|
||||
import { execFile } from 'child_process';
|
||||
import path from 'path';
|
||||
import { promisify } from 'util';
|
||||
import {
|
||||
buildWorkspaceFiles,
|
||||
branchRefCandidates,
|
||||
mapStatusCode,
|
||||
mergeWorkspaceFiles,
|
||||
parsePorcelain,
|
||||
parseNameStatus,
|
||||
parseUntrackedList,
|
||||
pickRepoRoot,
|
||||
unquoteGitPath,
|
||||
} from '../gitMap';
|
||||
|
||||
describe('mapStatusCode', () => {
|
||||
it('VSCode git Status 枚举映射到 FileChange.status', () => {
|
||||
|
|
@ -81,6 +94,82 @@ describe('parseNameStatus', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('buildWorkspaceFiles', () => {
|
||||
it('合并 diff HEAD 与未跟踪文件', () => {
|
||||
const files = buildWorkspaceFiles(
|
||||
'M\tsrc/a.ts\nA\tsrc/b.ts',
|
||||
'',
|
||||
'src/c.ts\n',
|
||||
);
|
||||
expect(files).toEqual([
|
||||
{ path: 'src/a.ts', status: 'modified' },
|
||||
{ path: 'src/b.ts', status: 'added' },
|
||||
{ path: 'src/c.ts', status: 'added' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('diff HEAD 为空时回退 staged', () => {
|
||||
const files = buildWorkspaceFiles('', 'M\tsrc/staged.ts', '');
|
||||
expect(files).toEqual([{ path: 'src/staged.ts', status: 'modified' }]);
|
||||
});
|
||||
|
||||
it('按路径去重,已跟踪优先于未跟踪', () => {
|
||||
const files = buildWorkspaceFiles('M\tsrc/a.ts', '', 'src/a.ts');
|
||||
expect(files).toEqual([{ path: 'src/a.ts', status: 'modified' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseUntrackedList', () => {
|
||||
it('解析未跟踪路径并忽略空行', () => {
|
||||
expect(parseUntrackedList('src/a.ts\n\n src/b.ts \n')).toEqual(['src/a.ts', 'src/b.ts']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeWorkspaceFiles', () => {
|
||||
it('合并并去重', () => {
|
||||
expect(mergeWorkspaceFiles(
|
||||
[{ path: 'a.ts', status: 'modified' }],
|
||||
['b.ts', 'a.ts'],
|
||||
)).toEqual([
|
||||
{ path: 'a.ts', status: 'modified' },
|
||||
{ path: 'b.ts', status: 'added' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unquoteGitPath', () => {
|
||||
it('解码 Git quotepath 八进制转义的中文路径', () => {
|
||||
const quoted = '"\\344\\273\\243\\347\\240\\201\\344\\277\\256\\346\\224\\271\\346\\234\\200\\345\\260\\217\\345\\271\\262\\351\\242\\204\\350\\247\\204\\345\\210\\231.md"';
|
||||
expect(unquoteGitPath(quoted)).toBe('代码修改最小干预规则.md');
|
||||
});
|
||||
|
||||
it('普通路径原样返回', () => {
|
||||
expect(unquoteGitPath('src/a.ts')).toBe('src/a.ts');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseNameStatus', () => {
|
||||
it('解析 quotepath 转义路径', () => {
|
||||
expect(parseNameStatus('A\t"\\344\\273\\243\\347\\240\\201.md"')).toEqual([
|
||||
{ path: '代码.md', status: 'added' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('branchRefCandidates', () => {
|
||||
it('本地分支名补充 origin/ 前缀', () => {
|
||||
expect(branchRefCandidates('dev')).toEqual(['dev', 'origin/dev']);
|
||||
});
|
||||
|
||||
it('master 回退到 main 候选', () => {
|
||||
expect(branchRefCandidates('master')).toEqual(['master', 'origin/master', 'main', 'origin/main']);
|
||||
});
|
||||
|
||||
it('已是远程引用时不重复拼接', () => {
|
||||
expect(branchRefCandidates('origin/main')).toEqual(['origin/main']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickRepoRoot', () => {
|
||||
const ws = '/Users/lost/tre/copilot-union/code-chat';
|
||||
|
||||
|
|
@ -117,3 +206,19 @@ describe('pickRepoRoot', () => {
|
|||
expect(pickRepoRoot(roots, undefined)).toBe('/a/repo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCommitFiles: git show revision placement', () => {
|
||||
const repoRoot = path.resolve(__dirname, '../../../../../..');
|
||||
const execGit = (args: string[]) =>
|
||||
promisify(execFile)('git', ['-c', 'core.quotepath=false', ...args], { cwd: repoRoot })
|
||||
.then((r) => r.stdout.trim());
|
||||
|
||||
it('revision 必须在 -- 之前,否则会被当成 pathspec 导致空列表', async () => {
|
||||
const good = await execGit(['show', '--name-status', '--format=', 'HEAD']);
|
||||
const bad = await execGit(['show', '--name-status', '--format=', '--', 'HEAD']);
|
||||
expect(good.length).toBeGreaterThan(0);
|
||||
expect(bad).toBe('');
|
||||
expect(parseNameStatus(good).length).toBeGreaterThan(0);
|
||||
expect(parseNameStatus(bad)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import { CliResult, CliRunOptions, LogLine, ReviewComment } from '../../shared/types';
|
||||
import { CliResult, CliRunOptions, LogLine, ReviewComment, ReviewMode } from '../../shared/types';
|
||||
|
||||
export function buildReviewArgs(opts: CliRunOptions): string[] {
|
||||
const args: string[] = ['review'];
|
||||
if (opts.mode === 'branch') {
|
||||
if (opts.mode === ReviewMode.Branch) {
|
||||
if (opts.from) args.push('--from', opts.from);
|
||||
if (opts.to) args.push('--to', opts.to);
|
||||
} else if (opts.mode === 'commit') {
|
||||
} else if (opts.mode === ReviewMode.Commit) {
|
||||
if (opts.commit) args.push('--commit', opts.commit);
|
||||
}
|
||||
args.push('--format', 'json');
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ export function parsePorcelain(output: string): FileChange[] {
|
|||
const c = x !== ' ' && x !== '?' ? x : y;
|
||||
code = c;
|
||||
}
|
||||
path = unquoteGitPath(path);
|
||||
if (seen.has(path)) continue;
|
||||
seen.add(path);
|
||||
files.push({ path, status: mapStatusCode(code) });
|
||||
|
|
@ -43,6 +44,40 @@ export function parsePorcelain(output: string): FileChange[] {
|
|||
return files;
|
||||
}
|
||||
|
||||
/** 解析 `git ls-files --others --exclude-standard` 输出的未跟踪路径列表。 */
|
||||
export function parseUntrackedList(output: string): string[] {
|
||||
return output.split('\n').map((line) => unquoteGitPath(line.trim())).filter(Boolean);
|
||||
}
|
||||
|
||||
/** 合并已跟踪变更与未跟踪文件,按路径去重。 */
|
||||
export function mergeWorkspaceFiles(tracked: FileChange[], untrackedPaths: string[]): FileChange[] {
|
||||
const files: FileChange[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const file of tracked) {
|
||||
if (seen.has(file.path)) continue;
|
||||
seen.add(file.path);
|
||||
files.push(file);
|
||||
}
|
||||
for (const path of untrackedPaths) {
|
||||
if (seen.has(path)) continue;
|
||||
seen.add(path);
|
||||
files.push({ path, status: 'added' });
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 git diff / ls-files 输出构建工作区文件列表。
|
||||
* 与 OCR CLI workspace 模式一致:先 diff HEAD,空则回退 staged,再合并未跟踪文件。
|
||||
*/
|
||||
export function buildWorkspaceFiles(diffHeadOut: string, diffCachedOut: string, untrackedOut: string): FileChange[] {
|
||||
let tracked = parseNameStatus(diffHeadOut);
|
||||
if (tracked.length === 0) {
|
||||
tracked = parseNameStatus(diffCachedOut);
|
||||
}
|
||||
return mergeWorkspaceFiles(tracked, parseUntrackedList(untrackedOut));
|
||||
}
|
||||
|
||||
/**
|
||||
* 从候选仓库根路径中选出与 workspace 匹配的那个。
|
||||
* VSCode git 扩展异步扫描嵌套仓库,repositories 顺序不稳定,直接取 [0] 会漂移到子仓库。
|
||||
|
|
@ -63,6 +98,50 @@ export function pickRepoRoot(roots: string[], workspacePath?: string): string |
|
|||
return roots[0];
|
||||
}
|
||||
|
||||
/** 生成用于 rev-parse 验证的分支引用候选列表。 */
|
||||
export function branchRefCandidates(ref: string): string[] {
|
||||
const candidates = [ref];
|
||||
if (!ref.includes('/')) {
|
||||
candidates.push(`origin/${ref}`);
|
||||
}
|
||||
if (ref === 'master') {
|
||||
candidates.push('main', 'origin/main');
|
||||
} else if (ref === 'main') {
|
||||
candidates.push('master', 'origin/master');
|
||||
}
|
||||
return [...new Set(candidates)];
|
||||
}
|
||||
|
||||
/**
|
||||
* 解码 Git quotepath 转义路径(core.quotepath=true 时中文等会显示为 "\344\273...")。
|
||||
*/
|
||||
export function unquoteGitPath(path: string): string {
|
||||
if (!path.startsWith('"') || !path.endsWith('"')) return path;
|
||||
|
||||
const bytes: number[] = [];
|
||||
const inner = path.slice(1, -1);
|
||||
for (let i = 0; i < inner.length; i++) {
|
||||
if (inner[i] !== '\\' || i + 1 >= inner.length) {
|
||||
bytes.push(inner.charCodeAt(i));
|
||||
continue;
|
||||
}
|
||||
if (i + 3 < inner.length && /^\d{3}$/.test(inner.slice(i + 1, i + 4))) {
|
||||
bytes.push(parseInt(inner.slice(i + 1, i + 4), 8));
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
i += 1;
|
||||
const esc = inner[i];
|
||||
if (esc === 'n') bytes.push(0x0a);
|
||||
else if (esc === 't') bytes.push(0x09);
|
||||
else if (esc === 'r') bytes.push(0x0d);
|
||||
else if (esc === '\\') bytes.push(0x5c);
|
||||
else if (esc === '"') bytes.push(0x22);
|
||||
else bytes.push(esc.charCodeAt(0));
|
||||
}
|
||||
return Buffer.from(bytes).toString('utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 `git diff --name-status` / `git show --name-status` 输出。
|
||||
* 每行制表符分隔:status<TAB>path,重命名为 R<score><TAB>old<TAB>new(取 new)。
|
||||
|
|
@ -75,7 +154,7 @@ export function parseNameStatus(output: string): FileChange[] {
|
|||
const parts = rawLine.split('\t');
|
||||
if (parts.length < 2) continue;
|
||||
const codeChar = parts[0][0];
|
||||
const path = parts.length >= 3 ? parts[parts.length - 1] : parts[1];
|
||||
const path = unquoteGitPath(parts.length >= 3 ? parts[parts.length - 1] : parts[1]);
|
||||
if (seen.has(path)) continue;
|
||||
seen.add(path);
|
||||
files.push({ path, status: mapStatusCode(codeChar) });
|
||||
|
|
|
|||
|
|
@ -153,6 +153,9 @@ const messages: Record<SupportedLocale, Record<string, string>> = {
|
|||
'ext.comment.statusFalsePositive': '✅ [False Positive]',
|
||||
'ext.comment.jumpFailed': 'Cannot locate ',
|
||||
'ext.comment.jumpNotAFile': ': is not an openable file.',
|
||||
'ext.comment.jumpLineUnresolved': 'Cannot jump to {path}: line number could not be resolved.',
|
||||
'ext.comment.jumpFileMissing': 'Cannot jump to {path}: file not found in the review snapshot.',
|
||||
'ext.comment.applyWorkspaceOnly': 'Apply is only available in Workspace review mode.',
|
||||
'ext.deleteProviderConfirm': 'Delete custom provider "{name}"?',
|
||||
'ext.deleteProviderConfirmBtn': 'Delete',
|
||||
'ext.git.justNow': 'just now',
|
||||
|
|
@ -295,6 +298,9 @@ const messages: Record<SupportedLocale, Record<string, string>> = {
|
|||
'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': '刚刚',
|
||||
|
|
|
|||
|
|
@ -95,6 +95,14 @@ export const PROVIDER_PRESETS: OcrProviderPreset[] = [
|
|||
envVar: 'Z_AI_API_KEY',
|
||||
models: ['glm-5.2', 'glm-5.1', 'glm-5-turbo', 'glm-4.7'],
|
||||
},
|
||||
{
|
||||
name: 'z-ai-coding',
|
||||
displayName: 'Z.AI Coding Plan API',
|
||||
protocol: 'openai',
|
||||
baseUrl: 'https://open.bigmodel.cn/api/coding/paas/v4',
|
||||
envVar: 'Z_AI_CODING_API_KEY',
|
||||
models: ['glm-5.2', 'glm-5.1', 'glm-5-turbo', 'glm-4.7'],
|
||||
},
|
||||
{
|
||||
name: 'mimo',
|
||||
displayName: 'Xiaomi MiMo API',
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
export type ReviewMode = 'workspace' | 'branch' | 'commit';
|
||||
export enum ReviewMode {
|
||||
Workspace = 'workspace',
|
||||
Branch = 'branch',
|
||||
Commit = 'commit',
|
||||
}
|
||||
|
||||
export type ReviewState =
|
||||
| 'idle' | 'running' | 'done' | 'empty' | 'cancelled' | 'failed';
|
||||
|
|
@ -105,7 +109,11 @@ export interface CliRunOptions {
|
|||
concurrency?: number;
|
||||
}
|
||||
|
||||
/** 审查完成后的评论挂载上下文(与 CliRunOptions 字段一致)。 */
|
||||
export type ReviewContext = Pick<CliRunOptions, 'mode' | 'from' | 'to' | 'commit'>;
|
||||
|
||||
export interface CommentSyncState {
|
||||
index: number;
|
||||
status: CommentStatus;
|
||||
jumpable?: boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,14 +55,14 @@ export function App() {
|
|||
<div class="result-region">
|
||||
{state.view === 'running' && <RunningView logs={state.logs} onCancel={() => bridge.post({ type: 'cancelReview' })} />}
|
||||
{state.view === 'done' && state.session.result && (
|
||||
<DoneView result={state.session.result} commentStatus={state.commentStatus} logs={state.logs}
|
||||
canJump={state.reviewMode === 'workspace'}
|
||||
<DoneView result={state.session.result} commentStatus={state.commentStatus}
|
||||
commentJumpable={state.commentJumpable} logs={state.logs}
|
||||
onOpen={(i) => bridge.post({ type: 'jumpToComment', index: i })}
|
||||
onAction={(i, action) => bridge.post({ type: 'commentAction', index: i, action })} />
|
||||
)}
|
||||
{state.view === 'empty' && <EmptyView logs={state.logs} />}
|
||||
{state.view === 'cancelled' && <CancelledView />}
|
||||
{state.view === 'failed' && <FailedView error={state.session.error} onRetry={() => start({ mode: 'workspace' })} />}
|
||||
{state.view === 'failed' && <FailedView error={state.session.error} onRetry={() => start({ mode: ReviewMode.Workspace })} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { I18nContext, resolveLocale } from './I18nProvider';
|
||||
import { useEffect, useReducer } from 'preact/hooks';
|
||||
import { useCallback, useEffect, useReducer } from 'preact/hooks';
|
||||
import { bridge } from './bridge';
|
||||
import { ConfigView } from './views/ConfigView';
|
||||
import { configPanelInitialState, configPanelReducer } from './configStore';
|
||||
|
|
@ -12,6 +12,7 @@ function runEnvCheck(dispatch: (action: { type: 'checkingEnv' }) => void): void
|
|||
|
||||
export function ConfigPanelApp() {
|
||||
const [state, dispatch] = useReducer(configPanelReducer, configPanelInitialState);
|
||||
const clearConnTest = useCallback(() => dispatch({ type: 'clearConnTest' }), []);
|
||||
|
||||
useEffect(() => {
|
||||
const unsub = bridge.onMessage((msg) => dispatch(msg));
|
||||
|
|
@ -59,7 +60,7 @@ export function ConfigPanelApp() {
|
|||
onCopy={(text) => bridge.post({ type: 'copyToClipboard', text })}
|
||||
onTest={(entries) => { dispatch({ type: 'testingConn' }); bridge.post({ type: 'testConnection', entries }); }}
|
||||
onSave={(entries) => bridge.post({ type: 'setConfigBatch', entries })}
|
||||
onClearConnTest={() => dispatch({ type: 'clearConnTest' })}
|
||||
onClearConnTest={clearConnTest}
|
||||
onDeleteCustomProvider={(name) => bridge.post({ type: 'deleteCustomProvider', name })}
|
||||
onActivateCustomProvider={(name) => bridge.post({ type: 'activateCustomProvider', name })}
|
||||
onClose={() => bridge.post({ type: 'closeConfigPanel' })}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { describeActiveProvider, isConfigReady } from '../../shared/configUtils';
|
||||
import { ReviewMode } from '../../shared/types';
|
||||
import { initialState, reducer } from '../store';
|
||||
|
||||
const baseConfig = {
|
||||
|
|
@ -150,7 +151,7 @@ describe('modeFiles 消息', () => {
|
|||
it('保存 mode 对应文件列表', () => {
|
||||
const next = reducer(initialState, {
|
||||
type: 'modeFiles',
|
||||
mode: 'branch',
|
||||
mode: ReviewMode.Branch,
|
||||
files: [{ path: 'src/a.ts', status: 'modified' }],
|
||||
});
|
||||
expect(next.modeFiles).toEqual([{ path: 'src/a.ts', status: 'modified' }]);
|
||||
|
|
|
|||
|
|
@ -12,16 +12,24 @@ interface Props {
|
|||
|
||||
export function CommentCard({ comment, index, status, canJump, onOpen, onAction }: Props) {
|
||||
const t = useT();
|
||||
const open = () => onOpen(index);
|
||||
return (
|
||||
<div class={`comment-card${status !== 'pending' ? ' dismissed' : ''}`}>
|
||||
<div class="comment-header">
|
||||
<div
|
||||
class={`comment-header${canJump ? ' jumpable' : ''}`}
|
||||
onClick={canJump ? open : undefined}
|
||||
onKeyDown={canJump ? (e) => { 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}
|
||||
>
|
||||
<span class="comment-file">{comment.path}</span>
|
||||
<span class="comment-line">L{comment.startLine}</span>
|
||||
</div>
|
||||
<div class="comment-body">{comment.content}</div>
|
||||
<div class="comment-actions">
|
||||
{canJump && <button onClick={() => onOpen(index)}>{t('cmp.comment.view')}</button>}
|
||||
<button onClick={() => onAction(index, 'discard')}>{t('cmp.comment.discard')}</button>
|
||||
{canJump && <button type="button" onClick={open}>{t('cmp.comment.view')}</button>}
|
||||
<button type="button" onClick={() => onAction(index, 'discard')}>{t('cmp.comment.discard')}</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ export interface AppState {
|
|||
logs: LogLine[];
|
||||
session: { state: ReviewState; result: CliResult | null; error?: string };
|
||||
commentStatus: Record<number, CommentStatus>;
|
||||
commentJumpable: Record<number, boolean>;
|
||||
reviewMode: ReviewMode;
|
||||
locale: SupportedLocale;
|
||||
}
|
||||
|
|
@ -26,7 +27,8 @@ export const initialState: AppState = {
|
|||
logs: [],
|
||||
session: { state: 'idle', result: null },
|
||||
commentStatus: {},
|
||||
reviewMode: 'workspace',
|
||||
commentJumpable: {},
|
||||
reviewMode: ReviewMode.Workspace,
|
||||
locale: 'en',
|
||||
};
|
||||
|
||||
|
|
@ -66,18 +68,30 @@ export function reducer(state: AppState, msg: HostToWebview | LocalAction): AppS
|
|||
...state,
|
||||
logs: starting ? [] : state.logs,
|
||||
commentStatus: starting ? {} : state.commentStatus,
|
||||
commentJumpable: starting ? {} : state.commentJumpable,
|
||||
session: { state: msg.state, result: starting ? null : state.session.result, error: msg.error },
|
||||
view: STATE_TO_VIEW[msg.state],
|
||||
};
|
||||
}
|
||||
case 'logLine':
|
||||
return { ...state, logs: [...state.logs, msg.line] };
|
||||
case 'reviewDone':
|
||||
return { ...state, session: { ...state.session, result: msg.result } };
|
||||
case 'reviewDone': {
|
||||
const commentJumpable: Record<number, boolean> = {};
|
||||
msg.result.comments.forEach((_, i) => { commentJumpable[i] = true; });
|
||||
return {
|
||||
...state,
|
||||
session: { ...state.session, result: msg.result },
|
||||
commentJumpable,
|
||||
};
|
||||
}
|
||||
case 'commentSync': {
|
||||
const commentStatus = { ...state.commentStatus };
|
||||
for (const c of msg.comments) commentStatus[c.index] = c.status;
|
||||
return { ...state, commentStatus };
|
||||
const commentJumpable = { ...state.commentJumpable };
|
||||
for (const c of msg.comments) {
|
||||
commentStatus[c.index] = c.status;
|
||||
if (c.jumpable !== undefined) commentJumpable[c.index] = c.jumpable;
|
||||
}
|
||||
return { ...state, commentStatus, commentJumpable };
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
|
|
|
|||
|
|
@ -824,6 +824,8 @@ body {
|
|||
margin-bottom: 10px;
|
||||
transition: opacity 0.3s, max-height 0.3s, padding 0.3s, margin 0.3s;
|
||||
max-height: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
|
@ -842,6 +844,18 @@ body {
|
|||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.comment-header.jumpable {
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
margin: -4px -6px 4px;
|
||||
padding: 4px 6px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.comment-header.jumpable:hover {
|
||||
background: var(--card-quiet);
|
||||
}
|
||||
|
||||
.comment-file {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11.5px;
|
||||
|
|
@ -886,11 +900,15 @@ body {
|
|||
line-height: 1.6;
|
||||
color: var(--ink);
|
||||
margin-bottom: 10px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.comment-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.comment-actions button {
|
||||
|
|
|
|||
|
|
@ -68,17 +68,17 @@ export function ConfigView({
|
|||
setTab(next.tab);
|
||||
setCustomView(next.customView);
|
||||
setCustomSelection(next.customSelection);
|
||||
}, [panelFocus, config, onClearConnTest]);
|
||||
}, [panelFocus, config]);
|
||||
|
||||
const wide = layout === 'panel';
|
||||
const t = useT();
|
||||
const stepper = (
|
||||
<div class="config-stepper">
|
||||
<div class={`config-step-pill${step === 1 ? ' active' : ''}${cliStatus === 'installed' ? ' done' : ''}`}>
|
||||
<div class={`config-step-pill${step === 1 ? ' done' : ''}`}>
|
||||
<span class="config-step-num">1</span>
|
||||
<span>{t('view.config.step1')}</span>
|
||||
</div>
|
||||
<div class={`config-step-pill${step === 2 ? ' active' : ''}`}>
|
||||
<div class={`config-step-pill${step === 2 ? ' done' : ''}`}>
|
||||
<span class="config-step-num">2</span>
|
||||
<span>{t('view.config.step2')}</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -7,13 +7,13 @@ import { useT } from '../I18nProvider';
|
|||
interface Props {
|
||||
result: CliResult;
|
||||
commentStatus: Record<number, CommentStatus>;
|
||||
commentJumpable: Record<number, boolean>;
|
||||
logs: LogLine[];
|
||||
canJump: boolean;
|
||||
onOpen: (index: number) => void;
|
||||
onAction: (index: number, action: 'apply' | 'discard' | 'falsePositive') => void;
|
||||
}
|
||||
|
||||
export function DoneView({ result, commentStatus, logs, canJump, onOpen, onAction }: Props) {
|
||||
export function DoneView({ result, commentStatus, commentJumpable, logs, onOpen, onAction }: Props) {
|
||||
const [showLogs, setShowLogs] = useState(false);
|
||||
const t = useT();
|
||||
const s = result.summary;
|
||||
|
|
@ -35,7 +35,8 @@ export function DoneView({ result, commentStatus, logs, canJump, onOpen, onActio
|
|||
)}
|
||||
|
||||
{result.comments.map((c, i) => (
|
||||
<CommentCard key={i} comment={c} index={i} canJump={canJump}
|
||||
<CommentCard key={i} comment={c} index={i}
|
||||
canJump={commentJumpable[i] !== false}
|
||||
status={commentStatus[i] ?? 'pending'} onOpen={onOpen} onAction={onAction} />
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<ReviewMode>(ReviewMode.Workspace);
|
||||
const [from, setFrom] = useState('');
|
||||
const [to, setTo] = useState('');
|
||||
const [commit, setCommit] = useState('');
|
||||
|
|
@ -31,7 +31,7 @@ export function IdleView({ gitState, modeFiles, filesLoading, configured, onMode
|
|||
if (!configured) return t('view.idle.configFirst');
|
||||
if (running) return t('view.idle.reviewing');
|
||||
if (!selectionReady) {
|
||||
return mode === 'branch' ? t('view.idle.selectBranch') : t('view.idle.selectCommit');
|
||||
return mode === ReviewMode.Branch ? t('view.idle.selectBranch') : t('view.idle.selectCommit');
|
||||
}
|
||||
if (files.length === 0) return t('view.idle.noFiles');
|
||||
return t('view.idle.reviewAll');
|
||||
|
|
@ -41,21 +41,21 @@ export function IdleView({ gitState, modeFiles, filesLoading, configured, onMode
|
|||
|
||||
// 分支两端都选好后,拉取 diff 文件列表
|
||||
useEffect(() => {
|
||||
if (mode === 'branch' && from && to) onRequestModeFiles('branch', from, to);
|
||||
if (mode === ReviewMode.Branch && from && to) onRequestModeFiles(ReviewMode.Branch, from, to);
|
||||
}, [mode, from, to]);
|
||||
|
||||
// 选中某 commit 后,拉取该 commit 文件列表
|
||||
useEffect(() => {
|
||||
if (mode === 'commit' && commit) onRequestModeFiles('commit', undefined, undefined, commit);
|
||||
if (mode === ReviewMode.Commit && commit) onRequestModeFiles(ReviewMode.Commit, undefined, undefined, commit);
|
||||
}, [mode, commit]);
|
||||
|
||||
const files = mode === 'workspace' ? gitState.workspaceFiles : modeFiles;
|
||||
const files = mode === ReviewMode.Workspace ? gitState.workspaceFiles : modeFiles;
|
||||
// 仅在「确实发起了请求」时显示 loading:分支需选满两端,提交需选中 commit。
|
||||
const willRequest = mode === 'workspace' || (mode === 'branch' && !!from && !!to) || (mode === 'commit' && !!commit);
|
||||
const willRequest = mode === ReviewMode.Workspace || (mode === ReviewMode.Branch && !!from && !!to) || (mode === ReviewMode.Commit && !!commit);
|
||||
const loading = filesLoading && willRequest;
|
||||
// 可发起审查的前置条件:按 tab 校验选择已就绪,且有待审查文件、不在加载/审查中。
|
||||
const selectionReady =
|
||||
mode === 'workspace' || (mode === 'branch' && !!from && !!to) || (mode === 'commit' && !!commit);
|
||||
mode === ReviewMode.Workspace || (mode === ReviewMode.Branch && !!from && !!to) || (mode === ReviewMode.Commit && !!commit);
|
||||
const canReview = configured && !running && !loading && selectionReady && files.length > 0;
|
||||
const primaryDisabled = configured ? !canReview : running || loading;
|
||||
|
||||
|
|
@ -70,14 +70,14 @@ export function IdleView({ gitState, modeFiles, filesLoading, configured, onMode
|
|||
return (
|
||||
<div class="setup">
|
||||
<div class="mode-tabs">
|
||||
{(['workspace', 'branch', 'commit'] as ReviewMode[]).map((m) => (
|
||||
{([ReviewMode.Workspace, ReviewMode.Branch, ReviewMode.Commit]).map((m) => (
|
||||
<button key={m} class={`mode-tab${mode === m ? ' active' : ''}`} onClick={() => switchMode(m)}>
|
||||
{m === 'workspace' ? t('view.idle.workspace') : m === 'branch' ? t('view.idle.branch') : t('view.idle.commit')}
|
||||
{m === ReviewMode.Workspace ? t('view.idle.workspace') : m === ReviewMode.Branch ? t('view.idle.branch') : t('view.idle.commit')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{mode === 'branch' && (
|
||||
{mode === ReviewMode.Branch && (
|
||||
<div class="mode-params active">
|
||||
<div class="mode-param-label">{t('view.idle.baseRef')}</div>
|
||||
<Select value={from} placeholder={t('view.idle.chooseBranch')} onChange={setFrom}
|
||||
|
|
@ -88,7 +88,7 @@ export function IdleView({ gitState, modeFiles, filesLoading, configured, onMode
|
|||
</div>
|
||||
)}
|
||||
|
||||
{mode === 'commit' && (
|
||||
{mode === ReviewMode.Commit && (
|
||||
<div class="mode-params active">
|
||||
<div class="files-label">{t('view.idle.commitHistory')}</div>
|
||||
<div class="commit-list">
|
||||
|
|
@ -113,8 +113,6 @@ export function IdleView({ gitState, modeFiles, filesLoading, configured, onMode
|
|||
|
||||
{configured && (
|
||||
<div class="setup-secondary">
|
||||
<button type="button" class="link-btn" onClick={onOpenCustomProviders}>{t('view.idle.manageCustom')}</button>
|
||||
<span class="setup-secondary-sep">·</span>
|
||||
<button type="button" class="link-btn" onClick={onOpenConfig}>{t('view.idle.modelConfig')}</button>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
4
go.mod
4
go.mod
|
|
@ -6,14 +6,16 @@ require (
|
|||
charm.land/bubbles/v2 v2.1.0
|
||||
charm.land/bubbletea/v2 v2.0.7
|
||||
charm.land/lipgloss/v2 v2.0.4
|
||||
github.com/anthropics/anthropic-sdk-go v1.52.0
|
||||
github.com/anthropics/anthropic-sdk-go v1.55.1
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.0
|
||||
github.com/modelcontextprotocol/go-sdk v1.6.1
|
||||
github.com/openai/openai-go/v3 v3.41.0
|
||||
github.com/pkoukk/tiktoken-go v0.1.8
|
||||
go.opentelemetry.io/otel v1.44.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0
|
||||
go.opentelemetry.io/otel/metric v1.44.0
|
||||
|
|
|
|||
8
go.sum
8
go.sum
|
|
@ -4,8 +4,8 @@ charm.land/bubbletea/v2 v2.0.7 h1:7qw2tTAVar7m7klOPBYfTB0mniv/RuexsYwMRNxSeL0=
|
|||
charm.land/bubbletea/v2 v2.0.7/go.mod h1:DGW2q8gvzHnOpMpZTORs0aySVHCox5C+2Svk0fci1qs=
|
||||
charm.land/lipgloss/v2 v2.0.4 h1:lcPeVtcp23SNra7lHy8iYE4UC2aIipVQ47sbGyyxR5Q=
|
||||
charm.land/lipgloss/v2 v2.0.4/go.mod h1:0653x8epbZSzdDfO/XPS1a/uYPOBeSsCssOpJOqDzik=
|
||||
github.com/anthropics/anthropic-sdk-go v1.52.0 h1:1TB9jt4DN87VMwS/hB1VK26tYzK0ipEOtqPaPGFtJQg=
|
||||
github.com/anthropics/anthropic-sdk-go v1.52.0/go.mod h1:3EfIfmFqxH6rbiLcIP4tPFyXL/IHakx2wDG4OU+TIEI=
|
||||
github.com/anthropics/anthropic-sdk-go v1.55.1 h1:GxukHUVou6AFIngxa/Aw1z79hmwg13Hmn++KE9werbM=
|
||||
github.com/anthropics/anthropic-sdk-go v1.55.1/go.mod h1:3EfIfmFqxH6rbiLcIP4tPFyXL/IHakx2wDG4OU+TIEI=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
|
||||
|
|
@ -109,10 +109,14 @@ go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
|
|||
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 h1:SUplec5dp06reu1zaXmOXdvqH398taqrDXqUl99jxSc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0/go.mod h1:ho2g4N+ane+swq5I/VBkKWnRDY4kUINH3FuqyZqX/Ug=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0 h1:RuynHbfU8JUEw7DyONgkVYg2SVtsoF28y0LGIr69jgA=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0/go.mod h1:qZF+/lBs71APw8mlnEZcqZHMzqrYrsFiJOv83lX1OGo=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 h1:qazEJlUOQzhCpzQpFETGby7EdqjI1wsd0W+6Gg1SCTU=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0/go.mod h1:fOD2Yefuxixkx3ahVNf0O/PERb6r4OlbxfATVnYvzCo=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 h1:lgh3PiVrRUWMLOVSkQicxzZll5NjF1r+AtsX1XRIHw0=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0/go.mod h1:5Cnhth3m/AgOeTgE3ex12pPmiu/gGtZit03kSzx9X7s=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0 h1:hqxVTu/GtBF+vJ8d1fzW7fRxZFvgoDjWcxwwCaFDYpU=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0/go.mod h1:z5fVEF4X5v0ESvlJqBrrFlBVoj5EQuefZpzsu7R+x5Q=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0 h1:bl2S7Ubua0Nms+D/gAmznQTd4dxxMA93aKbcpKqiTCs=
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@ package agent
|
|||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
|
@ -21,6 +23,8 @@ import (
|
|||
"github.com/open-code-review/open-code-review/internal/stdout"
|
||||
"github.com/open-code-review/open-code-review/internal/telemetry"
|
||||
"github.com/open-code-review/open-code-review/internal/tool"
|
||||
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
)
|
||||
|
||||
// AgentWarning is re-exported from llmloop for backwards compatibility with
|
||||
|
|
@ -110,6 +114,9 @@ type Args struct {
|
|||
// Session is an optional session history instance for collecting conversation records.
|
||||
// When nil, a default one is created automatically with git branch auto-detected from repoDir.
|
||||
Session *session.SessionHistory
|
||||
|
||||
// Resume is an optional read-only checkpoint index from a previous review session.
|
||||
Resume *session.ResumeState
|
||||
}
|
||||
|
||||
// Agent orchestrates the AI-powered code review. LLM tool-use loop / memory
|
||||
|
|
@ -124,6 +131,16 @@ type Agent struct {
|
|||
session *session.SessionHistory
|
||||
subtaskFailed int64 // count of failed subtasks, accessed atomically
|
||||
runner *llmloop.Runner
|
||||
resumeInfo *ResumeInfo
|
||||
}
|
||||
|
||||
// ResumeInfo summarizes file-level reuse for a resumed review.
|
||||
type ResumeInfo struct {
|
||||
ResumedFrom string `json:"resumed_from"`
|
||||
ReusedFiles int64 `json:"reused_files"`
|
||||
RerunFiles int64 `json:"rerun_files"`
|
||||
PreviousModel string `json:"previous_model,omitempty"`
|
||||
CurrentModel string `json:"current_model,omitempty"`
|
||||
}
|
||||
|
||||
// New creates a new Agent from the given arguments.
|
||||
|
|
@ -141,10 +158,11 @@ func New(args Args) *Agent {
|
|||
mode = reviewModeString(args.From, args.To, args.Commit)
|
||||
}
|
||||
args.Session = session.New(args.RepoDir, gitBranch, args.Model, session.SessionOptions{
|
||||
ReviewMode: mode,
|
||||
DiffFrom: args.From,
|
||||
DiffTo: args.To,
|
||||
DiffCommit: args.Commit,
|
||||
ReviewMode: mode,
|
||||
DiffFrom: args.From,
|
||||
DiffTo: args.To,
|
||||
DiffCommit: args.Commit,
|
||||
ResumedFrom: resumedFromSession(args.Resume),
|
||||
})
|
||||
}
|
||||
a := &Agent{
|
||||
|
|
@ -222,6 +240,23 @@ func (a *Agent) Session() *session.SessionHistory {
|
|||
return a.session
|
||||
}
|
||||
|
||||
// SessionID returns the current review's session id, or "" when no session has been created.
|
||||
func (a *Agent) SessionID() string {
|
||||
if a == nil || a.session == nil {
|
||||
return ""
|
||||
}
|
||||
return a.session.SessionID
|
||||
}
|
||||
|
||||
// ResumeInfo returns resume metadata for output. Nil means this was not a resume run.
|
||||
func (a *Agent) ResumeInfo() *ResumeInfo {
|
||||
if a.resumeInfo == nil {
|
||||
return nil
|
||||
}
|
||||
info := *a.resumeInfo
|
||||
return &info
|
||||
}
|
||||
|
||||
// FilesReviewed returns the number of changed files included in this review.
|
||||
func (a *Agent) FilesReviewed() int64 {
|
||||
return int64(len(a.diffs))
|
||||
|
|
@ -324,6 +359,7 @@ func (a *Agent) dispatchSubtasks(ctx context.Context) ([]model.LlmComment, error
|
|||
if len(a.diffs) == 0 {
|
||||
return nil, fmt.Errorf("all diffs filtered out by token size")
|
||||
}
|
||||
toDispatch := a.applyResume(a.diffs)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
|
|
@ -336,8 +372,8 @@ func (a *Agent) dispatchSubtasks(ctx context.Context) ([]model.LlmComment, error
|
|||
timeout := time.Duration(a.args.ConcurrentTaskTimeout) * time.Minute
|
||||
|
||||
var dispatched int64
|
||||
for i := range a.diffs {
|
||||
if a.diffs[i].IsDeleted {
|
||||
for i := range toDispatch {
|
||||
if toDispatch[i].IsDeleted {
|
||||
continue
|
||||
}
|
||||
dispatched++
|
||||
|
|
@ -345,8 +381,25 @@ func (a *Agent) dispatchSubtasks(ctx context.Context) ([]model.LlmComment, error
|
|||
sem <- struct{}{} // acquire semaphore
|
||||
|
||||
go func(d model.Diff) {
|
||||
fingerprint := reviewItemFingerprint(a.reviewMode(), d)
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }() // release
|
||||
// A panic while reviewing one file must be isolated exactly like an
|
||||
// error return: counted in subtaskFailed and recorded as a
|
||||
// subtask_error warning, so other files still complete and the
|
||||
// all-failed rollup below stays correct. Registered before the
|
||||
// timeout-cancel defer, so cancel() still runs first on unwind and
|
||||
// fileCtx is already cancelled here — use the parent ctx for telemetry.
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
atomic.AddInt64(&a.subtaskFailed, 1)
|
||||
a.session.RecordReviewItemFailed(d.NewPath, d.OldPath, d.NewPath, fingerprint, fmt.Sprintf("panic: %v", r))
|
||||
fmt.Fprintf(stdout.Writer(), "[ocr] Subtask panic for %s: %v\n%s\n", d.NewPath, r, debug.Stack())
|
||||
telemetry.ErrorEvent(ctx, "subtask.panic", fmt.Errorf("panic: %v", r),
|
||||
telemetry.AnyToAttr("file.path", d.NewPath))
|
||||
a.recordWarning("subtask_error", d.NewPath, fmt.Sprintf("panic: %v", r))
|
||||
}
|
||||
}()
|
||||
|
||||
var fileCtx context.Context
|
||||
var cancel context.CancelFunc
|
||||
|
|
@ -357,20 +410,31 @@ func (a *Agent) dispatchSubtasks(ctx context.Context) ([]model.LlmComment, error
|
|||
fileCtx = ctx
|
||||
}
|
||||
|
||||
if err := a.executeSubtask(fileCtx, d); err != nil {
|
||||
completed, skipReason, err := a.executeSubtask(fileCtx, d)
|
||||
if err != nil {
|
||||
atomic.AddInt64(&a.subtaskFailed, 1)
|
||||
a.session.RecordReviewItemFailed(d.NewPath, d.OldPath, d.NewPath, fingerprint, err.Error())
|
||||
fmt.Fprintf(stdout.Writer(), "[ocr] Subtask error for %s: %v\n", d.NewPath, err)
|
||||
telemetry.ErrorEvent(fileCtx, "subtask.error", err,
|
||||
telemetry.AnyToAttr("file.path", d.NewPath))
|
||||
a.recordWarning("subtask_error", d.NewPath, err.Error())
|
||||
return
|
||||
}
|
||||
}(a.diffs[i])
|
||||
if !completed {
|
||||
if skipReason != "" {
|
||||
a.session.RecordReviewItemFailed(d.NewPath, d.OldPath, d.NewPath, fingerprint, skipReason)
|
||||
}
|
||||
return
|
||||
}
|
||||
comments := a.args.CommentCollector.CommentsForPath(d.NewPath)
|
||||
a.session.RecordReviewItemDone(d.NewPath, d.OldPath, d.NewPath, fingerprint, comments)
|
||||
}(toDispatch[i])
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
if dispatched == 0 {
|
||||
return []model.LlmComment{}, nil
|
||||
return a.args.CommentCollector.Comments(), nil
|
||||
}
|
||||
|
||||
// All subtasks finished — collect comments from the global collector once.
|
||||
|
|
@ -386,8 +450,76 @@ func (a *Agent) dispatchSubtasks(ctx context.Context) ([]model.LlmComment, error
|
|||
return a.args.CommentCollector.Comments(), nil
|
||||
}
|
||||
|
||||
func (a *Agent) applyResume(diffs []model.Diff) []model.Diff {
|
||||
resume := a.args.Resume
|
||||
if resume == nil {
|
||||
return diffs
|
||||
}
|
||||
|
||||
mode := a.reviewMode()
|
||||
toDispatch := make([]model.Diff, 0, len(diffs))
|
||||
var reused int64
|
||||
for _, d := range diffs {
|
||||
if d.IsDeleted {
|
||||
toDispatch = append(toDispatch, d)
|
||||
continue
|
||||
}
|
||||
fingerprint := reviewItemFingerprint(mode, d)
|
||||
item, ok := resume.Item(fingerprint)
|
||||
if !ok {
|
||||
toDispatch = append(toDispatch, d)
|
||||
continue
|
||||
}
|
||||
for _, cm := range item.Comments {
|
||||
a.args.CommentCollector.Add(cm)
|
||||
}
|
||||
a.session.RecordReviewItemReused(effectivePath(d), d.OldPath, d.NewPath, fingerprint, resume.SessionID, item.Comments)
|
||||
reused++
|
||||
}
|
||||
|
||||
rerun := countDispatchable(toDispatch)
|
||||
a.resumeInfo = &ResumeInfo{
|
||||
ResumedFrom: resume.SessionID,
|
||||
ReusedFiles: reused,
|
||||
RerunFiles: rerun,
|
||||
PreviousModel: resume.Model,
|
||||
CurrentModel: a.args.Model,
|
||||
}
|
||||
fmt.Fprintf(stdout.Writer(), "[ocr] Resume %s: reusing %d file(s), reviewing %d file(s)\n", resume.SessionID, reused, rerun)
|
||||
return toDispatch
|
||||
}
|
||||
|
||||
func countDispatchable(diffs []model.Diff) int64 {
|
||||
var n int64
|
||||
for _, d := range diffs {
|
||||
if !d.IsDeleted {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func (a *Agent) reviewMode() string {
|
||||
if a.args.ReviewMode != "" {
|
||||
return a.args.ReviewMode
|
||||
}
|
||||
return reviewModeString(a.args.From, a.args.To, a.args.Commit)
|
||||
}
|
||||
|
||||
func reviewItemFingerprint(mode string, d model.Diff) string {
|
||||
sum := sha256.Sum256([]byte(mode + "\x00" + d.OldPath + "\x00" + d.NewPath + "\x00" + d.Diff))
|
||||
return fmt.Sprintf("%x", sum)
|
||||
}
|
||||
|
||||
func resumedFromSession(resume *session.ResumeState) string {
|
||||
if resume == nil {
|
||||
return ""
|
||||
}
|
||||
return resume.SessionID
|
||||
}
|
||||
|
||||
// executeSubtask performs the Plan Phase + Main Loop for a single file.
|
||||
func (a *Agent) executeSubtask(ctx context.Context, d model.Diff) error {
|
||||
func (a *Agent) executeSubtask(ctx context.Context, d model.Diff) (bool, string, error) {
|
||||
ctx, span := telemetry.StartSpan(ctx, "subtask.execute."+d.NewPath)
|
||||
defer span.End()
|
||||
telemetry.SetAttr(span, "file.path", d.NewPath)
|
||||
|
|
@ -396,7 +528,7 @@ func (a *Agent) executeSubtask(ctx context.Context, d model.Diff) error {
|
|||
telemetry.SetAttr(span, "lines.deleted", d.Deletions)
|
||||
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
return false, "", ctx.Err()
|
||||
}
|
||||
|
||||
newPath := d.NewPath
|
||||
|
|
@ -430,7 +562,7 @@ func (a *Agent) executeSubtask(ctx context.Context, d model.Diff) error {
|
|||
|
||||
// Phase 2: Main task loop
|
||||
if len(a.args.Template.MainTask.Messages) == 0 {
|
||||
return fmt.Errorf("main_task.messages is empty in template")
|
||||
return false, "", fmt.Errorf("main_task.messages is empty in template")
|
||||
}
|
||||
|
||||
rawMsgs := a.args.Template.MainTask.Messages
|
||||
|
|
@ -468,10 +600,21 @@ func (a *Agent) executeSubtask(ctx context.Context, d model.Diff) error {
|
|||
telemetry.AnyToAttr("file.path", newPath),
|
||||
telemetry.AnyToAttr("tokens", tokenCount),
|
||||
telemetry.AnyToAttr("max_tokens", maxAllowed))
|
||||
return nil
|
||||
return false, msg, nil
|
||||
}
|
||||
|
||||
err := a.runner.RunPerFile(ctx, messages, newPath)
|
||||
mainCompleted, err := func() (bool, error) {
|
||||
ctx, mainSpan := telemetry.StartSpan(ctx, "main.loop")
|
||||
defer mainSpan.End()
|
||||
telemetry.SetAttr(mainSpan, "file.path", newPath)
|
||||
completed, err := a.runner.RunPerFile(ctx, messages, newPath)
|
||||
if err != nil {
|
||||
mainSpan.SetStatus(codes.Error, err.Error())
|
||||
mainSpan.RecordError(err)
|
||||
return false, err
|
||||
}
|
||||
return completed, nil
|
||||
}()
|
||||
if err == nil {
|
||||
// REVIEW_FILTER_TASK runs after the main loop and decides which of the
|
||||
// just-collected comments to drop. It needs to see comments produced by
|
||||
|
|
@ -481,12 +624,22 @@ func (a *Agent) executeSubtask(ctx context.Context, d model.Diff) error {
|
|||
}
|
||||
a.executeReviewFilter(ctx, d, newPath)
|
||||
}
|
||||
return err
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
if !mainCompleted {
|
||||
return false, "main_task did not complete before stopping", nil
|
||||
}
|
||||
return true, "", nil
|
||||
}
|
||||
|
||||
// executeReviewFilter runs the REVIEW_FILTER_TASK to remove comments that are
|
||||
// provably incorrect based solely on the diff. Errors are logged and silently ignored.
|
||||
func (a *Agent) executeReviewFilter(ctx context.Context, d model.Diff, newPath string) {
|
||||
ctx, span := telemetry.StartSpan(ctx, "review_filter.execute")
|
||||
defer span.End()
|
||||
telemetry.SetAttr(span, "file.path", newPath)
|
||||
|
||||
ft := a.args.Template.ReviewFilterTask
|
||||
if ft == nil || len(ft.Messages) == 0 {
|
||||
return
|
||||
|
|
@ -496,6 +649,7 @@ func (a *Agent) executeReviewFilter(ctx context.Context, d model.Diff, newPath s
|
|||
if len(comments) == 0 {
|
||||
return
|
||||
}
|
||||
telemetry.SetAttr(span, "comments.before", len(comments))
|
||||
|
||||
commentsJSON := buildFilterCommentsJSON(comments)
|
||||
|
||||
|
|
@ -518,20 +672,33 @@ func (a *Agent) executeReviewFilter(ctx context.Context, d model.Diff, newPath s
|
|||
rec := fs.AppendTaskRecord(session.ReviewFilterTask, messages)
|
||||
startTime := time.Now()
|
||||
|
||||
_, llmSpan := telemetry.StartLLMSpan(ctx, a.args.Model)
|
||||
resp, err := a.args.LLMClient.CompletionsWithCtx(ctx, llm.ChatRequest{
|
||||
Model: a.args.Model,
|
||||
Messages: messages,
|
||||
MaxTokens: a.args.Template.MaxTokens,
|
||||
})
|
||||
duration := time.Since(startTime)
|
||||
if err != nil {
|
||||
rec.SetError(err, time.Since(startTime))
|
||||
telemetry.RecordLLMResult(llmSpan, duration, 0, err)
|
||||
llmSpan.End()
|
||||
rec.SetError(err, duration)
|
||||
fmt.Fprintf(stdout.Writer(), "[ocr] Review filter failed for %s: %v\n", newPath, err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
span.RecordError(err)
|
||||
return
|
||||
}
|
||||
rec.SetResponse(resp, time.Since(startTime))
|
||||
var totalTokens int64
|
||||
if resp.Usage != nil {
|
||||
totalTokens = resp.Usage.TotalTokens
|
||||
}
|
||||
telemetry.RecordLLMResult(llmSpan, duration, totalTokens, nil)
|
||||
llmSpan.End()
|
||||
rec.SetResponse(resp, duration)
|
||||
a.runner.RecordUsage(resp.Usage)
|
||||
|
||||
indices := parseFilterResponse(resp.Content(), len(comments))
|
||||
telemetry.SetAttr(span, "comments.filtered", len(indices))
|
||||
if len(indices) == 0 {
|
||||
return
|
||||
}
|
||||
|
|
@ -706,6 +873,10 @@ func (a *Agent) extFromPath(path string) string {
|
|||
// executePlanPhase runs the plan task for a single file, sending template messages
|
||||
// with resolved placeholders and collecting the LLM response as plan guidance.
|
||||
func (a *Agent) executePlanPhase(ctx context.Context, newPath, rawDiff, changeFiles, rule string) (string, error) {
|
||||
ctx, span := telemetry.StartSpan(ctx, "plan.execute")
|
||||
defer span.End()
|
||||
telemetry.SetAttr(span, "file.path", newPath)
|
||||
|
||||
pt := a.args.Template.PlanTask
|
||||
messages := make([]llm.Message, 0, len(pt.Messages))
|
||||
for _, m := range pt.Messages {
|
||||
|
|
@ -724,16 +895,28 @@ func (a *Agent) executePlanPhase(ctx context.Context, newPath, rawDiff, changeFi
|
|||
rec := fs.AppendTaskRecord(session.PlanTask, messages)
|
||||
startTime := time.Now()
|
||||
|
||||
_, llmSpan := telemetry.StartLLMSpan(ctx, a.args.Model)
|
||||
resp, err := a.args.LLMClient.CompletionsWithCtx(ctx, llm.ChatRequest{
|
||||
Model: a.args.Model,
|
||||
Messages: messages,
|
||||
MaxTokens: a.args.Template.MaxTokens,
|
||||
})
|
||||
duration := time.Since(startTime)
|
||||
if err != nil {
|
||||
rec.SetError(err, time.Since(startTime))
|
||||
telemetry.RecordLLMResult(llmSpan, duration, 0, err)
|
||||
llmSpan.End()
|
||||
rec.SetError(err, duration)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
span.RecordError(err)
|
||||
return "", fmt.Errorf("plan request: %w", err)
|
||||
}
|
||||
rec.SetResponse(resp, time.Since(startTime))
|
||||
var totalTokens int64
|
||||
if resp.Usage != nil {
|
||||
totalTokens = resp.Usage.TotalTokens
|
||||
}
|
||||
telemetry.RecordLLMResult(llmSpan, duration, totalTokens, nil)
|
||||
llmSpan.End()
|
||||
rec.SetResponse(resp, duration)
|
||||
a.runner.RecordUsage(resp.Usage)
|
||||
fmt.Fprintf(stdout.Writer(), "[ocr] Plan completed for %s\n", newPath)
|
||||
return resp.Content(), nil
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
"github.com/open-code-review/open-code-review/internal/config/toolsconfig"
|
||||
"github.com/open-code-review/open-code-review/internal/llm"
|
||||
"github.com/open-code-review/open-code-review/internal/model"
|
||||
"github.com/open-code-review/open-code-review/internal/session"
|
||||
"github.com/open-code-review/open-code-review/internal/tool"
|
||||
)
|
||||
|
||||
|
|
@ -407,6 +408,62 @@ func TestFilterLargeDiffs_ZeroMaxTokens(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestApplyResumeReusesCompletedItemsAcrossModels(t *testing.T) {
|
||||
diffs := []model.Diff{
|
||||
{OldPath: "a.go", NewPath: "a.go", Diff: "+a", Insertions: 1},
|
||||
{OldPath: "b.go", NewPath: "b.go", Diff: "+b", Insertions: 1},
|
||||
}
|
||||
fp := reviewItemFingerprint(session.ReviewModeRange, diffs[0])
|
||||
resume := &session.ResumeState{
|
||||
SessionID: "old-session",
|
||||
Model: "anthropic-model",
|
||||
ReviewMode: session.ReviewModeRange,
|
||||
DiffFrom: "main",
|
||||
DiffTo: "feature",
|
||||
Items: map[string]session.ResumeItem{
|
||||
fp: {
|
||||
FilePath: "a.go",
|
||||
OldPath: "a.go",
|
||||
NewPath: "a.go",
|
||||
Fingerprint: fp,
|
||||
Comments: []model.LlmComment{{
|
||||
Path: "a.go",
|
||||
Content: "cached comment",
|
||||
}},
|
||||
},
|
||||
},
|
||||
}
|
||||
collector := tool.NewCommentCollector()
|
||||
sess := session.New(t.TempDir(), "feature", "openai-model", session.SessionOptions{
|
||||
ReviewMode: session.ReviewModeRange,
|
||||
DiffFrom: "main",
|
||||
DiffTo: "feature",
|
||||
ResumedFrom: "old-session",
|
||||
})
|
||||
defer sess.Finalize()
|
||||
a := New(Args{
|
||||
From: "main",
|
||||
To: "feature",
|
||||
Model: "openai-model",
|
||||
CommentCollector: collector,
|
||||
Resume: resume,
|
||||
Session: sess,
|
||||
})
|
||||
|
||||
toDispatch := a.applyResume(diffs)
|
||||
if len(toDispatch) != 1 || toDispatch[0].NewPath != "b.go" {
|
||||
t.Fatalf("toDispatch = %+v, want only b.go", toDispatch)
|
||||
}
|
||||
comments := collector.Comments()
|
||||
if len(comments) != 1 || comments[0].Content != "cached comment" {
|
||||
t.Fatalf("comments = %+v", comments)
|
||||
}
|
||||
info := a.ResumeInfo()
|
||||
if info == nil || info.ReusedFiles != 1 || info.RerunFiles != 1 || info.PreviousModel != "anthropic-model" || info.CurrentModel != "openai-model" {
|
||||
t.Fatalf("ResumeInfo = %+v", info)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountReviewable(t *testing.T) {
|
||||
a := New(Args{})
|
||||
diffs := []model.Diff{
|
||||
|
|
@ -500,6 +557,140 @@ func TestDispatchSubtasks_WithFakeLLM(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestDispatchSubtasks_TokenThresholdSkipIsNotReusableCheckpoint(t *testing.T) {
|
||||
tmpHome := t.TempDir()
|
||||
t.Setenv("HOME", tmpHome)
|
||||
repoDir := t.TempDir()
|
||||
sess := session.New(repoDir, "feature", "fake", session.SessionOptions{
|
||||
ReviewMode: session.ReviewModeRange,
|
||||
DiffFrom: "main",
|
||||
DiffTo: "feature",
|
||||
})
|
||||
|
||||
client := &fakeAgentClient{responses: []*llm.ChatResponse{
|
||||
agentTaskDoneResponse(),
|
||||
}}
|
||||
a := New(Args{
|
||||
From: "main",
|
||||
To: "feature",
|
||||
LLMClient: client,
|
||||
Model: "fake",
|
||||
Session: sess,
|
||||
Template: template.Template{
|
||||
MaxTokens: 100,
|
||||
MaxToolRequestTimes: 5,
|
||||
MainTask: template.LlmConversation{
|
||||
Messages: []template.ChatMessage{
|
||||
{Role: "user", Content: strings.Repeat("context ", 200) + "{{diff}}"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
diff := model.Diff{NewPath: "large-prompt.go", OldPath: "large-prompt.go", Diff: "+x", Insertions: 1}
|
||||
a.diffs = []model.Diff{diff}
|
||||
a.currentDate = "2025-06-26 10:00"
|
||||
|
||||
comments, err := a.dispatchSubtasks(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("dispatchSubtasks: %v", err)
|
||||
}
|
||||
if len(comments) != 0 {
|
||||
t.Fatalf("expected no comments, got %d", len(comments))
|
||||
}
|
||||
if client.calls != 0 {
|
||||
t.Fatalf("threshold skip should not call LLM, got %d calls", client.calls)
|
||||
}
|
||||
sess.Finalize()
|
||||
|
||||
state, err := session.LoadResumeState(repoDir, sess.SessionID)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadResumeState: %v", err)
|
||||
}
|
||||
if state.CompletedCount() != 0 {
|
||||
t.Fatalf("CompletedCount = %d, want 0", state.CompletedCount())
|
||||
}
|
||||
fp := reviewItemFingerprint(session.ReviewModeRange, diff)
|
||||
if _, ok := state.Item(fp); ok {
|
||||
t.Fatal("token-threshold skip was recorded as a reusable checkpoint")
|
||||
}
|
||||
summary, items, err := session.LoadDetail(repoDir, sess.SessionID)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadDetail: %v", err)
|
||||
}
|
||||
if summary.CompletedFiles != 0 || summary.FailedFiles != 1 {
|
||||
t.Fatalf("summary counts = completed %d failed %d, want completed 0 failed 1", summary.CompletedFiles, summary.FailedFiles)
|
||||
}
|
||||
if len(items) != 1 || items[0].Type != "failed" || !strings.Contains(items[0].Error, "prompt tokens") {
|
||||
t.Fatalf("items = %+v, want one token-threshold failed item", items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchSubtasks_MainTaskWithoutTaskDoneIsNotReusableCheckpoint(t *testing.T) {
|
||||
tmpHome := t.TempDir()
|
||||
t.Setenv("HOME", tmpHome)
|
||||
repoDir := t.TempDir()
|
||||
sess := session.New(repoDir, "feature", "fake", session.SessionOptions{
|
||||
ReviewMode: session.ReviewModeRange,
|
||||
DiffFrom: "main",
|
||||
DiffTo: "feature",
|
||||
})
|
||||
|
||||
emptyContent := ""
|
||||
client := &fakeAgentClient{responses: []*llm.ChatResponse{{
|
||||
Choices: []llm.Choice{{Message: llm.ResponseMessage{Content: &emptyContent}}},
|
||||
Model: "fake",
|
||||
Usage: &llm.UsageInfo{PromptTokens: 10, CompletionTokens: 1},
|
||||
}}}
|
||||
a := New(Args{
|
||||
From: "main",
|
||||
To: "feature",
|
||||
LLMClient: client,
|
||||
Model: "fake",
|
||||
Session: sess,
|
||||
Template: template.Template{
|
||||
MaxTokens: 100000,
|
||||
MaxToolRequestTimes: 1,
|
||||
MainTask: template.LlmConversation{
|
||||
Messages: []template.ChatMessage{{Role: "user", Content: "Review {{diff}}"}},
|
||||
},
|
||||
},
|
||||
})
|
||||
diff := model.Diff{NewPath: "needs-review.go", OldPath: "needs-review.go", Diff: "+x", Insertions: 1}
|
||||
a.diffs = []model.Diff{diff}
|
||||
a.currentDate = "2025-06-26 10:00"
|
||||
|
||||
_, err := a.dispatchSubtasks(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("dispatchSubtasks: %v", err)
|
||||
}
|
||||
if client.calls != 1 {
|
||||
t.Fatalf("LLM calls = %d, want 1", client.calls)
|
||||
}
|
||||
sess.Finalize()
|
||||
|
||||
state, err := session.LoadResumeState(repoDir, sess.SessionID)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadResumeState: %v", err)
|
||||
}
|
||||
if state.CompletedCount() != 0 {
|
||||
t.Fatalf("CompletedCount = %d, want 0", state.CompletedCount())
|
||||
}
|
||||
fp := reviewItemFingerprint(session.ReviewModeRange, diff)
|
||||
if _, ok := state.Item(fp); ok {
|
||||
t.Fatal("incomplete main task was recorded as a reusable checkpoint")
|
||||
}
|
||||
summary, items, err := session.LoadDetail(repoDir, sess.SessionID)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadDetail: %v", err)
|
||||
}
|
||||
if summary.CompletedFiles != 0 || summary.FailedFiles != 1 {
|
||||
t.Fatalf("summary counts = completed %d failed %d, want completed 0 failed 1", summary.CompletedFiles, summary.FailedFiles)
|
||||
}
|
||||
if len(items) != 1 || items[0].Type != "failed" || !strings.Contains(items[0].Error, "main_task did not complete") {
|
||||
t.Fatalf("items = %+v, want one incomplete-main failed item", items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchSubtasks_AllDeleted(t *testing.T) {
|
||||
client := &fakeAgentClient{}
|
||||
a := New(Args{
|
||||
|
|
|
|||
|
|
@ -412,10 +412,16 @@ func TestExecuteSubtask_EmptyMainTask(t *testing.T) {
|
|||
})
|
||||
a.currentDate = "2025-06-26 10:00"
|
||||
|
||||
err := a.executeSubtask(context.Background(), model.Diff{NewPath: "a.go", Diff: "+x", Insertions: 1})
|
||||
completed, skipReason, err := a.executeSubtask(context.Background(), model.Diff{NewPath: "a.go", Diff: "+x", Insertions: 1})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty main_task messages")
|
||||
}
|
||||
if completed {
|
||||
t.Fatal("empty main_task should not complete review")
|
||||
}
|
||||
if skipReason != "" {
|
||||
t.Fatalf("skipReason = %q, want empty on error", skipReason)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "main_task.messages is empty") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
|
|
@ -442,10 +448,16 @@ func TestExecuteSubtask_TokenThresholdExceeded(t *testing.T) {
|
|||
a.currentDate = "2025-06-26 10:00"
|
||||
a.diffs = []model.Diff{{NewPath: "a.go", Diff: strings.Repeat("code ", 200), Insertions: 100}}
|
||||
|
||||
err := a.executeSubtask(context.Background(), a.diffs[0])
|
||||
completed, skipReason, err := a.executeSubtask(context.Background(), a.diffs[0])
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if completed {
|
||||
t.Fatal("token-threshold skip should not complete review")
|
||||
}
|
||||
if skipReason == "" {
|
||||
t.Fatal("expected skip reason for token-threshold skip")
|
||||
}
|
||||
|
||||
warnings := a.Warnings()
|
||||
found := false
|
||||
|
|
@ -514,10 +526,16 @@ func TestExecuteSubtask_WithPlanPhase(t *testing.T) {
|
|||
a.currentDate = "2025-06-26 10:00"
|
||||
a.diffs = []model.Diff{{NewPath: "main.go", OldPath: "main.go", Diff: "+new code", Insertions: 5}}
|
||||
|
||||
err := a.executeSubtask(context.Background(), a.diffs[0])
|
||||
completed, skipReason, err := a.executeSubtask(context.Background(), a.diffs[0])
|
||||
if err != nil {
|
||||
t.Fatalf("executeSubtask: %v", err)
|
||||
}
|
||||
if !completed {
|
||||
t.Fatal("expected completed review")
|
||||
}
|
||||
if skipReason != "" {
|
||||
t.Fatalf("skipReason = %q, want empty on completed review", skipReason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteSubtask_ContextCancelled(t *testing.T) {
|
||||
|
|
@ -538,10 +556,16 @@ func TestExecuteSubtask_ContextCancelled(t *testing.T) {
|
|||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
err := a.executeSubtask(ctx, model.Diff{NewPath: "a.go", Diff: "+x", Insertions: 1})
|
||||
completed, skipReason, err := a.executeSubtask(ctx, model.Diff{NewPath: "a.go", Diff: "+x", Insertions: 1})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for cancelled context")
|
||||
}
|
||||
if completed {
|
||||
t.Fatal("cancelled context should not complete review")
|
||||
}
|
||||
if skipReason != "" {
|
||||
t.Fatalf("skipReason = %q, want empty on error", skipReason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteReviewFilter_WithTimeout(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -157,6 +157,9 @@ func TestCopyMessages(t *testing.T) {
|
|||
}
|
||||
|
||||
cp = append(cp, llm.NewTextMessage("user", "c"))
|
||||
if len(cp) != len(orig)+1 {
|
||||
t.Errorf("appended copy length = %d, want %d", len(cp), len(orig)+1)
|
||||
}
|
||||
if len(orig) != 2 {
|
||||
t.Error("copyMessages: appending to copy modified original slice")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ func TestIsAllowedExt(t *testing.T) {
|
|||
{".java", true},
|
||||
{".ts", true},
|
||||
{".tsx", true},
|
||||
{".astro", true},
|
||||
{".ASTRO", true},
|
||||
{".py", true},
|
||||
{".rs", true},
|
||||
{".ets", true},
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@
|
|||
".less",
|
||||
".html",
|
||||
".htm",
|
||||
".astro",
|
||||
".vue",
|
||||
".svelte",
|
||||
".xml",
|
||||
|
|
@ -66,4 +67,4 @@
|
|||
".json5",
|
||||
".dart",
|
||||
".tf"
|
||||
]
|
||||
]
|
||||
|
|
|
|||
45
internal/config/rules/rule_docs/astro.md
Normal file
45
internal/config/rules/rule_docs/astro.md
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
#### Obvious Typos or Spelling Errors
|
||||
- Spelling errors in component names, props, slots, or user-facing strings that affect readability
|
||||
|
||||
#### Dead Code
|
||||
- Unused islands, framework components, scripts, or template branches that add client cost without affecting rendered behavior
|
||||
|
||||
#### Astro Component Boundaries
|
||||
- When frontmatter data reaches client HTML, inline scripts, or hydrated islands, verify whether it was computed at build time or request time and whether exposing non-`PUBLIC_` env values, cookies, headers, sessions, `Astro.locals`, secrets, request-only data, or server-only APIs is intentional
|
||||
- Flag `.astro` templates that appear to assume frontmatter values are reactive in the browser
|
||||
- Flag framework components used only to render static markup when plain Astro markup would avoid unnecessary client JavaScript
|
||||
|
||||
#### Hydration and Islands
|
||||
- `client:*` applies only to directly imported UI framework components, not `.astro` components or dynamic tags
|
||||
- Flag `client:load` on non-critical UI, missed `client:idle` or `client:visible` opportunities, `client:media` where the media query does not actually gate the interaction need, and over-hydration from large or overly numerous islands
|
||||
- Flag `client:only` without the framework string or without fallback content when the result is blank or confusing pre-hydration UI
|
||||
|
||||
#### Server-to-Client Data Transfer
|
||||
- Flag hydrated framework component props or server-fetched data passed client-side without reducing to the minimal interaction payload; props crossing hydrated boundaries must use Astro-supported serializable types, so flag functions, class instances, circular objects, secrets, and unnecessarily large payloads.
|
||||
- `<script define:vars>` values are JSON-stringified and inline; flag secrets, large payloads, or repeated per-instance duplication
|
||||
|
||||
#### Server Islands
|
||||
- Flag `server:defer` usage without required adapter support or without a fallback slot when deferred content needs a meaningful loading state
|
||||
- Props passed into `server:defer` islands must use Astro-supported serializable types; flag functions, circular objects, secrets, and large request objects
|
||||
- Flag `server:defer` uses that leak request-specific data into cacheable output or weaken privacy assumptions
|
||||
|
||||
#### Template Safety
|
||||
- Treat `set:html` as a high-risk escape hatch; flag it unless the source is clearly trusted or sanitized
|
||||
- Flag unsafe or insufficiently validated dynamic values inserted into attributes, URLs, or raw markup
|
||||
- Flag fragile template structures, especially mixed `set:*` usage or conditional markup that changes HTML shape in surprising ways
|
||||
|
||||
#### Scripts
|
||||
- Flag scripts with attributes other than `src` when unprocessed behavior causes avoidable per-instance duplication, bypasses bundling, or relies on server-only values
|
||||
- Flag framework hydration used for behavior that a small processed Astro script would handle
|
||||
|
||||
#### Styles
|
||||
- Flag unnecessary `is:global` usage when scoped styles or a narrow `:global(...)` escape would do
|
||||
- Flag selectors that assume scoped CSS can style child component internals across a component boundary
|
||||
- Flag components that accept parent styling but fail to forward `class` and needed rest props
|
||||
|
||||
#### Content and Assets
|
||||
- For structured Markdown/MDX/JSON content, flag ad hoc loading when Astro content collections would materially improve schema validation, typing, or route generation
|
||||
- Flag plain `<img>` or `public/` asset usage when the implementation appears to expect Astro image optimization, responsive behavior, fingerprinting, transforms, or import-time validation
|
||||
|
||||
#### Markup and Accessibility
|
||||
- Flag non-semantic or fragile interactive markup, including inaccessible islands before hydration, invalid conditional HTML, or mixed Astro/framework composition that breaks keyboard or focus behavior
|
||||
76
internal/config/rules/rule_docs/python.md
Normal file
76
internal/config/rules/rule_docs/python.md
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
> Favor precision over recall: only raise an issue when you are confident it is a real defect, and stay silent when the surrounding context is unclear — a false alarm costs more reviewer trust than a missed minor issue. Treat security and correctness findings as blocking, and style or idiom suggestions as non-blocking.
|
||||
|
||||
#### Obvious Typos or Spelling Errors
|
||||
- Spelling errors in variable, function, class, or module names at their declaration sites; do not report spelling errors at reference sites, as these are determined by the declaration
|
||||
- Strings in log messages or exception messages containing spelling errors that affect readability
|
||||
|
||||
#### Dead Code
|
||||
- Code blocks that can never be reached (e.g., branches where the condition is always false, code after a `return`, `raise`, `break`, or `continue`)
|
||||
- Variables, imports, or function parameters that are declared but never read or referenced
|
||||
- Large blocks of commented-out code with no apparent intent to preserve
|
||||
|
||||
#### Mutable Default Arguments and Shared State
|
||||
- Mutable default arguments such as `def f(x=[])` or `def f(x={})`; the default is created once and shared across every call. Default to `None` and build the value inside the body
|
||||
- Class-level mutable attributes shared unintentionally across instances when a per-instance value was intended
|
||||
- Module-level mutable globals (lists, dicts, caches) mutated across requests or threads, retaining state in ways that surprise the caller
|
||||
- Closures that capture a loop variable by reference and all end up seeing its final value
|
||||
- Do not report when the function never mutates the argument, or when the shared default is a deliberate, documented cache or sentinel
|
||||
|
||||
#### Boundary and Edge-Case Handling
|
||||
- Empty inputs assumed to be non-empty: indexing `xs[0]`, `max()`/`min()`, or slicing without first handling the empty `list`, `str`, `dict`, or iterator
|
||||
- Off-by-one and out-of-range access on indices, ranges, or slices, especially at the first/last element
|
||||
- `None` reaching code that assumes a value, when an upstream call or default can legitimately return `None` (confirm the data source with `file_read` before flagging)
|
||||
- Comparing floats for exact equality with `==`; use `math.isclose` or an explicit tolerance, since floating-point results are not exact
|
||||
- Integer/float and division assumptions: unintended truncation with `//`, or `ZeroDivisionError` when a divisor can be zero
|
||||
- Heterogeneous or unexpected element types in a collection that the code assumes are uniform (e.g., mixing `None`, numbers, and strings)
|
||||
- Dictionary access by key without handling the missing-key case (`d[k]` vs `d.get(k)`), or set/dict operations that assume a key is present
|
||||
- Do not report edge cases that a caller or type contract has already ruled out, or inputs that cannot occur given validated boundaries upstream
|
||||
|
||||
#### Error Handling and Exceptions
|
||||
- Bare `except:` swallows everything, including `KeyboardInterrupt` and `SystemExit`; catch `except Exception` at minimum, and prefer the specific exception types you expect
|
||||
- `except Exception` that is still broader than the failure being handled; narrow it to the exceptions actually raised by the guarded call
|
||||
- Exceptions caught and silently discarded (`pass`) without logging or re-raising
|
||||
- Original traceback lost when re-raising; prefer `raise NewError(...) from err` to preserve the cause
|
||||
- Broad `try` blocks that wrap far more than the line that can actually fail, hiding where the error originates
|
||||
- `assert` used for runtime validation of external input — assertions are stripped under `python -O`
|
||||
|
||||
#### Identity and Equality Comparisons
|
||||
- Using `is`/`is not` to compare against literals such as strings, numbers, or tuples; this relies on implementation-specific interning rather than value equality — use `==` (a real correctness risk)
|
||||
- Comparing against `True`/`False` with `==`, where a truthy-but-not-`True` value (e.g. `1`, a non-empty container) would compare unequal; prefer a plain truthiness check
|
||||
- Reserve `is` for identity checks against singletons and sentinels
|
||||
- Comparing against `None` with `==`/`!=` rather than `is`/`is not` is a style preference; report as minor, not blocking
|
||||
|
||||
#### Resource Management
|
||||
- Files, sockets, locks, or database connections opened without a `with` statement, risking leaks on early return or exception
|
||||
- Context managers available but bypassed in favor of manual `open()`/`close()` pairs
|
||||
- Resources acquired in a `try` whose `finally` cleanup is missing or incomplete on the error path
|
||||
- Iterators or generators holding resources open longer than necessary
|
||||
- Do not report short-lived scripts, or handles already managed by an enclosing `with` or framework-managed lifecycle (confirm the surrounding scope with `file_read` before flagging)
|
||||
|
||||
#### Performance
|
||||
Confirm data scale and that the code is on a hot path before flagging:
|
||||
- Building strings with `+=` in a loop instead of accumulating in a list and `"".join(...)`, or using an f-string
|
||||
- Repeated membership tests against a `list` where a `set` or `dict` would turn O(n) lookups into O(1)
|
||||
- Building a full list when a generator would avoid holding everything in memory
|
||||
- Recomputing inside a loop a value that is invariant across iterations (e.g., compiling a regex, attribute lookups in hot paths)
|
||||
- Passing an eagerly formatted f-string to `logging` (e.g., `logging.info(f"...")`) instead of `logging.info("%s", value)`, which defeats lazy formatting when the level is disabled
|
||||
|
||||
#### Concurrency and Async
|
||||
Only flag concurrency issues when there is evidence of multi-threaded, multi-process, or async invocation (confirm the call context before reporting):
|
||||
- CPU-bound work parallelized with `threading` under the GIL where `multiprocessing` or a process pool is the right tool (traditional CPython; free-threaded builds excepted); I/O-bound work is the case threads actually help
|
||||
- Check-then-act races on shared state without a `Lock`, or non-atomic compound updates assumed to be atomic
|
||||
- Blocking calls (synchronous I/O, `time.sleep`, `requests`, CPU-heavy work) inside `async def`, stalling the event loop; use the async equivalent or run them in an executor
|
||||
- `asyncio` tasks created and never awaited, so exceptions are swallowed and the work may be garbage-collected before it finishes
|
||||
- Shared mutable state across threads or tasks without synchronization or a thread-safe structure
|
||||
|
||||
Do not report local variables (each thread has its own), read-only access to shared data, or code with no evidence of concurrent use.
|
||||
|
||||
#### Security-Sensitive Code
|
||||
Validate the data source before flagging; confirm the input is actually attacker-controlled rather than a trusted constant:
|
||||
- `eval`, `exec`, or `compile` on untrusted input; this is arbitrary code execution
|
||||
- `subprocess` with `shell=True` built from unsanitized input; pass an argument list and avoid the shell
|
||||
- `pickle`, `marshal`, or `yaml.load` (without `SafeLoader`) on untrusted data; deserialization can execute arbitrary code
|
||||
- SQL built by string concatenation or f-strings instead of parameterized queries
|
||||
- Secrets, tokens, passwords, or PII written to logs or committed in source
|
||||
- Weak or misused cryptography (`hashlib.md5`/`sha1` for passwords, `random` for security tokens); use `secrets` and vetted libraries
|
||||
- Untrusted file paths joined without validation, allowing path traversal
|
||||
|
|
@ -344,6 +344,11 @@ func loadRuleFile(path string) (*ProjectRule, error) {
|
|||
return &pr, nil
|
||||
}
|
||||
|
||||
// loadProjectRule reads <repoDir>/.opencodereview/rule.json. Since #287 anchored
|
||||
// RepoDir at the git top-level, `ocr review` from a monorepo subdirectory loads
|
||||
// the repo-root rule file — which is consistent, since rule entries match against
|
||||
// root-relative diff paths. A subproject-local rule.json under the subdirectory is
|
||||
// intentionally not consulted; put shared rules at the repo root, or pass --rule.
|
||||
func loadProjectRule(repoDir string) (*ProjectRule, error) {
|
||||
path := filepath.Join(repoDir, ".opencodereview", "rule.json")
|
||||
data, err := os.ReadFile(path)
|
||||
|
|
|
|||
|
|
@ -13,10 +13,12 @@
|
|||
"**/*.{yaml,yml}": "yaml.md",
|
||||
"**/*.java": "java.md",
|
||||
"**/*.ets": "arkts.md",
|
||||
"**/*.astro": "astro.md",
|
||||
"**/*.{ts,js,tsx,jsx}": "ts_js_tsx_jsx.md",
|
||||
"**/*.{kt}": "kotlin.md",
|
||||
"**/*.rs": "rust.md",
|
||||
"**/*.{cpp,cc,hpp}": "cpp.md",
|
||||
"**/*.c": "c.md"
|
||||
"**/*.c": "c.md",
|
||||
"**/*.py": "python.md"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ func TestResolve_DefaultRules(t *testing.T) {
|
|||
{"frontend/package.json", "latest"},
|
||||
{"config/app.yaml", "yaml-key"},
|
||||
{"deploy/values.yml", "yaml-key"},
|
||||
{"src/pages/index.astro", "client:*"},
|
||||
{"src/components/app.tsx", "React"},
|
||||
{"lib/utils.ts", "TypeScript"},
|
||||
{"app.kt", "Null Safety"},
|
||||
|
|
@ -79,6 +80,8 @@ func TestResolve_DefaultRules(t *testing.T) {
|
|||
{"src/lib.rs", "Ownership and Lifetime Correctness"},
|
||||
{"crates/service/src/main.rs", "Unsafe Code Boundaries"},
|
||||
{"crates/service/Cargo.toml", "Cargo Manifest Hygiene"},
|
||||
{"scripts/deploy.py", "Mutable Default Arguments"},
|
||||
{"src/app/main.py", "Mutable Default Arguments"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
|
@ -103,7 +106,6 @@ func TestResolve_FallbackToDefault(t *testing.T) {
|
|||
"docs/architecture.txt",
|
||||
"Makefile",
|
||||
"internal/agent/agent.go",
|
||||
"scripts/deploy.py",
|
||||
"ios/ViewController.m",
|
||||
}
|
||||
|
||||
|
|
@ -157,12 +159,23 @@ func TestResolve_CaseInsensitive(t *testing.T) {
|
|||
rule := &SystemRule{
|
||||
DefaultRule: "default",
|
||||
PathRules: []PathRule{
|
||||
{Pattern: "**/*.astro", Rule: "astro-rule"},
|
||||
{Pattern: "**/*.java", Rule: "java-rule"},
|
||||
{Pattern: "**/Cargo.toml", Rule: "cargo-rule"},
|
||||
},
|
||||
}
|
||||
|
||||
got := rule.Resolve("Foo.Java")
|
||||
got := rule.Resolve("Foo.Astro")
|
||||
if got != "astro-rule" {
|
||||
t.Errorf("expected astro-rule for uppercase extension, got %q", got)
|
||||
}
|
||||
|
||||
got = rule.Resolve("foo.astro")
|
||||
if got != "astro-rule" {
|
||||
t.Errorf("expected astro-rule for lowercase, got %q", got)
|
||||
}
|
||||
|
||||
got = rule.Resolve("Foo.Java")
|
||||
if got != "java-rule" {
|
||||
t.Errorf("expected java-rule for uppercase extension, got %q", got)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,11 +51,37 @@
|
|||
"suggestion_code": {
|
||||
"type": "string",
|
||||
"description": "Corresponding suggested code snippet, maintaining consistent code style."
|
||||
},
|
||||
"category": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"bug",
|
||||
"security",
|
||||
"performance",
|
||||
"maintainability",
|
||||
"test",
|
||||
"style",
|
||||
"documentation",
|
||||
"other"
|
||||
],
|
||||
"description": "The category the issue belongs to."
|
||||
},
|
||||
"severity": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"critical",
|
||||
"high",
|
||||
"medium",
|
||||
"low"
|
||||
],
|
||||
"description": "The severity of the issue."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"content",
|
||||
"existing_code"
|
||||
"existing_code",
|
||||
"category",
|
||||
"severity"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,9 @@ func TestLoad_CustomFile(t *testing.T) {
|
|||
data := `[
|
||||
{"name": "test_tool", "plan_task": true, "main_task": false, "definition": {"name": "test_tool"}}
|
||||
]`
|
||||
os.WriteFile(path, []byte(data), 0644)
|
||||
if err := os.WriteFile(path, []byte(data), 0644); err != nil {
|
||||
t.Fatalf("write tools.json: %v", err)
|
||||
}
|
||||
|
||||
tools, err := Load(path)
|
||||
if err != nil {
|
||||
|
|
@ -61,7 +63,9 @@ func TestLoad_FileNotFound(t *testing.T) {
|
|||
func TestLoad_InvalidJSON(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
path := filepath.Join(tmp, "tools.json")
|
||||
os.WriteFile(path, []byte("not json"), 0644)
|
||||
if err := os.WriteFile(path, []byte("not json"), 0644); err != nil {
|
||||
t.Fatalf("write tools.json: %v", err)
|
||||
}
|
||||
|
||||
_, err := Load(path)
|
||||
if err == nil {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
"github.com/open-code-review/open-code-review/internal/llm"
|
||||
"github.com/open-code-review/open-code-review/internal/model"
|
||||
"github.com/open-code-review/open-code-review/internal/stdout"
|
||||
"github.com/open-code-review/open-code-review/internal/telemetry"
|
||||
)
|
||||
|
||||
// ReLocateComment calls the LLM to regenerate a precise existing_code snippet
|
||||
|
|
@ -44,15 +45,26 @@ func ReLocateComment(
|
|||
messages = append(messages, llm.NewTextMessage(m.Role, content))
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
_, llmSpan := telemetry.StartLLMSpan(ctx, modelName)
|
||||
resp, err := client.CompletionsWithCtx(ctx, llm.ChatRequest{
|
||||
Model: modelName,
|
||||
Messages: messages,
|
||||
MaxTokens: maxTokens,
|
||||
})
|
||||
duration := time.Since(startTime)
|
||||
if err != nil {
|
||||
telemetry.RecordLLMResult(llmSpan, duration, 0, err)
|
||||
llmSpan.End()
|
||||
fmt.Fprintf(stdout.Writer(), "[ocr] Re-location LLM call failed for %s: %v\n", cm.Path, err)
|
||||
return false, nil, messages
|
||||
}
|
||||
var totalTokens int64
|
||||
if resp.Usage != nil {
|
||||
totalTokens = resp.Usage.TotalTokens
|
||||
}
|
||||
telemetry.RecordLLMResult(llmSpan, duration, totalTokens, nil)
|
||||
llmSpan.End()
|
||||
|
||||
code := extractCodeBlock(resp.Content())
|
||||
if code == "" {
|
||||
|
|
|
|||
|
|
@ -243,7 +243,9 @@ func TestParseShellRC_ModelOverride(t *testing.T) {
|
|||
export ANTHROPIC_AUTH_TOKEN="token"
|
||||
export ANTHROPIC_MODEL=claude-3-opus
|
||||
`
|
||||
os.WriteFile(rcPath, []byte(content), 0644)
|
||||
if err := os.WriteFile(rcPath, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("write rc: %v", err)
|
||||
}
|
||||
|
||||
ep, ok, err := parseShellRC(rcPath, "override-model")
|
||||
if err != nil {
|
||||
|
|
@ -265,7 +267,9 @@ func TestParseShellRC_Incomplete(t *testing.T) {
|
|||
export ANTHROPIC_AUTH_TOKEN="token"
|
||||
# missing ANTHROPIC_MODEL
|
||||
`
|
||||
os.WriteFile(rcPath, []byte(content), 0644)
|
||||
if err := os.WriteFile(rcPath, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("write rc: %v", err)
|
||||
}
|
||||
|
||||
_, ok, err := parseShellRC(rcPath, "")
|
||||
if err != nil {
|
||||
|
|
@ -288,16 +292,16 @@ func TestParseShellRC_NonexistentFile(t *testing.T) {
|
|||
|
||||
func TestModelListContains(t *testing.T) {
|
||||
models := []string{"gpt-4", " claude-3-opus ", "gemini-pro"}
|
||||
if !modelListContains(models, "claude-3-opus") {
|
||||
if !ModelListContains(models, "claude-3-opus") {
|
||||
t.Error("expected true for claude-3-opus")
|
||||
}
|
||||
if !modelListContains(models, "gpt-4") {
|
||||
if !ModelListContains(models, "gpt-4") {
|
||||
t.Error("expected true for gpt-4")
|
||||
}
|
||||
if modelListContains(models, "gpt-3.5") {
|
||||
if ModelListContains(models, "gpt-3.5") {
|
||||
t.Error("expected false for gpt-3.5")
|
||||
}
|
||||
if modelListContains(nil, "anything") {
|
||||
if ModelListContains(nil, "anything") {
|
||||
t.Error("expected false for nil list")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,6 +43,25 @@ var registry = []Provider{
|
|||
"gpt-5.4-mini",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "edenai",
|
||||
DisplayName: "Eden AI",
|
||||
Protocol: "openai",
|
||||
BaseURL: "https://api.edenai.run/v3",
|
||||
EnvVar: "EDENAI_API_KEY",
|
||||
Models: []string{
|
||||
"anthropic/claude-opus-4-5",
|
||||
"anthropic/claude-sonnet-4-5",
|
||||
"anthropic/claude-haiku-4-5",
|
||||
"openai/gpt-5.1",
|
||||
"openai/gpt-5.1-codex",
|
||||
"google/gemini-3.1-pro-preview",
|
||||
"mistral/devstral-medium-latest",
|
||||
"mistral/codestral-latest",
|
||||
"deepseek/deepseek-v4-pro",
|
||||
"xai/grok-4",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "dashscope",
|
||||
DisplayName: "Alibaba DashScope API",
|
||||
|
|
@ -168,6 +187,19 @@ var registry = []Provider{
|
|||
"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: []string{
|
||||
"glm-5.2",
|
||||
"glm-5.1",
|
||||
"glm-5-turbo",
|
||||
"glm-4.7",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "mimo",
|
||||
DisplayName: "Xiaomi MiMo API",
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import (
|
|||
)
|
||||
|
||||
func TestLookupProvider_KnownProviders(t *testing.T) {
|
||||
names := []string{"anthropic", "openai", "dashscope"}
|
||||
names := []string{"anthropic", "openai", "dashscope", "edenai"}
|
||||
for _, name := range names {
|
||||
p, ok := LookupProvider(name)
|
||||
if !ok {
|
||||
|
|
@ -40,7 +40,7 @@ func TestListProviders_Order(t *testing.T) {
|
|||
if len(providers) < 3 {
|
||||
t.Fatalf("expected at least 3 providers, got %d", len(providers))
|
||||
}
|
||||
expected := []string{"anthropic", "baidu-qianfan", "dashscope", "dashscope-tokenplan", "deepseek", "hy-tokenplan", "kimi", "mimo", "minimax", "openai", "tencent-tokenhub", "volcengine", "z-ai"}
|
||||
expected := []string{"anthropic", "baidu-qianfan", "dashscope", "dashscope-tokenplan", "deepseek", "edenai", "hy-tokenplan", "kimi", "mimo", "minimax", "openai", "tencent-tokenhub", "volcengine", "z-ai", "z-ai-coding"}
|
||||
if len(providers) != len(expected) {
|
||||
t.Fatalf("expected %d providers, got %d", len(expected), len(providers))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -94,6 +94,22 @@ func ResolveEndpointWithModelOverride(configPath, modelOverride string) (Resolve
|
|||
if ok {
|
||||
ep.Timeout = envTimeout
|
||||
}
|
||||
// OCR_LLM_EXTRA_HEADERS is a global override: merges into
|
||||
// extra headers regardless of which strategy resolved the
|
||||
// endpoint. Env values take precedence over config-file values.
|
||||
if raw := os.Getenv(envOCRLLMExtraHeaders); raw != "" {
|
||||
envHeaders, err := ParseExtraHeaders(raw)
|
||||
if err != nil {
|
||||
return ResolvedEndpoint{}, fmt.Errorf("resolve %s: %w", s.name, err)
|
||||
}
|
||||
if ep.ExtraHeaders == nil {
|
||||
ep.ExtraHeaders = envHeaders
|
||||
} else {
|
||||
for k, v := range envHeaders {
|
||||
ep.ExtraHeaders[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
return ep, nil
|
||||
}
|
||||
}
|
||||
|
|
@ -173,16 +189,7 @@ func tryOCREnv(modelOverride string) (ResolvedEndpoint, bool, error) {
|
|||
}
|
||||
}
|
||||
|
||||
var extraHeaders map[string]string
|
||||
if extraHeadersRaw := os.Getenv(envOCRLLMExtraHeaders); extraHeadersRaw != "" {
|
||||
var err error
|
||||
extraHeaders, err = ParseExtraHeaders(extraHeadersRaw)
|
||||
if err != nil {
|
||||
return ResolvedEndpoint{}, false, fmt.Errorf("OCR environment: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return ResolvedEndpoint{URL: url, Token: token, Model: model, Protocol: protocol, AuthHeader: authHeader, Source: "OCR environment", ExtraHeaders: extraHeaders}, true, nil
|
||||
return ResolvedEndpoint{URL: url, Token: token, Model: model, Protocol: protocol, AuthHeader: authHeader, Source: "OCR environment"}, true, nil
|
||||
}
|
||||
|
||||
// llmFileConfig represents the llm section in config.json.
|
||||
|
|
@ -311,7 +318,7 @@ func tryProviderConfig(cfg configFile, modelOverride string) (ResolvedEndpoint,
|
|||
// Apply model override with validation.
|
||||
if modelOverride != "" {
|
||||
if len(availableModels) > 0 {
|
||||
if !modelListContains(availableModels, modelOverride) {
|
||||
if !ModelListContains(availableModels, modelOverride) {
|
||||
return ResolvedEndpoint{}, false, fmt.Errorf(
|
||||
"model %q is not available for provider %q; available models: %s",
|
||||
modelOverride,
|
||||
|
|
@ -524,8 +531,8 @@ func defaultAuthHeader(protocol string) string {
|
|||
return ""
|
||||
}
|
||||
|
||||
// modelListContains checks if a model exists in the available models list.
|
||||
func modelListContains(models []string, target string) bool {
|
||||
// ModelListContains reports whether target matches any entry in models (trimmed).
|
||||
func ModelListContains(models []string, target string) bool {
|
||||
target = strings.TrimSpace(target)
|
||||
for _, model := range models {
|
||||
if strings.TrimSpace(model) == target {
|
||||
|
|
|
|||
|
|
@ -104,7 +104,9 @@ func TestResolveEndpoint_ConfigFileStripsModelSuffix(t *testing.T) {
|
|||
}
|
||||
data, _ := json.Marshal(cfg)
|
||||
cfgPath := filepath.Join(t.TempDir(), "config.json")
|
||||
os.WriteFile(cfgPath, data, 0644)
|
||||
if err := os.WriteFile(cfgPath, data, 0644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
ep, err := ResolveEndpoint(cfgPath)
|
||||
if err != nil {
|
||||
|
|
@ -137,7 +139,9 @@ func TestResolveEndpoint_ConfigAnthropicDefaultsToAuthorization(t *testing.T) {
|
|||
}
|
||||
data, _ := json.Marshal(cfg)
|
||||
cfgPath := filepath.Join(t.TempDir(), "config.json")
|
||||
os.WriteFile(cfgPath, data, 0644)
|
||||
if err := os.WriteFile(cfgPath, data, 0644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
ep, err := ResolveEndpoint(cfgPath)
|
||||
if err != nil {
|
||||
|
|
@ -168,7 +172,9 @@ func TestResolveEndpoint_ConfigAuthHeaderOverrideToXAPIKey(t *testing.T) {
|
|||
}
|
||||
data, _ := json.Marshal(cfg)
|
||||
cfgPath := filepath.Join(t.TempDir(), "config.json")
|
||||
os.WriteFile(cfgPath, data, 0644)
|
||||
if err := os.WriteFile(cfgPath, data, 0644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
ep, err := ResolveEndpoint(cfgPath)
|
||||
if err != nil {
|
||||
|
|
@ -199,7 +205,9 @@ func TestResolveEndpoint_ConfigOpenAIIgnoresAuthHeader(t *testing.T) {
|
|||
}
|
||||
data, _ := json.Marshal(cfg)
|
||||
cfgPath := filepath.Join(t.TempDir(), "config.json")
|
||||
os.WriteFile(cfgPath, data, 0644)
|
||||
if err := os.WriteFile(cfgPath, data, 0644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
ep, err := ResolveEndpoint(cfgPath)
|
||||
if err != nil {
|
||||
|
|
@ -271,7 +279,9 @@ func TestResolveEndpoint_ProviderAnthropic(t *testing.T) {
|
|||
}
|
||||
data, _ := json.Marshal(cfg)
|
||||
cfgPath := filepath.Join(t.TempDir(), "config.json")
|
||||
os.WriteFile(cfgPath, data, 0644)
|
||||
if err := os.WriteFile(cfgPath, data, 0644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
ep, err := ResolveEndpoint(cfgPath)
|
||||
if err != nil {
|
||||
|
|
@ -305,7 +315,9 @@ func TestResolveEndpoint_ProviderOpenAI(t *testing.T) {
|
|||
}
|
||||
data, _ := json.Marshal(cfg)
|
||||
cfgPath := filepath.Join(t.TempDir(), "config.json")
|
||||
os.WriteFile(cfgPath, data, 0644)
|
||||
if err := os.WriteFile(cfgPath, data, 0644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
ep, err := ResolveEndpoint(cfgPath)
|
||||
if err != nil {
|
||||
|
|
@ -334,7 +346,9 @@ func TestResolveEndpoint_ProviderModelOverride(t *testing.T) {
|
|||
}
|
||||
data, _ := json.Marshal(cfg)
|
||||
cfgPath := filepath.Join(t.TempDir(), "config.json")
|
||||
os.WriteFile(cfgPath, data, 0644)
|
||||
if err := os.WriteFile(cfgPath, data, 0644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
ep, err := ResolveEndpoint(cfgPath)
|
||||
if err != nil {
|
||||
|
|
@ -356,7 +370,9 @@ func TestResolveEndpoint_ProviderEntryModelOverridesDefault(t *testing.T) {
|
|||
}
|
||||
data, _ := json.Marshal(cfg)
|
||||
cfgPath := filepath.Join(t.TempDir(), "config.json")
|
||||
os.WriteFile(cfgPath, data, 0644)
|
||||
if err := os.WriteFile(cfgPath, data, 0644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
ep, err := ResolveEndpoint(cfgPath)
|
||||
if err != nil {
|
||||
|
|
@ -383,7 +399,9 @@ func TestResolveEndpointWithModelOverride_CustomProviderWithoutConfiguredModel(t
|
|||
}
|
||||
data, _ := json.Marshal(cfg)
|
||||
cfgPath := filepath.Join(t.TempDir(), "config.json")
|
||||
os.WriteFile(cfgPath, data, 0644)
|
||||
if err := os.WriteFile(cfgPath, data, 0644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
ep, err := ResolveEndpointWithModelOverride(cfgPath, "llama-3-8b")
|
||||
if err != nil {
|
||||
|
|
@ -409,7 +427,9 @@ func TestResolveEndpoint_ProviderAPIKeyEnvFallback(t *testing.T) {
|
|||
}
|
||||
data, _ := json.Marshal(cfg)
|
||||
cfgPath := filepath.Join(t.TempDir(), "config.json")
|
||||
os.WriteFile(cfgPath, data, 0644)
|
||||
if err := os.WriteFile(cfgPath, data, 0644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
ep, err := ResolveEndpoint(cfgPath)
|
||||
if err != nil {
|
||||
|
|
@ -431,7 +451,9 @@ func TestResolveEndpoint_ProviderMissingAPIKey(t *testing.T) {
|
|||
}
|
||||
data, _ := json.Marshal(cfg)
|
||||
cfgPath := filepath.Join(t.TempDir(), "config.json")
|
||||
os.WriteFile(cfgPath, data, 0644)
|
||||
if err := os.WriteFile(cfgPath, data, 0644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
_, err := ResolveEndpoint(cfgPath)
|
||||
if err == nil {
|
||||
|
|
@ -448,7 +470,9 @@ func TestResolveEndpoint_ProviderNotConfigured(t *testing.T) {
|
|||
}
|
||||
data, _ := json.Marshal(cfg)
|
||||
cfgPath := filepath.Join(t.TempDir(), "config.json")
|
||||
os.WriteFile(cfgPath, data, 0644)
|
||||
if err := os.WriteFile(cfgPath, data, 0644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
_, err := ResolveEndpoint(cfgPath)
|
||||
if err == nil {
|
||||
|
|
@ -472,7 +496,9 @@ func TestResolveEndpoint_CustomProvider(t *testing.T) {
|
|||
}
|
||||
data, _ := json.Marshal(cfg)
|
||||
cfgPath := filepath.Join(t.TempDir(), "config.json")
|
||||
os.WriteFile(cfgPath, data, 0644)
|
||||
if err := os.WriteFile(cfgPath, data, 0644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
ep, err := ResolveEndpoint(cfgPath)
|
||||
if err != nil {
|
||||
|
|
@ -508,7 +534,9 @@ func TestResolveEndpoint_CustomProviderInvalidProtocol(t *testing.T) {
|
|||
}
|
||||
data, _ := json.Marshal(cfg)
|
||||
cfgPath := filepath.Join(t.TempDir(), "config.json")
|
||||
os.WriteFile(cfgPath, data, 0644)
|
||||
if err := os.WriteFile(cfgPath, data, 0644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
_, err := ResolveEndpoint(cfgPath)
|
||||
if err == nil {
|
||||
|
|
@ -531,7 +559,9 @@ func TestResolveEndpoint_CustomProviderMissingFields(t *testing.T) {
|
|||
}
|
||||
data, _ := json.Marshal(cfg)
|
||||
cfgPath := filepath.Join(t.TempDir(), "config.json")
|
||||
os.WriteFile(cfgPath, data, 0644)
|
||||
if err := os.WriteFile(cfgPath, data, 0644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
_, err := ResolveEndpoint(cfgPath)
|
||||
if err == nil {
|
||||
|
|
@ -555,7 +585,9 @@ func TestResolveEndpoint_CustomProviderModelFromTopLevel(t *testing.T) {
|
|||
}
|
||||
data, _ := json.Marshal(cfg)
|
||||
cfgPath := filepath.Join(t.TempDir(), "config.json")
|
||||
os.WriteFile(cfgPath, data, 0644)
|
||||
if err := os.WriteFile(cfgPath, data, 0644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
ep, err := ResolveEndpoint(cfgPath)
|
||||
if err != nil {
|
||||
|
|
@ -578,7 +610,9 @@ func TestResolveEndpoint_LegacyLlmStillWorks(t *testing.T) {
|
|||
}
|
||||
data, _ := json.Marshal(cfg)
|
||||
cfgPath := filepath.Join(t.TempDir(), "config.json")
|
||||
os.WriteFile(cfgPath, data, 0644)
|
||||
if err := os.WriteFile(cfgPath, data, 0644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
ep, err := ResolveEndpoint(cfgPath)
|
||||
if err != nil {
|
||||
|
|
@ -603,7 +637,9 @@ func TestResolveEndpoint_ProviderAnthropicURLHasMessagesSuffix(t *testing.T) {
|
|||
}
|
||||
data, _ := json.Marshal(cfg)
|
||||
cfgPath := filepath.Join(t.TempDir(), "config.json")
|
||||
os.WriteFile(cfgPath, data, 0644)
|
||||
if err := os.WriteFile(cfgPath, data, 0644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
ep, err := ResolveEndpoint(cfgPath)
|
||||
if err != nil {
|
||||
|
|
@ -629,7 +665,9 @@ func TestResolveEndpoint_ProviderExtraBody(t *testing.T) {
|
|||
}
|
||||
data, _ := json.Marshal(cfg)
|
||||
cfgPath := filepath.Join(t.TempDir(), "config.json")
|
||||
os.WriteFile(cfgPath, data, 0644)
|
||||
if err := os.WriteFile(cfgPath, data, 0644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
ep, err := ResolveEndpoint(cfgPath)
|
||||
if err != nil {
|
||||
|
|
@ -654,7 +692,9 @@ func TestResolveEndpointWithModelOverride_ValidModelInPresetList(t *testing.T) {
|
|||
}
|
||||
data, _ := json.Marshal(cfg)
|
||||
cfgPath := filepath.Join(t.TempDir(), "config.json")
|
||||
os.WriteFile(cfgPath, data, 0644)
|
||||
if err := os.WriteFile(cfgPath, data, 0644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
ep, err := ResolveEndpointWithModelOverride(cfgPath, "claude-opus-4-8")
|
||||
if err != nil {
|
||||
|
|
@ -676,7 +716,9 @@ func TestResolveEndpointWithModelOverride_InvalidModelInPresetList(t *testing.T)
|
|||
}
|
||||
data, _ := json.Marshal(cfg)
|
||||
cfgPath := filepath.Join(t.TempDir(), "config.json")
|
||||
os.WriteFile(cfgPath, data, 0644)
|
||||
if err := os.WriteFile(cfgPath, data, 0644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
_, err := ResolveEndpointWithModelOverride(cfgPath, "claude-opsu-4-6")
|
||||
if err == nil {
|
||||
|
|
@ -706,7 +748,9 @@ func TestResolveEndpointWithModelOverride_ValidModelInCustomProviderList(t *test
|
|||
}
|
||||
data, _ := json.Marshal(cfg)
|
||||
cfgPath := filepath.Join(t.TempDir(), "config.json")
|
||||
os.WriteFile(cfgPath, data, 0644)
|
||||
if err := os.WriteFile(cfgPath, data, 0644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
ep, err := ResolveEndpointWithModelOverride(cfgPath, "llama-3-8b")
|
||||
if err != nil {
|
||||
|
|
@ -733,7 +777,9 @@ func TestResolveEndpointWithModelOverride_InvalidModelInCustomProviderList(t *te
|
|||
}
|
||||
data, _ := json.Marshal(cfg)
|
||||
cfgPath := filepath.Join(t.TempDir(), "config.json")
|
||||
os.WriteFile(cfgPath, data, 0644)
|
||||
if err := os.WriteFile(cfgPath, data, 0644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
_, err := ResolveEndpointWithModelOverride(cfgPath, "gpt-4")
|
||||
if err == nil {
|
||||
|
|
@ -760,7 +806,9 @@ func TestResolveEndpointWithModelOverride_NoValidationWhenNoModelList(t *testing
|
|||
}
|
||||
data, _ := json.Marshal(cfg)
|
||||
cfgPath := filepath.Join(t.TempDir(), "config.json")
|
||||
os.WriteFile(cfgPath, data, 0644)
|
||||
if err := os.WriteFile(cfgPath, data, 0644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
ep, err := ResolveEndpointWithModelOverride(cfgPath, "any-model-name")
|
||||
if err != nil {
|
||||
|
|
@ -785,7 +833,9 @@ func TestResolveEndpointWithModelOverride_MergesPresetAndEntryModels(t *testing.
|
|||
}
|
||||
data, _ := json.Marshal(cfg)
|
||||
cfgPath := filepath.Join(t.TempDir(), "config.json")
|
||||
os.WriteFile(cfgPath, data, 0644)
|
||||
if err := os.WriteFile(cfgPath, data, 0644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
// Should accept both preset models and entry models.
|
||||
ep1, err := ResolveEndpointWithModelOverride(cfgPath, "claude-opus-4-8")
|
||||
|
|
@ -823,7 +873,9 @@ func TestResolveEndpointWithModelOverride_LegacyConfigNoValidation(t *testing.T)
|
|||
}
|
||||
data, _ := json.Marshal(cfg)
|
||||
cfgPath := filepath.Join(t.TempDir(), "config.json")
|
||||
os.WriteFile(cfgPath, data, 0644)
|
||||
if err := os.WriteFile(cfgPath, data, 0644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
// Legacy config has no model list, so any override should be accepted.
|
||||
ep, err := ResolveEndpointWithModelOverride(cfgPath, "any-override-model")
|
||||
|
|
@ -1087,7 +1139,9 @@ func TestResolveEndpoint_ProviderExtraHeaders(t *testing.T) {
|
|||
}
|
||||
data, _ := json.Marshal(cfg)
|
||||
cfgPath := filepath.Join(t.TempDir(), "config.json")
|
||||
os.WriteFile(cfgPath, data, 0644)
|
||||
if err := os.WriteFile(cfgPath, data, 0644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
ep, err := ResolveEndpoint(cfgPath)
|
||||
if err != nil {
|
||||
|
|
@ -1101,6 +1155,41 @@ func TestResolveEndpoint_ProviderExtraHeaders(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestResolveEndpoint_EnvExtraHeadersMergedWithConfigFile(t *testing.T) {
|
||||
clearAllEnv(t)
|
||||
t.Setenv("OCR_LLM_EXTRA_HEADERS", "EagleEye-TraceId=trace-from-env,X-New=from-env")
|
||||
|
||||
cfg := configFile{
|
||||
Provider: "anthropic",
|
||||
Providers: map[string]providerEntryConfig{
|
||||
"anthropic": {
|
||||
APIKey: "sk-ant-test",
|
||||
Model: "claude-sonnet-4-6",
|
||||
ExtraHeaders: map[string]string{"X-Org-ID": "org-123"},
|
||||
},
|
||||
},
|
||||
}
|
||||
data, _ := json.Marshal(cfg)
|
||||
cfgPath := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(cfgPath, data, 0644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
ep, err := ResolveEndpoint(cfgPath)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if v, ok := ep.ExtraHeaders["X-Org-ID"]; !ok || v != "org-123" {
|
||||
t.Errorf("config file header preserved: ExtraHeaders[\"X-Org-ID\"] = %q, want %q", v, "org-123")
|
||||
}
|
||||
if v, ok := ep.ExtraHeaders["EagleEye-TraceId"]; !ok || v != "trace-from-env" {
|
||||
t.Errorf("env header merged: ExtraHeaders[\"EagleEye-TraceId\"] = %q, want %q", v, "trace-from-env")
|
||||
}
|
||||
if v, ok := ep.ExtraHeaders["X-New"]; !ok || v != "from-env" {
|
||||
t.Errorf("env header merged: ExtraHeaders[\"X-New\"] = %q, want %q", v, "from-env")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveEndpoint_LegacyLlmExtraHeaders(t *testing.T) {
|
||||
clearAllEnv(t)
|
||||
|
||||
|
|
@ -1114,7 +1203,9 @@ func TestResolveEndpoint_LegacyLlmExtraHeaders(t *testing.T) {
|
|||
}
|
||||
data, _ := json.Marshal(cfg)
|
||||
cfgPath := filepath.Join(t.TempDir(), "config.json")
|
||||
os.WriteFile(cfgPath, data, 0644)
|
||||
if err := os.WriteFile(cfgPath, data, 0644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
ep, err := ResolveEndpoint(cfgPath)
|
||||
if err != nil {
|
||||
|
|
@ -1218,7 +1309,9 @@ func TestResolveEndpoint_ConfigTimeoutSec(t *testing.T) {
|
|||
}
|
||||
data, _ := json.Marshal(cfg)
|
||||
cfgPath := filepath.Join(t.TempDir(), "config.json")
|
||||
os.WriteFile(cfgPath, data, 0644)
|
||||
if err := os.WriteFile(cfgPath, data, 0644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
ep, err := ResolveEndpoint(cfgPath)
|
||||
if err != nil {
|
||||
|
|
@ -1242,7 +1335,9 @@ func TestResolveEndpoint_NegativeConfigTimeoutSec(t *testing.T) {
|
|||
}
|
||||
data, _ := json.Marshal(cfg)
|
||||
cfgPath := filepath.Join(t.TempDir(), "config.json")
|
||||
os.WriteFile(cfgPath, data, 0644)
|
||||
if err := os.WriteFile(cfgPath, data, 0644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
_, err := ResolveEndpoint(cfgPath)
|
||||
if err == nil {
|
||||
|
|
@ -1308,7 +1403,9 @@ func TestResolveEndpoint_ProviderConfigTimeoutSec(t *testing.T) {
|
|||
}
|
||||
data, _ := json.Marshal(cfg)
|
||||
cfgPath := filepath.Join(t.TempDir(), "config.json")
|
||||
os.WriteFile(cfgPath, data, 0644)
|
||||
if err := os.WriteFile(cfgPath, data, 0644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
ep, err := ResolveEndpoint(cfgPath)
|
||||
if err != nil {
|
||||
|
|
@ -1334,7 +1431,9 @@ func TestResolveEndpoint_ProviderConfigNegativeTimeoutSec(t *testing.T) {
|
|||
}
|
||||
data, _ := json.Marshal(cfg)
|
||||
cfgPath := filepath.Join(t.TempDir(), "config.json")
|
||||
os.WriteFile(cfgPath, data, 0644)
|
||||
if err := os.WriteFile(cfgPath, data, 0644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
_, err := ResolveEndpoint(cfgPath)
|
||||
if err == nil {
|
||||
|
|
@ -1359,7 +1458,9 @@ func TestResolveEndpoint_EnvTimeoutOverridesConfigTimeout(t *testing.T) {
|
|||
}
|
||||
data, _ := json.Marshal(cfg)
|
||||
cfgPath := filepath.Join(t.TempDir(), "config.json")
|
||||
os.WriteFile(cfgPath, data, 0644)
|
||||
if err := os.WriteFile(cfgPath, data, 0644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
ep, err := ResolveEndpoint(cfgPath)
|
||||
if err != nil {
|
||||
|
|
@ -1387,7 +1488,9 @@ func TestResolveEndpoint_EnvTimeoutOverridesProviderTimeout(t *testing.T) {
|
|||
}
|
||||
data, _ := json.Marshal(cfg)
|
||||
cfgPath := filepath.Join(t.TempDir(), "config.json")
|
||||
os.WriteFile(cfgPath, data, 0644)
|
||||
if err := os.WriteFile(cfgPath, data, 0644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
ep, err := ResolveEndpoint(cfgPath)
|
||||
if err != nil {
|
||||
|
|
@ -1412,7 +1515,9 @@ func TestResolveEndpoint_InvalidEnvTimeoutWithConfig(t *testing.T) {
|
|||
}
|
||||
data, _ := json.Marshal(cfg)
|
||||
cfgPath := filepath.Join(t.TempDir(), "config.json")
|
||||
os.WriteFile(cfgPath, data, 0644)
|
||||
if err := os.WriteFile(cfgPath, data, 0644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
_, err := ResolveEndpoint(cfgPath)
|
||||
if err == nil {
|
||||
|
|
@ -1436,7 +1541,9 @@ func TestResolveEndpoint_NegativeEnvTimeoutWithConfig(t *testing.T) {
|
|||
}
|
||||
data, _ := json.Marshal(cfg)
|
||||
cfgPath := filepath.Join(t.TempDir(), "config.json")
|
||||
os.WriteFile(cfgPath, data, 0644)
|
||||
if err := os.WriteFile(cfgPath, data, 0644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
_, err := ResolveEndpoint(cfgPath)
|
||||
if err == nil {
|
||||
|
|
|
|||
|
|
@ -27,24 +27,37 @@ var completionTokensPaths = []string{
|
|||
}
|
||||
|
||||
var cacheReadTokensPaths = []string{
|
||||
"usage.cache_read_input_tokens", // Anthropic
|
||||
"cache_read_input_tokens", // flat at root
|
||||
"usage.prompt_tokens_details.cache_tokens_hit", // some providers
|
||||
"usage.prompt_tokens_details.cache_tokens", // some providers
|
||||
"usage.cache_read_input_tokens", // Anthropic
|
||||
"cache_read_input_tokens", // flat at root
|
||||
"data.usage.cache_read_input_tokens", // wrapped Anthropic-compatible proxy
|
||||
"usage.prompt_tokens_details.cached_tokens", // OpenAI-compatible providers
|
||||
"data.usage.prompt_tokens_details.cached_tokens", // wrapped OpenAI-compatible providers
|
||||
}
|
||||
|
||||
var cacheWriteTokensPaths = []string{
|
||||
"usage.cache_creation_input_tokens", // Anthropic / proxy
|
||||
"cache_creation_input_tokens", // flat at root
|
||||
"usage.cache_creation_input_tokens", // Anthropic / proxy
|
||||
"cache_creation_input_tokens", // flat at root
|
||||
"data.usage.cache_creation_input_tokens", // wrapped Anthropic-compatible proxy
|
||||
"usage.prompt_tokens_details.cache_creation_tokens", // ApexRoute / LLM Gateway — proxy normalization of Anthropic cache_creation_input_tokens
|
||||
"data.usage.prompt_tokens_details.cache_creation_tokens", // wrapped proxy normalization
|
||||
}
|
||||
|
||||
// anthropicCacheReadPathCount is the number of Anthropic-style cache read paths
|
||||
// at the start of cacheReadTokensPaths. OpenAI-style paths follow; under OpenAI
|
||||
// semantics cached tokens are already included in prompt_tokens.
|
||||
const anthropicCacheReadPathCount = 3
|
||||
|
||||
// anthropicCacheWritePathCount is the number of Anthropic-style cache write paths
|
||||
// at the start of cacheWriteTokensPaths.
|
||||
const anthropicCacheWritePathCount = 3
|
||||
|
||||
// totalTokensPaths is an ordered list of JSON paths to try when extracting
|
||||
// total token count from a response body. Paths are dot-separated keys that
|
||||
// navigate through nested map[string]any objects. The first match wins.
|
||||
var totalTokensPaths = []string{
|
||||
"usage.total_tokens", // OpenAI standard
|
||||
"total_tokens", // flat at root
|
||||
"data.usage.total_tokens", // wrapped in data layer (some proxy APIs)
|
||||
"data.usage.total_tokens", // wrapped in data layer
|
||||
}
|
||||
|
||||
// resolveUsage parses raw JSON bytes into a map and extracts token usage
|
||||
|
|
@ -58,8 +71,8 @@ func resolveUsage(raw []byte) *UsageInfo {
|
|||
total, hasAny := probePath(rawBody, totalTokensPaths)
|
||||
prompt, _ := probePath(rawBody, promptTokensPaths)
|
||||
completion, _ := probePath(rawBody, completionTokensPaths)
|
||||
cacheRead, _ := probePath(rawBody, cacheReadTokensPaths)
|
||||
cacheWrite, _ := probePath(rawBody, cacheWriteTokensPaths)
|
||||
cacheRead, cacheReadIdx, _ := probePathIndex(rawBody, cacheReadTokensPaths)
|
||||
cacheWrite, cacheWriteIdx, _ := probePathIndex(rawBody, cacheWriteTokensPaths)
|
||||
|
||||
if !hasAny && prompt == 0 && completion == 0 {
|
||||
return nil
|
||||
|
|
@ -74,8 +87,17 @@ func resolveUsage(raw []byte) *UsageInfo {
|
|||
}
|
||||
|
||||
// If TotalTokens wasn't explicitly available but we have prompt+completion, compute it.
|
||||
// Anthropic reports cache tokens separately from input_tokens, so include them in the
|
||||
// fallback total. OpenAI prompt_tokens already includes cached_tokens, so only add cache
|
||||
// counts when they came from Anthropic-style top-level fields.
|
||||
if total == 0 && (prompt > 0 || completion > 0) {
|
||||
ui.TotalTokens = prompt + completion + cacheRead + cacheWrite
|
||||
ui.TotalTokens = prompt + completion
|
||||
if cacheReadIdx >= 0 && cacheReadIdx < anthropicCacheReadPathCount {
|
||||
ui.TotalTokens += cacheRead
|
||||
}
|
||||
if cacheWriteIdx >= 0 && cacheWriteIdx < anthropicCacheWritePathCount {
|
||||
ui.TotalTokens += cacheWrite
|
||||
}
|
||||
}
|
||||
|
||||
return ui
|
||||
|
|
@ -84,7 +106,13 @@ func resolveUsage(raw []byte) *UsageInfo {
|
|||
// probePath walks through each candidate path in order, returning the first
|
||||
// int64 value found along with true. Returns (0, false) if none match.
|
||||
func probePath(root map[string]any, paths []string) (int64, bool) {
|
||||
for _, p := range paths {
|
||||
v, _, ok := probePathIndex(root, paths)
|
||||
return v, ok
|
||||
}
|
||||
|
||||
// probePathIndex is like probePath but also returns the index of the matched path.
|
||||
func probePathIndex(root map[string]any, paths []string) (int64, int, bool) {
|
||||
for i, p := range paths {
|
||||
parts := strings.Split(p, ".")
|
||||
|
||||
var current any = root
|
||||
|
|
@ -101,13 +129,13 @@ func probePath(root map[string]any, paths []string) (int64, bool) {
|
|||
|
||||
switch v := current.(type) {
|
||||
case float64:
|
||||
return int64(v), true
|
||||
return int64(v), i, true
|
||||
case int64:
|
||||
return v, true
|
||||
return v, i, true
|
||||
case int:
|
||||
return int64(v), true
|
||||
return int64(v), i, true
|
||||
}
|
||||
next:
|
||||
}
|
||||
return 0, false
|
||||
return 0, -1, false
|
||||
}
|
||||
|
|
|
|||
123
internal/llm/usage_resolver_test.go
Normal file
123
internal/llm/usage_resolver_test.go
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
package llm
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestResolveUsageOpenAICompatibleCachedTokens(t *testing.T) {
|
||||
usage := resolveUsage([]byte(`{
|
||||
"usage": {
|
||||
"prompt_tokens": 100,
|
||||
"completion_tokens": 20,
|
||||
"total_tokens": 120,
|
||||
"prompt_tokens_details": {
|
||||
"cached_tokens": 75
|
||||
}
|
||||
}
|
||||
}`))
|
||||
|
||||
if usage == nil {
|
||||
t.Fatal("resolveUsage returned nil")
|
||||
}
|
||||
if usage.CacheReadTokens != 75 {
|
||||
t.Errorf("CacheReadTokens = %d, want 75", usage.CacheReadTokens)
|
||||
}
|
||||
if usage.PromptTokens != 100 {
|
||||
t.Errorf("PromptTokens = %d, want 100", usage.PromptTokens)
|
||||
}
|
||||
if usage.CompletionTokens != 20 {
|
||||
t.Errorf("CompletionTokens = %d, want 20", usage.CompletionTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveUsageWrappedCachedTokens(t *testing.T) {
|
||||
usage := resolveUsage([]byte(`{
|
||||
"data": {
|
||||
"usage": {
|
||||
"prompt_tokens": 100,
|
||||
"completion_tokens": 20,
|
||||
"prompt_tokens_details": {
|
||||
"cached_tokens": 75,
|
||||
"cache_creation_tokens": 10
|
||||
}
|
||||
}
|
||||
}
|
||||
}`))
|
||||
|
||||
if usage == nil {
|
||||
t.Fatal("resolveUsage returned nil")
|
||||
}
|
||||
if usage.CacheReadTokens != 75 {
|
||||
t.Errorf("CacheReadTokens = %d, want 75", usage.CacheReadTokens)
|
||||
}
|
||||
if usage.CacheWriteTokens != 10 {
|
||||
t.Errorf("CacheWriteTokens = %d, want 10", usage.CacheWriteTokens)
|
||||
}
|
||||
if usage.TotalTokens != 120 {
|
||||
t.Errorf("TotalTokens = %d, want 120 (OpenAI cached tokens are included in prompt_tokens)", usage.TotalTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveUsageWrappedAnthropicCompatibleCacheTokens(t *testing.T) {
|
||||
usage := resolveUsage([]byte(`{
|
||||
"data": {
|
||||
"usage": {
|
||||
"prompt_tokens": 100,
|
||||
"completion_tokens": 20,
|
||||
"cache_read_input_tokens": 40,
|
||||
"cache_creation_input_tokens": 15
|
||||
}
|
||||
}
|
||||
}`))
|
||||
|
||||
if usage == nil {
|
||||
t.Fatal("resolveUsage returned nil")
|
||||
}
|
||||
if usage.CacheReadTokens != 40 {
|
||||
t.Errorf("CacheReadTokens = %d, want 40", usage.CacheReadTokens)
|
||||
}
|
||||
if usage.CacheWriteTokens != 15 {
|
||||
t.Errorf("CacheWriteTokens = %d, want 15", usage.CacheWriteTokens)
|
||||
}
|
||||
if usage.TotalTokens != 175 {
|
||||
t.Errorf("TotalTokens = %d, want 175", usage.TotalTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveUsageCacheReadPathPriority(t *testing.T) {
|
||||
usage := resolveUsage([]byte(`{
|
||||
"usage": {
|
||||
"prompt_tokens": 100,
|
||||
"completion_tokens": 20,
|
||||
"cache_read_input_tokens": 40,
|
||||
"prompt_tokens_details": {
|
||||
"cached_tokens": 75
|
||||
}
|
||||
}
|
||||
}`))
|
||||
|
||||
if usage == nil {
|
||||
t.Fatal("resolveUsage returned nil")
|
||||
}
|
||||
if usage.CacheReadTokens != 40 {
|
||||
t.Errorf("CacheReadTokens = %d, want 40 (Anthropic path should win)", usage.CacheReadTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveUsageCacheCreationTokensPriority(t *testing.T) {
|
||||
usage := resolveUsage([]byte(`{
|
||||
"usage": {
|
||||
"prompt_tokens": 100,
|
||||
"completion_tokens": 20,
|
||||
"cache_creation_input_tokens": 30,
|
||||
"prompt_tokens_details": {
|
||||
"cache_creation_tokens": 15
|
||||
}
|
||||
}
|
||||
}`))
|
||||
|
||||
if usage == nil {
|
||||
t.Fatal("resolveUsage returned nil")
|
||||
}
|
||||
if usage.CacheWriteTokens != 30 {
|
||||
t.Errorf("CacheWriteTokens = %d, want 30 (Anthropic top-level path should win over prompt_tokens_details)", usage.CacheWriteTokens)
|
||||
}
|
||||
}
|
||||
|
|
@ -144,8 +144,9 @@ func (r *Runner) CollectPendingComments() []model.LlmComment {
|
|||
// It sends messages with the configured tool definitions, executes any
|
||||
// tool calls returned by the model, and collects review comments until
|
||||
// task_done is called or limits are reached. Token usage and warnings
|
||||
// are aggregated on the Runner across all files.
|
||||
func (r *Runner) RunPerFile(ctx context.Context, messages []llm.Message, newPath string) error {
|
||||
// are aggregated on the Runner across all files. The returned bool is true
|
||||
// only when the model explicitly calls task_done.
|
||||
func (r *Runner) RunPerFile(ctx context.Context, messages []llm.Message, newPath string) (bool, error) {
|
||||
toolReqCount := r.deps.Template.MaxToolRequestTimes
|
||||
const maxConsecutiveEmptyRounds = 3
|
||||
consecutiveEmptyRounds := 0
|
||||
|
|
@ -153,7 +154,7 @@ func (r *Runner) RunPerFile(ctx context.Context, messages []llm.Message, newPath
|
|||
for toolReqCount > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
return false, ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
|
|
@ -163,6 +164,7 @@ func (r *Runner) RunPerFile(ctx context.Context, messages []llm.Message, newPath
|
|||
rec := fs.AppendTaskRecord(session.MainTask, append([]llm.Message(nil), messages...))
|
||||
startTime := time.Now()
|
||||
|
||||
_, llmSpan := telemetry.StartLLMSpan(ctx, r.deps.Model)
|
||||
resp, err := r.deps.LLMClient.CompletionsWithCtx(ctx, llm.ChatRequest{
|
||||
Model: r.deps.Model,
|
||||
Messages: messages,
|
||||
|
|
@ -172,8 +174,10 @@ func (r *Runner) RunPerFile(ctx context.Context, messages []llm.Message, newPath
|
|||
duration := time.Since(startTime)
|
||||
if err != nil {
|
||||
rec.SetError(err, duration)
|
||||
telemetry.RecordLLMResult(llmSpan, duration, 0, err)
|
||||
llmSpan.End()
|
||||
telemetry.RecordLLMRequest(ctx, r.deps.Model, duration, 0, "error")
|
||||
return fmt.Errorf("LLM completion error: %w", err)
|
||||
return false, fmt.Errorf("LLM completion error: %w", err)
|
||||
}
|
||||
rec.SetResponse(resp, duration)
|
||||
totalTokens := int64(0)
|
||||
|
|
@ -184,6 +188,8 @@ func (r *Runner) RunPerFile(ctx context.Context, messages []llm.Message, newPath
|
|||
atomic.AddInt64(&r.totalCacheReadTokens, resp.Usage.CacheReadTokens)
|
||||
atomic.AddInt64(&r.totalCacheWriteTokens, resp.Usage.CacheWriteTokens)
|
||||
}
|
||||
telemetry.RecordLLMResult(llmSpan, duration, totalTokens, nil)
|
||||
llmSpan.End()
|
||||
telemetry.RecordLLMRequest(ctx, r.deps.Model, duration, totalTokens, "ok")
|
||||
|
||||
content := resp.Content()
|
||||
|
|
@ -228,7 +234,7 @@ func (r *Runner) RunPerFile(ctx context.Context, messages []llm.Message, newPath
|
|||
}
|
||||
|
||||
if taskCompleted {
|
||||
break
|
||||
return true, nil
|
||||
}
|
||||
if !hasValidResult {
|
||||
consecutiveEmptyRounds++
|
||||
|
|
@ -251,7 +257,7 @@ func (r *Runner) RunPerFile(ctx context.Context, messages []llm.Message, newPath
|
|||
if toolReqCount <= 0 {
|
||||
fmt.Fprintf(stdout.Writer(), "[ocr] Max tool requests reached for %s.\n", newPath)
|
||||
}
|
||||
return nil
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// executeToolCall dispatches a single tool call from the LLM response and
|
||||
|
|
@ -272,14 +278,19 @@ func (r *Runner) executeToolCall(ctx context.Context, newPath string, call llm.T
|
|||
return tool.Of(fmt.Sprintf("Error parsing tool arguments for %s: %v", call.Function.Name, err))
|
||||
}
|
||||
telemetry.PrintToolCallStarted(call.Function.Name, dynArgs)
|
||||
_, toolSpan := telemetry.StartToolSpan(ctx, call.Function.Name)
|
||||
startTime := time.Now()
|
||||
result, err := p.Execute(ctx, dynArgs)
|
||||
dur := time.Since(startTime)
|
||||
if err != nil {
|
||||
telemetry.RecordToolResult(toolSpan, call.Function.Name, dur.Milliseconds(), err)
|
||||
toolSpan.End()
|
||||
telemetry.RecordToolCall(ctx, call.Function.Name, dur, false)
|
||||
telemetry.PrintToolCallError(call.Function.Name, err)
|
||||
return tool.Of(fmt.Sprintf("Error executing tool %s: %v", call.Function.Name, err))
|
||||
}
|
||||
telemetry.RecordToolResult(toolSpan, call.Function.Name, dur.Milliseconds(), nil)
|
||||
toolSpan.End()
|
||||
telemetry.RecordToolCall(ctx, call.Function.Name, dur, true)
|
||||
telemetry.PrintToolCallFinished(call.Function.Name, dur)
|
||||
if rec != nil {
|
||||
|
|
@ -314,10 +325,14 @@ func (r *Runner) executeToolCall(ctx context.Context, newPath string, call llm.T
|
|||
|
||||
if t == tool.CodeComment {
|
||||
telemetry.PrintToolCallStarted(t.Name(), args)
|
||||
_, toolSpan := telemetry.StartToolSpan(ctx, t.Name())
|
||||
|
||||
comments, errMsg := tool.ParseComments(args)
|
||||
if errMsg != "" {
|
||||
telemetry.RecordToolCall(ctx, t.Name(), time.Since(startTime), false)
|
||||
dur := time.Since(startTime)
|
||||
telemetry.RecordToolResult(toolSpan, t.Name(), dur.Milliseconds(), fmt.Errorf("%s", errMsg))
|
||||
toolSpan.End()
|
||||
telemetry.RecordToolCall(ctx, t.Name(), dur, false)
|
||||
return tool.Of(errMsg)
|
||||
}
|
||||
|
||||
|
|
@ -361,8 +376,13 @@ func (r *Runner) executeToolCall(ctx context.Context, newPath string, call llm.T
|
|||
asyncCtx := context.WithoutCancel(ctx)
|
||||
toolName := t.Name()
|
||||
pool.Submit(func() ([]model.LlmComment, error) {
|
||||
defer func() {
|
||||
dur := time.Since(startTime)
|
||||
telemetry.RecordToolResult(toolSpan, toolName, dur.Milliseconds(), nil)
|
||||
toolSpan.End()
|
||||
telemetry.PrintToolCallFinished(toolName, dur)
|
||||
}()
|
||||
resolveAndCollect(asyncCtx)
|
||||
telemetry.PrintToolCallFinished(toolName, time.Since(startTime))
|
||||
return []model.LlmComment{}, nil
|
||||
})
|
||||
telemetry.RecordToolCall(asyncCtx, toolName, time.Since(startTime), true)
|
||||
|
|
@ -371,6 +391,8 @@ func (r *Runner) executeToolCall(ctx context.Context, newPath string, call llm.T
|
|||
|
||||
resolveAndCollect(ctx)
|
||||
dur := time.Since(startTime)
|
||||
telemetry.RecordToolResult(toolSpan, t.Name(), dur.Milliseconds(), nil)
|
||||
toolSpan.End()
|
||||
telemetry.RecordToolCall(ctx, t.Name(), dur, true)
|
||||
telemetry.PrintToolCallFinished(t.Name(), dur)
|
||||
if rec != nil {
|
||||
|
|
@ -381,9 +403,12 @@ func (r *Runner) executeToolCall(ctx context.Context, newPath string, call llm.T
|
|||
|
||||
// Synchronous path for all other tools
|
||||
telemetry.PrintToolCallStarted(t.Name(), args)
|
||||
_, toolSpan := telemetry.StartToolSpan(ctx, t.Name())
|
||||
result, err := p.Execute(ctx, args)
|
||||
dur := time.Since(startTime)
|
||||
ok := err == nil
|
||||
telemetry.RecordToolResult(toolSpan, t.Name(), dur.Milliseconds(), err)
|
||||
toolSpan.End()
|
||||
telemetry.RecordToolCall(ctx, t.Name(), dur, ok)
|
||||
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -99,10 +99,13 @@ func TestRunPerFile_TaskDoneImmediately(t *testing.T) {
|
|||
runner := NewRunner(deps)
|
||||
|
||||
msgs := []llm.Message{llm.NewTextMessage("user", "review this file")}
|
||||
err := runner.RunPerFile(context.Background(), msgs, "main.go")
|
||||
completed, err := runner.RunPerFile(context.Background(), msgs, "main.go")
|
||||
if err != nil {
|
||||
t.Fatalf("RunPerFile: %v", err)
|
||||
}
|
||||
if !completed {
|
||||
t.Fatal("expected task_done to complete RunPerFile")
|
||||
}
|
||||
if client.calls != 1 {
|
||||
t.Errorf("expected 1 LLM call, got %d", client.calls)
|
||||
}
|
||||
|
|
@ -123,10 +126,13 @@ func TestRunPerFile_ToolCallThenDone(t *testing.T) {
|
|||
runner := NewRunner(deps)
|
||||
|
||||
msgs := []llm.Message{llm.NewTextMessage("user", "review")}
|
||||
err := runner.RunPerFile(context.Background(), msgs, "main.go")
|
||||
completed, err := runner.RunPerFile(context.Background(), msgs, "main.go")
|
||||
if err != nil {
|
||||
t.Fatalf("RunPerFile: %v", err)
|
||||
}
|
||||
if !completed {
|
||||
t.Fatal("expected task_done to complete RunPerFile")
|
||||
}
|
||||
if client.calls != 2 {
|
||||
t.Errorf("expected 2 LLM calls, got %d", client.calls)
|
||||
}
|
||||
|
|
@ -149,10 +155,13 @@ func TestRunPerFile_ContextCancelled(t *testing.T) {
|
|||
cancel()
|
||||
|
||||
msgs := []llm.Message{llm.NewTextMessage("user", "review")}
|
||||
err := runner.RunPerFile(ctx, msgs, "main.go")
|
||||
completed, err := runner.RunPerFile(ctx, msgs, "main.go")
|
||||
if err == nil {
|
||||
t.Error("expected error for cancelled context")
|
||||
}
|
||||
if completed {
|
||||
t.Fatal("cancelled context should not complete RunPerFile")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPerFile_UnknownTool(t *testing.T) {
|
||||
|
|
@ -179,15 +188,39 @@ func TestRunPerFile_UnknownTool(t *testing.T) {
|
|||
runner := NewRunner(deps)
|
||||
|
||||
msgs := []llm.Message{llm.NewTextMessage("user", "review")}
|
||||
err := runner.RunPerFile(context.Background(), msgs, "main.go")
|
||||
completed, err := runner.RunPerFile(context.Background(), msgs, "main.go")
|
||||
if err != nil {
|
||||
t.Fatalf("RunPerFile: %v", err)
|
||||
}
|
||||
if !completed {
|
||||
t.Fatal("expected task_done to complete RunPerFile")
|
||||
}
|
||||
if client.calls != 2 {
|
||||
t.Errorf("expected 2 calls, got %d", client.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPerFile_MaxToolRequestsWithoutTaskDoneDoesNotComplete(t *testing.T) {
|
||||
content := ""
|
||||
client := &fakeClient{responses: []*llm.ChatResponse{{
|
||||
Choices: []llm.Choice{{Message: llm.ResponseMessage{Content: &content}}},
|
||||
Model: "fake",
|
||||
Usage: &llm.UsageInfo{PromptTokens: 5, CompletionTokens: 5},
|
||||
}}}
|
||||
deps := newTestDeps(client)
|
||||
deps.Template.MaxToolRequestTimes = 1
|
||||
runner := NewRunner(deps)
|
||||
|
||||
msgs := []llm.Message{llm.NewTextMessage("user", "review")}
|
||||
completed, err := runner.RunPerFile(context.Background(), msgs, "main.go")
|
||||
if err != nil {
|
||||
t.Fatalf("RunPerFile: %v", err)
|
||||
}
|
||||
if completed {
|
||||
t.Fatal("RunPerFile completed without task_done")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunner_RecordWarning(t *testing.T) {
|
||||
deps := newTestDeps(&fakeClient{})
|
||||
runner := NewRunner(deps)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ package llmloop
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime/debug"
|
||||
"sync"
|
||||
|
||||
"github.com/open-code-review/open-code-review/internal/model"
|
||||
|
|
@ -55,6 +56,14 @@ func (p *CommentWorkerPool) Submit(f func() ([]model.LlmComment, error)) {
|
|||
p.wg.Go(func() {
|
||||
p.semaphore <- struct{}{}
|
||||
defer func() { <-p.semaphore }()
|
||||
// Contain a panic in the submitted work so one bad unit of work cannot
|
||||
// crash the whole process. The work that panics contributes no comments;
|
||||
// the semaphore is still released via the defer above.
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
fmt.Fprintf(stdout.Writer(), "[ocr] CommentWorkerPool panic: %v\n%s\n", r, debug.Stack())
|
||||
}
|
||||
}()
|
||||
|
||||
comments, err := f()
|
||||
if err != nil {
|
||||
|
|
@ -68,6 +77,16 @@ func (p *CommentWorkerPool) Submit(f func() ([]model.LlmComment, error)) {
|
|||
|
||||
// Await blocks until all submitted work has completed and returns
|
||||
// aggregated results from every Submit call so far.
|
||||
//
|
||||
// A panic in submitted work is recovered and logged inside Submit (see the
|
||||
// recover defer there) but is not surfaced here as an error or reflected in
|
||||
// the returned count — a unit that panics contributes no comments and is
|
||||
// indistinguishable from one that produced zero.
|
||||
//
|
||||
// Concurrency contract: Await must not run concurrently with Submit. Submit
|
||||
// calls wg.Go (which does wg.Add(1) synchronously), so a Submit racing Await
|
||||
// would risk sync.WaitGroup's "Add called concurrently with Wait" panic.
|
||||
// Callers must ensure every Submit has returned before calling Await.
|
||||
func (p *CommentWorkerPool) Await() []model.LlmComment {
|
||||
p.wg.Wait()
|
||||
return p.results
|
||||
|
|
|
|||
|
|
@ -97,3 +97,24 @@ func TestCommentWorkerPool_AwaitEmpty(t *testing.T) {
|
|||
t.Errorf("expected nil for no submissions, got %v", results)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommentWorkerPool_PanicIsIsolated(t *testing.T) {
|
||||
p := NewCommentWorkerPool(2)
|
||||
|
||||
p.Submit(func() ([]model.LlmComment, error) {
|
||||
panic("boom in submitted work")
|
||||
})
|
||||
p.Submit(func() ([]model.LlmComment, error) {
|
||||
return []model.LlmComment{{Path: "healthy.go", Content: "fine"}}, nil
|
||||
})
|
||||
|
||||
// Await must not crash: the recovered panic contributes no comments, and the
|
||||
// healthy task's result is still collected.
|
||||
results := p.Await()
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("expected 1 result after a panicking task, got %d", len(results))
|
||||
}
|
||||
if results[0].Path != "healthy.go" {
|
||||
t.Errorf("Path = %q, want healthy.go", results[0].Path)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -113,7 +113,7 @@ func TestProvider_Execute_Integration(t *testing.T) {
|
|||
}
|
||||
|
||||
c := &Client{name: "test-srv", session: session, tools: toolsResult.Tools}
|
||||
defer c.Close()
|
||||
defer func() { _ = c.Close() }()
|
||||
|
||||
p := &Provider{toolName: "greet", client: c}
|
||||
result, err := p.Execute(ctx, map[string]any{"name": "world"})
|
||||
|
|
@ -193,7 +193,7 @@ func TestNewClient_Integration(t *testing.T) {
|
|||
session: session,
|
||||
tools: toolsResult.Tools,
|
||||
}
|
||||
defer c.Close()
|
||||
defer func() { _ = c.Close() }()
|
||||
|
||||
if c.Name() != "test-srv" {
|
||||
t.Errorf("Name() = %q", c.Name())
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue