Compare commits
87 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
6adcb1ecd4 | ||
|
|
8e049189f7 | ||
|
|
b2a6f9bf2b | ||
|
|
2a5c894635 | ||
|
|
2301ee00cc | ||
|
|
22dcd9969a | ||
|
|
52a51f86f8 | ||
|
|
be470bb8ee | ||
|
|
d84d76b6dc | ||
|
|
e3813ceeda | ||
|
|
c076b8e17f | ||
|
|
eae863502b | ||
|
|
62972cbcf8 | ||
|
|
403335ef5a | ||
|
|
86d474c5d7 | ||
|
|
dc30b81ad6 | ||
|
|
3e01d0db17 | ||
|
|
d22bd7fa3d | ||
|
|
08e7846a37 | ||
|
|
0881ad87b8 | ||
|
|
0d601eaf58 | ||
|
|
dde66f1dee | ||
|
|
f2a6f7c5c7 | ||
|
|
b109baf0c2 | ||
|
|
30c083c0de | ||
|
|
793bbc6a61 | ||
|
|
9a11fc1302 | ||
|
|
38fc691498 | ||
|
|
be4b0b0aa9 | ||
|
|
9f71753f68 | ||
|
|
453c4f9c76 | ||
|
|
110e5284fb | ||
|
|
b3eb4b3491 | ||
|
|
5bd291739d | ||
|
|
904ecd3ae1 | ||
|
|
98fe309f03 | ||
|
|
e1a6a404ba | ||
|
|
b3b4f238a3 |
|
|
@ -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`.
|
||||
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ Create an annotated git tag with an auto-generated summary of changes since the
|
|||
Run:
|
||||
|
||||
```bash
|
||||
git tag -a <new-version> -m "<summary>"
|
||||
git tag -s <new-version> -m "<summary>"
|
||||
```
|
||||
|
||||
Report the created tag and its message to the user.
|
||||
|
|
|
|||
20
.github/workflows/ci.yml
vendored
|
|
@ -14,12 +14,12 @@ jobs:
|
|||
runs-on: self-hosted
|
||||
timeout-minutes: 15
|
||||
container:
|
||||
image: golang:1.26.4
|
||||
image: golang:1.26.5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Trust workspace
|
||||
run: git config --global safe.directory '*'
|
||||
run: git config --global --replace-all safe.directory '*'
|
||||
|
||||
- name: Vet
|
||||
run: go vet ./...
|
||||
|
|
@ -29,8 +29,20 @@ jobs:
|
|||
go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||
govulncheck ./...
|
||||
|
||||
- name: Test
|
||||
run: go test -v -race -count=1 ./...
|
||||
- name: Test with coverage
|
||||
run: |
|
||||
go test -v -race -count=1 -coverprofile=coverage.out ./...
|
||||
go tool cover -func=coverage.out | grep total: | awk '{print $3}'
|
||||
|
||||
- name: Check coverage threshold
|
||||
run: |
|
||||
COVERAGE=$(go tool cover -func=coverage.out | grep total: | awk '{print $3}' | sed 's/%//')
|
||||
echo "Total coverage: ${COVERAGE}%"
|
||||
if awk "BEGIN {exit !($COVERAGE < 80)}"; then
|
||||
echo "FAIL: Coverage ${COVERAGE}% is below 80% threshold"
|
||||
exit 1
|
||||
fi
|
||||
echo "PASS: Coverage ${COVERAGE}% meets 80% threshold"
|
||||
|
||||
- name: Build
|
||||
run: go build -o /dev/null ./cmd/opencodereview
|
||||
|
|
|
|||
6
.github/workflows/deploy-pages.yml
vendored
|
|
@ -20,12 +20,12 @@ jobs:
|
|||
build:
|
||||
runs-on: self-hosted
|
||||
container:
|
||||
image: node:20
|
||||
image: node:24
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Trust workspace
|
||||
run: git config --global safe.directory '*'
|
||||
run: git config --global --replace-all safe.directory '*'
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: pages
|
||||
|
|
@ -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
|
||||
|
|
|
|||
496
.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
|
||||
run: git config --global --add 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: Trust workspace
|
||||
run: git config --global --replace-all safe.directory '*'
|
||||
|
||||
- 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'
|
||||
|
|
|
|||
21
.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:
|
||||
|
|
@ -31,7 +31,7 @@ jobs:
|
|||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Trust workspace
|
||||
run: git config --global safe.directory '*'
|
||||
run: git config --global --replace-all safe.directory '*'
|
||||
|
||||
- name: Build
|
||||
env:
|
||||
|
|
@ -58,6 +58,10 @@ jobs:
|
|||
release:
|
||||
needs: build
|
||||
runs-on: self-hosted
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
attestations: write
|
||||
container:
|
||||
image: ubuntu:24.04
|
||||
steps:
|
||||
|
|
@ -69,7 +73,7 @@ jobs:
|
|||
fetch-depth: 0
|
||||
|
||||
- name: Trust workspace
|
||||
run: git config --global safe.directory '*'
|
||||
run: git config --global --replace-all safe.directory '*'
|
||||
|
||||
- name: Generate release notes from commits
|
||||
id: notes
|
||||
|
|
@ -145,18 +149,25 @@ jobs:
|
|||
opencodereview-*
|
||||
sha256sum.txt
|
||||
|
||||
- name: Attest release artifacts
|
||||
uses: actions/attest-build-provenance@v2
|
||||
with:
|
||||
subject-path: |
|
||||
opencodereview-*
|
||||
sha256sum.txt
|
||||
|
||||
npm-publish:
|
||||
needs: release
|
||||
runs-on: self-hosted
|
||||
container:
|
||||
image: node:20
|
||||
image: node:24
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Trust workspace
|
||||
run: git config --global safe.directory '*'
|
||||
run: git config --global --replace-all safe.directory '*'
|
||||
|
||||
- name: Install jq
|
||||
run: apt-get update && apt-get install -y jq
|
||||
|
|
|
|||
109
ASSURANCE_CASE.md
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
# Security Assurance Case
|
||||
|
||||
This document provides a security assurance case for Open Code Review (OCR), justifying that security requirements are met through secure design principles and countermeasures against common implementation weaknesses.
|
||||
|
||||
## Threat Model
|
||||
|
||||
### System Description
|
||||
|
||||
OCR is a CLI tool that:
|
||||
|
||||
1. Reads git diff output from a local repository.
|
||||
2. Sends code diffs to a configured LLM provider (OpenAI, Anthropic, etc.) via HTTPS.
|
||||
3. Receives review comments from the LLM and presents them to the user.
|
||||
4. Optionally serves a local web viewer for browsing review session history.
|
||||
|
||||
### Actors
|
||||
|
||||
| Actor | Trust Level |
|
||||
|-------|-------------|
|
||||
| Local user | Trusted — invokes the CLI with full control over configuration |
|
||||
| LLM provider API | Semi-trusted — responses are validated before use |
|
||||
| Git repository | Semi-trusted — diffs may contain adversarial content |
|
||||
| Network | Untrusted — all communication uses TLS |
|
||||
| Web browser (viewer) | Untrusted — may be exploited via DNS rebinding |
|
||||
|
||||
### Trust Boundaries
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ User Machine (Trusted Zone) │
|
||||
│ │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌────────────┐ │
|
||||
│ │ Git Repo │───▶│ OCR CLI │───▶│ Local File │ │
|
||||
│ │ (diffs) │ │ (core) │ │ (output) │ │
|
||||
│ └──────────┘ └────┬─────┘ └────────────┘ │
|
||||
│ Trust ────────▶│◀──────── Trust │
|
||||
│ Boundary 1 │ Boundary 3 │
|
||||
│ │ │
|
||||
│ ┌────┴─────┐ │
|
||||
│ │ Viewer │◀── Trust Boundary 4 │
|
||||
│ │ (HTTP) │ (Browser access) │
|
||||
│ └──────────┘ │
|
||||
└───────────────────────┼───────────────────────────┘
|
||||
Trust ──────▶│◀────── Boundary 2
|
||||
│
|
||||
┌─────────┴──────────┐
|
||||
│ LLM Provider API │
|
||||
│ (HTTPS only) │
|
||||
└────────────────────┘
|
||||
```
|
||||
|
||||
1. **Git → CLI**: Diff content may contain crafted payloads. Parsed with strict format validation.
|
||||
2. **CLI → LLM API**: API keys transmitted over HTTPS only. Responses validated before use.
|
||||
3. **CLI → Local output**: File writes constrained to the working directory.
|
||||
4. **Browser → Viewer**: Host-header allowlist enforces access control; blocks DNS rebinding.
|
||||
|
||||
### Threat Summary
|
||||
|
||||
| ID | Threat | Boundary | Mitigation |
|
||||
|----|--------|----------|------------|
|
||||
| T1 | Command injection via crafted diff content | 1 | All external commands are `git` only, with hardcoded subcommands; no shell expansion; `--end-of-options` used to prevent flag injection |
|
||||
| T2 | API key leakage | 2 | Keys read from environment variables only; never logged, written to output files, or transmitted beyond the configured LLM endpoint |
|
||||
| T3 | Path traversal via LLM-suggested file paths | 3 | `pathutil.WithinBase()` validates all file paths against the repository root, both before and after symlink resolution |
|
||||
| T4 | DNS rebinding against local viewer | 4 | Host-header allowlist rejects requests from non-loopback origins; configurable via `OCR_VIEWER_ALLOWED_HOSTS` |
|
||||
| T5 | Man-in-the-middle on API communication | 2 | Go's `net/http` enforces TLS 1.2+ with full certificate verification by default; `InsecureSkipVerify` is never set |
|
||||
| T6 | Malicious LLM response | 2 | JSON schema validation on response structure; line number bounds checking against actual diff ranges |
|
||||
| T7 | Dependency vulnerabilities | All | `govulncheck` runs in CI; Dependabot monitors for updates; `go.sum` provides integrity verification |
|
||||
|
||||
## Secure Design Principles
|
||||
|
||||
The following analysis maps [Saltzer & Schroeder's design principles](https://ieeexplore.ieee.org/document/1451869) to the project's implementation.
|
||||
|
||||
| Principle | How Applied |
|
||||
|-----------|-------------|
|
||||
| **Least privilege** | `CGO_ENABLED=0` eliminates C library attack surface. The CLI requires no elevated permissions. No network listeners except the opt-in viewer. |
|
||||
| **Fail-safe defaults** | API keys must be explicitly provided via environment variables. The viewer binds to localhost by default; non-loopback hosts require explicit allowlisting. |
|
||||
| **Complete mediation** | Every viewer HTTP request is checked against the host allowlist (`internal/viewer/hostguard.go`). Every file path from agent tools is validated against the repository root (`internal/tool/filereader.go:98`, `internal/pathutil/path.go`). |
|
||||
| **Economy of mechanism** | External process execution is limited to `git` with hardcoded subcommands — no shell invocation, no arbitrary command execution. |
|
||||
| **Open design** | Fully open-source (Apache-2.0). Security relies on TLS, not obscurity. |
|
||||
| **Separation of privilege** | API authentication (keys) is separated from configuration (files). The viewer's host guard is a distinct middleware layer. |
|
||||
| **Least common mechanism** | Each review session writes to its own JSONL file. No shared state between sessions. |
|
||||
| **Psychological acceptability** | Security defaults (HTTPS, localhost binding, host allowlist) require no user configuration. Overrides (`OCR_VIEWER_ALLOWED_HOSTS`) are explicit and documented. |
|
||||
|
||||
## Countermeasures Against Common Weaknesses
|
||||
|
||||
The following maps [OWASP Top 10](https://owasp.org/www-project-top-ten/) and [CWE/SANS Top 25](https://cwe.mitre.org/top25/) categories to the project.
|
||||
|
||||
| Weakness | Applicability | Countermeasure |
|
||||
|----------|---------------|----------------|
|
||||
| **A03:2021 Injection** (CWE-78 OS Command Injection) | All `exec.Command` calls use `git` with explicit argument lists — no shell interpolation. `--end-of-options` prevents flag injection. | Mitigated |
|
||||
| **A01:2021 Broken Access Control** (CWE-22 Path Traversal) | Agent file-read tool validates paths with `pathutil.WithinBase()` before and after symlink resolution (`internal/tool/filereader.go:91-112`). | Mitigated |
|
||||
| **A02:2021 Cryptographic Failures** | All API communication uses HTTPS/TLS 1.2+. Go's default TLS configuration is used without weakening. `InsecureSkipVerify` is never set. | Mitigated |
|
||||
| **A07:2021 Auth Failures** (CWE-798 Hard-coded Credentials) | API keys are read exclusively from environment variables, never embedded in code or config files, never logged. | Mitigated |
|
||||
| **A05:2021 Security Misconfiguration** | Secure defaults: localhost-only viewer, HTTPS-only API calls, `CGO_ENABLED=0`. The `go vet` and `govulncheck` tools run in CI. | Mitigated |
|
||||
| **A06:2021 Vulnerable Components** (CWE-1104) | Dependabot monitors Go modules and GitHub Actions. `govulncheck` runs on every push/PR. Dependencies are locked via `go.sum`. | Mitigated |
|
||||
| **A08:2021 Software Integrity** | Release binaries include SHA-256 checksums. Release tags are cryptographically signed (SSH). `CGO_ENABLED=0` produces static binaries with no external shared library dependencies. | Mitigated |
|
||||
| **A09:2021 Logging Failures** | API keys and sensitive headers are excluded from all log output and telemetry. | Mitigated |
|
||||
| **A10:2021 SSRF** | The CLI only makes outbound requests to user-configured LLM API endpoints. The viewer does not make outbound requests. | Not applicable |
|
||||
| **CWE-416 Use After Free / CWE-787 Out-of-bounds Write** | Go is a memory-safe language. `CGO_ENABLED=0` eliminates C memory risks. Race detector (`-race`) runs in CI. | Not applicable (memory-safe language) |
|
||||
|
||||
## Automated Verification
|
||||
|
||||
| Check | Tool | When |
|
||||
|-------|------|------|
|
||||
| Static analysis | `go vet` | Every push and PR (CI) |
|
||||
| Known vulnerability scan | `govulncheck` | Every push and PR (CI) |
|
||||
| Data race detection | `go test -race` | Every push and PR (CI) |
|
||||
| Dependency monitoring | Dependabot | Continuous |
|
||||
| Build integrity | `CGO_ENABLED=0`, `go.sum` checksums | Every build |
|
||||
28
Makefile
|
|
@ -1,4 +1,4 @@
|
|||
.PHONY: build test clean run help fmt vet check \
|
||||
.PHONY: build test clean run help fmt vet check coverage \
|
||||
build-all dist sha256sum version-info \
|
||||
build-linux-amd64 build-linux-arm64 build-darwin-amd64 build-darwin-arm64 \
|
||||
build-windows-amd64 build-windows-arm64
|
||||
|
|
@ -31,11 +31,25 @@ endef
|
|||
build:
|
||||
$(GO) build -ldflags "$(LD_FLAGS)" -o $(DIST_DIR)/$(BINARY_NAME) ./cmd/opencodereview
|
||||
|
||||
PACKAGES := $(shell $(GO) list ./... | grep -v /extensions/)
|
||||
|
||||
test:
|
||||
LC_ALL=C $(GO) test -v -race -count=1 ./...
|
||||
LC_ALL=C $(GO) test -v -race -count=1 $(PACKAGES)
|
||||
|
||||
COVERAGE_THRESHOLD := 80
|
||||
|
||||
coverage:
|
||||
LC_ALL=C $(GO) test -count=1 -coverprofile=coverage.out $(PACKAGES)
|
||||
$(GO) tool cover -func=coverage.out | grep total:
|
||||
@COVERAGE=$$($(GO) tool cover -func=coverage.out | grep total: | awk '{print $$3}' | sed 's/%//'); \
|
||||
if awk "BEGIN {exit !($$COVERAGE < $(COVERAGE_THRESHOLD))}"; then \
|
||||
echo "FAIL: Coverage $${COVERAGE}% is below $(COVERAGE_THRESHOLD)% threshold"; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
echo "PASS: Coverage $${COVERAGE}% meets $(COVERAGE_THRESHOLD)% threshold"
|
||||
|
||||
clean:
|
||||
rm -rf $(DIST_DIR)
|
||||
rm -rf $(DIST_DIR) coverage.out
|
||||
|
||||
run: build
|
||||
$(DIST_DIR)/$(BINARY_NAME) --staged
|
||||
|
|
@ -44,15 +58,15 @@ help: build
|
|||
$(DIST_DIR)/$(BINARY_NAME) -h
|
||||
|
||||
fmt:
|
||||
$(GO) fmt ./...
|
||||
$(GO) fmt $(PACKAGES)
|
||||
|
||||
vet:
|
||||
LC_ALL=C $(GO) vet ./...
|
||||
LC_ALL=C $(GO) vet $(PACKAGES)
|
||||
|
||||
check:
|
||||
$(GO) mod tidy
|
||||
$(GO) fmt ./...
|
||||
LC_ALL=C $(GO) vet ./...
|
||||
$(GO) fmt $(PACKAGES)
|
||||
LC_ALL=C $(GO) vet $(PACKAGES)
|
||||
@echo "check passed"
|
||||
|
||||
# ── Cross-platform targets ───────────────────────────────────────────────────
|
||||
|
|
|
|||
206
README.ja-JP.md
|
|
@ -13,10 +13,9 @@
|
|||
<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://www.bestpractices.dev/projects/13328/badge" /></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>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="#supported-platforms"><img alt="Windows" src="https://img.shields.io/badge/Windows-supported-blue.svg" /></a>
|
||||
|
|
@ -38,7 +37,9 @@ Open Code ReviewはAIを活用したコードレビューCLIツールです。
|
|||
|
||||
Gitのdiffを読み取り、変更されたファイルをツール利用機能を持つエージェント経由で設定可能なLLMに送信し、行レベルの精度で構造化されたレビューコメントを生成します。エージェントはファイル全体の内容を読み取り、コードベースを検索し、コンテキストのために他の変更ファイルを参照し、深いレビューを生成できます — 単なる表面的なdiffへのフィードバックではありません。diffレビュー以外にも、`ocr scan` はファイル全体をレビューできます。不慣れなコードベースの監査や、意味のあるdiffがないディレクトリの検査に便利です。
|
||||
|
||||

|
||||
詳細は[公式サイト](https://alibaba.github.io/open-code-review/)をご覧ください。
|
||||
|
||||

|
||||
|
||||
## ベンチマーク
|
||||
|
||||
|
|
@ -54,7 +55,7 @@ Gitのdiffを読み取り、変更されたファイルをツール利用機能
|
|||
| **平均時間 (Avg Time)** | レビューあたりの実時間 | CIパイプラインの待機時間に影響 |
|
||||
| **平均トークン (Avg Token)** | レビューあたりの総トークン消費量 | APIコストに直接影響 |
|
||||
|
||||

|
||||

|
||||
|
||||
## なぜOpen Code Reviewなのか?
|
||||
|
||||
|
|
@ -90,6 +91,10 @@ Open Code Reviewのコア哲学は、決定論的エンジニアリングとエ
|
|||
|
||||
## 使い方
|
||||
|
||||
### 前提条件
|
||||
|
||||
- **Git >= 2.41** — Open Code Review は diff 生成、コード検索、リポジトリ操作に Git を利用します。
|
||||
|
||||
### CLI
|
||||
|
||||
#### インストール
|
||||
|
|
@ -102,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):
|
||||
|
|
@ -263,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 # ディレクトリまたは特定のファイルをスキャン
|
||||
|
|
@ -398,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/) ディレクトリを参照してください。
|
||||
|
||||
## コマンド
|
||||
|
||||
|
|
@ -416,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` | — | バージョン情報を表示 |
|
||||
|
||||
|
|
@ -429,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` を尊重するファイルシステムウォークにフォールバック)。
|
||||
|
|
@ -485,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
|
||||
|
|
@ -492,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
|
||||
|
||||
|
|
@ -565,6 +662,45 @@ OCRは4層の優先度チェーンを使ってレビュールールを解決し
|
|||
- 各層の中では、ルールは宣言順に評価されます — 最初にマッチしたものが採用されます。
|
||||
- ルールファイルが存在しない場合は、何も出力せずスキップされます。
|
||||
|
||||
**`rule` フィールドはインラインコンテンツとファイルパスの両方をサポートします。** システムは次の順序で自動判別します:
|
||||
|
||||
1. 値に改行が含まれる → **インラインコンテンツ**(複数行ルールがファイルパスと見なされることはありません)。
|
||||
2. 値が単一行で、スペースを含まず、`.md` / `.txt` / `.markdown` で終わる → **ファイルパス**。
|
||||
- 絶対パス(`/` で始まる)はそのまま使用されます。
|
||||
- 相対パスはプロジェクトルートで解決されます。パストラバーサル(例: `../../etc/passwd.md`)はブロックされます。見つからない場合は `[WARN]` を出力し、ルールはクリアされます(インラインへのフォールバックなし)。
|
||||
- ファイルはバリデーションを通過する必要があります:ホワイトリスト拡張子、≤ 512 KB、シンボリックリンク解決後のターゲットもホワイトリスト拡張子であること。バリデーションに失敗した場合、ルールはクリアされます。
|
||||
3. それ以外 → **インラインコンテンツ**。
|
||||
|
||||
```json
|
||||
{
|
||||
"rules": [
|
||||
{
|
||||
"path": "**/*mapper*.xml",
|
||||
"rule": "docs/sql-rules.md"
|
||||
},
|
||||
{
|
||||
"path": "**/*.java",
|
||||
"rule": "Always check for null safety and resource leaks"
|
||||
},
|
||||
{
|
||||
"path": "**/*.go",
|
||||
"rule": "shared/go-concurrency.md"
|
||||
},
|
||||
{
|
||||
"path": "**/*.py",
|
||||
"rule": "/Users/me/team-rules/python.md"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `docs/sql-rules.md` — 相対パス、`<project>/docs/sql-rules.md` から読み込み。
|
||||
- `Always check for null safety…` — インライン文字列、そのまま使用。
|
||||
- `shared/go-concurrency.md` — 相対パス、同様に解決。
|
||||
- `/Users/me/team-rules/python.md` — 絶対パス、そのまま使用。
|
||||
|
||||
> 絶対パスはプロジェクト外のファイルにアクセスできますが、これは意図的な設計です。`rule.json` はメンテナが作成する信頼された入力のためです。共有ルールを共通パス(例:`/opt/company-rules/`)に置くことで、各プロジェクトへのコピーが不要になります。
|
||||
|
||||
### パスフィルタリング
|
||||
|
||||
ルールファイルでは `include` と `exclude` フィールドも使用でき、どのファイルをレビュー対象にするかを制御できます:
|
||||
|
|
@ -621,15 +757,22 @@ OCRは4層の優先度チェーンを使ってレビュールールを解決し
|
|||
| `providers.<name>.models` | array | 対話的選択に使う任意のプロバイダーモデル一覧 |
|
||||
| `providers.<name>.auth_header` | string | `x-api-key` \| `authorization` |
|
||||
| `providers.<name>.extra_body` | object | すべてのリクエストボディにマージされるJSONオブジェクト |
|
||||
| `providers.<name>.timeout_sec` | integer | リクエストごとのHTTPタイムアウト(秒)、デフォルト `300` |
|
||||
| `providers.<name>.extra_headers` | string | カンマ区切りの `key=value` HTTPヘッダー |
|
||||
| `custom_providers.<name>.*` | — | 任意の`models`を含む`providers.<name>.*`と同じフィールド |
|
||||
| `llm.url` | string | `https://api.openai.com/v1/chat/completions` |
|
||||
| `llm.auth_token` | string | `sk-xxxxxxx` |
|
||||
| `llm.auth_header` | string | Anthropicのみ:`x-api-key` \| `authorization` |
|
||||
| `llm.extra_body` | object | すべてのリクエストボディにマージされるJSONオブジェクト |
|
||||
| `llm.timeout_sec` | integer | リクエストごとのHTTPタイムアウト(秒)、デフォルト `300` |
|
||||
| `llm.extra_headers` | string | カンマ区切りの `key=value` HTTPヘッダー |
|
||||
| `llm.model` | string | `claude-opus-4-6` |
|
||||
| `llm.use_anthropic` | boolean | `true` \| `false` |
|
||||
| `mcp_servers.<name>.command` | string | MCPサーバーを起動するコマンド |
|
||||
| `mcp_servers.<name>.args` | array | MCPサーバーのコマンドライン引数 |
|
||||
| `mcp_servers.<name>.env` | array | 環境変数(`KEY=VALUE`形式) |
|
||||
| `mcp_servers.<name>.tools` | array | 許可するツール名(空の場合はすべてのツール) |
|
||||
| `mcp_servers.<name>.setup` | string | サーバー起動前に実行するセットアップコマンド |
|
||||
| `language` | string | 任意の言語名、例:`English`、`Chinese`(デフォルト:`English`) |
|
||||
| `telemetry.enabled` | boolean | `true` \| `false` |
|
||||
| `telemetry.exporter` | string | `console` \| `otlp` |
|
||||
|
|
@ -638,6 +781,43 @@ OCRは4層の優先度チェーンを使ってレビュールールを解決し
|
|||
|
||||
環境変数は設定ファイルより優先されます。
|
||||
|
||||
### MCPサーバー
|
||||
|
||||
Open Code Reviewは[Model Context Protocol (MCP)](https://modelcontextprotocol.io/)サーバーをサポートしており、レビューエージェントがstdioトランスポートを介してコードレビュー中に外部ツールを使用できます。
|
||||
|
||||
CLIからMCPサーバーを設定します:
|
||||
|
||||
```bash
|
||||
# MCPサーバーを追加
|
||||
ocr config set mcp_servers.<name>.command <command>
|
||||
ocr config set mcp_servers.<name>.args '["arg1","arg2"]'
|
||||
ocr config set mcp_servers.<name>.env '["KEY=VALUE"]'
|
||||
ocr config set mcp_servers.<name>.tools '["tool_name"]'
|
||||
ocr config set mcp_servers.<name>.setup '<setup command>'
|
||||
|
||||
# MCPサーバーを削除
|
||||
ocr config unset mcp_servers.<name>
|
||||
```
|
||||
|
||||
| フィールド | 必須 | 説明 |
|
||||
|-----------|------|------|
|
||||
| `command` | はい | MCPサーバーを起動する実行コマンド |
|
||||
| `args` | いいえ | サーバーに渡すコマンドライン引数 |
|
||||
| `env` | いいえ | 環境変数(`KEY=VALUE`形式) |
|
||||
| `tools` | いいえ | 許可するツール名。空の場合、サーバーのすべてのツールが利用可能 |
|
||||
| `setup` | いいえ | サーバー起動前に実行するシェルコマンド(例:インデックスの構築) |
|
||||
|
||||
> **注意:** MCPツールの名前が組み込みツールと競合する場合、そのツールは警告付きでスキップされます。`setup`コマンドのタイムアウトは5分です。
|
||||
|
||||
**例:[CodeGraph](https://github.com/nicholasgasior/codegraph)を追加してコード構造分析を強化**
|
||||
|
||||
```bash
|
||||
ocr config set mcp_servers.codegraph.command codegraph
|
||||
ocr config set mcp_servers.codegraph.args '["serve","--mcp"]'
|
||||
ocr config set mcp_servers.codegraph.tools '["codegraph_explore"]'
|
||||
ocr config set mcp_servers.codegraph.setup 'codegraph init && codegraph index'
|
||||
```
|
||||
|
||||
### 環境変数
|
||||
|
||||
| 変数 | 用途 |
|
||||
|
|
@ -647,6 +827,7 @@ OCRは4層の優先度チェーンを使ってレビュールールを解決し
|
|||
| `OCR_LLM_AUTH_HEADER` | Anthropic認証ヘッダー(`x-api-key`または`authorization`) |
|
||||
| `OCR_LLM_EXTRA_HEADERS` | カンマ区切りの `key=value` HTTPヘッダー |
|
||||
| `OCR_LLM_MODEL` | モデル名 |
|
||||
| `OCR_LLM_TIMEOUT` | リクエストごとのHTTPタイムアウト(秒)、設定ファイルの `timeout_sec` を上書き |
|
||||
| `OCR_USE_ANTHROPIC` | `true` = Anthropic、`false` = OpenAI |
|
||||
|
||||
|
||||
|
|
@ -662,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>
|
||||
|
||||
## ライセンス
|
||||
|
||||
|
|
|
|||
202
README.ko-KR.md
|
|
@ -13,10 +13,9 @@
|
|||
<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://www.bestpractices.dev/projects/13328/badge" /></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>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="#supported-platforms"><img alt="Windows" src="https://img.shields.io/badge/Windows-supported-blue.svg" /></a>
|
||||
|
|
@ -38,6 +37,8 @@ Open Code Review는 AI 기반 코드 리뷰 CLI 도구입니다. Alibaba Group
|
|||
|
||||
이 도구는 Git diff를 읽고, 변경 파일을 tool-use 기능을 가진 agent를 통해 설정 가능한 LLM으로 전달한 뒤, 라인 단위 위치 정보가 포함된 구조화된 리뷰 코멘트를 생성합니다. agent는 전체 파일 내용 읽기, 코드베이스 검색, 다른 변경 파일 확인 등을 통해 맥락을 확보하고 표면적인 diff 피드백이 아닌 깊이 있는 리뷰를 수행할 수 있습니다. diff 리뷰 외에도 `ocr scan`은 전체 파일을 리뷰할 수 있어, 익숙하지 않은 코드베이스를 감사하거나 의미 있는 diff가 없는 디렉터리를 검토하는 데 유용합니다.
|
||||
|
||||
자세한 내용은 [공식 웹사이트](https://alibaba.github.io/open-code-review/)를 참조하세요.
|
||||
|
||||

|
||||
|
||||
## 벤치마크
|
||||
|
|
@ -90,6 +91,10 @@ agent의 강점은 동적 판단과 동적 context 검색이 중요한 지점에
|
|||
|
||||
## 사용 방법
|
||||
|
||||
### 사전 요구 사항
|
||||
|
||||
- **Git >= 2.41** — Open Code Review는 diff 생성, 코드 검색, 저장소 작업에 Git을 사용합니다.
|
||||
|
||||
### CLI
|
||||
|
||||
#### 설치
|
||||
|
|
@ -102,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):
|
||||
|
|
@ -263,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 # 디렉터리 또는 특정 파일 스캔
|
||||
|
|
@ -398,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
|
||||
|
||||
|
|
@ -416,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 정보 표시 |
|
||||
|
||||
|
|
@ -429,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`를 따르는 파일 시스템 탐색으로 폴백).
|
||||
|
|
@ -485,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
|
||||
|
|
@ -492,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
|
||||
|
||||
|
|
@ -565,6 +662,45 @@ OCR은 네 계층의 priority chain으로 review rule을 해석합니다. 각
|
|||
- 각 계층 안에서는 rule이 선언 순서대로 평가되며 첫 번째 match가 선택됩니다.
|
||||
- rule file이 없으면 조용히 건너뜁니다.
|
||||
|
||||
**`rule` 필드는 인라인 콘텐츠와 파일 경로를 모두 지원합니다.** 시스템이 다음 순서로 자동 판별합니다:
|
||||
|
||||
1. 값에 줄바꿈이 포함된 경우 → **인라인 콘텐츠** (여러 줄 규칙은 파일 경로로 간주되지 않습니다).
|
||||
2. 값이 한 줄이고 공백이 없으며 `.md` / `.txt` / `.markdown`으로 끝나는 경우 → **파일 경로**.
|
||||
- 절대 경로(`/`로 시작)는 그대로 사용됩니다.
|
||||
- 상대 경로는 프로젝트 루트에서 확인합니다. 경로 탐색(예: `../../etc/passwd.md`)은 차단됩니다. 없으면 `[WARN]`을 출력하고 규칙이 지워집니다 (인라인으로 폴백 없음).
|
||||
- 파일은 유효성 검사를 통과해야 합니다: 허용된 확장자, ≤ 512 KB, 심볼릭 링크 해석 후 대상도 허용된 확장자여야 합니다. 검증 실패 시 규칙이 지워집니다.
|
||||
3. 그 외의 경우 → **인라인 콘텐츠**.
|
||||
|
||||
```json
|
||||
{
|
||||
"rules": [
|
||||
{
|
||||
"path": "**/*mapper*.xml",
|
||||
"rule": "docs/sql-rules.md"
|
||||
},
|
||||
{
|
||||
"path": "**/*.java",
|
||||
"rule": "Always check for null safety and resource leaks"
|
||||
},
|
||||
{
|
||||
"path": "**/*.go",
|
||||
"rule": "shared/go-concurrency.md"
|
||||
},
|
||||
{
|
||||
"path": "**/*.py",
|
||||
"rule": "/Users/me/team-rules/python.md"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `docs/sql-rules.md` — 상대 경로, `<project>/docs/sql-rules.md`에서 로드.
|
||||
- `Always check for null safety…` — 인라인 문자열, 그대로 사용.
|
||||
- `shared/go-concurrency.md` — 상대 경로, 동일하게 해결.
|
||||
- `/Users/me/team-rules/python.md` — 절대 경로, 그대로 사용.
|
||||
|
||||
> 절대 경로는 프로젝트 외부 파일에 접근할 수 있으며, 이는 의도된 설계입니다. `rule.json`은 프로젝트 메인테이너가 작성하는 신뢰된 입력입니다. 팀은 공유 규칙을 공통 경로(예: `/opt/company-rules/`)에 두어 각 프로젝트에 복사할 필요가 없습니다.
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
Config file: `~/.opencodereview/config.json`
|
||||
|
|
@ -579,15 +715,22 @@ Config file: `~/.opencodereview/config.json`
|
|||
| `providers.<name>.models` | array | 대화형 선택에 사용할 optional provider model 목록 |
|
||||
| `providers.<name>.auth_header` | string | `x-api-key` \| `authorization` |
|
||||
| `providers.<name>.extra_body` | object | 모든 요청 본문에 병합되는 JSON 객체 |
|
||||
| `providers.<name>.timeout_sec` | integer | 요청당 HTTP timeout(초), 기본값 `300` |
|
||||
| `providers.<name>.extra_headers` | string | 쉼표로 구분된 `key=value` HTTP 헤더 |
|
||||
| `custom_providers.<name>.*` | — | optional `models`를 포함한 `providers.<name>.*`과 동일한 필드 |
|
||||
| `llm.url` | string | `https://api.openai.com/v1/chat/completions` |
|
||||
| `llm.auth_token` | string | `sk-xxxxxxx` |
|
||||
| `llm.auth_header` | string | Anthropic only: `x-api-key` \| `authorization` |
|
||||
| `llm.extra_body` | object | 모든 요청 본문에 병합되는 JSON 객체 |
|
||||
| `llm.timeout_sec` | integer | 요청당 HTTP timeout(초), 기본값 `300` |
|
||||
| `llm.extra_headers` | string | 쉼표로 구분된 `key=value` HTTP 헤더 |
|
||||
| `llm.model` | string | `claude-opus-4-6` |
|
||||
| `llm.use_anthropic` | boolean | `true` \| `false` |
|
||||
| `mcp_servers.<name>.command` | string | MCP 서버를 시작하는 명령어 |
|
||||
| `mcp_servers.<name>.args` | array | MCP 서버의 커맨드라인 인수 |
|
||||
| `mcp_servers.<name>.env` | array | 환경 변수 (`KEY=VALUE` 형식) |
|
||||
| `mcp_servers.<name>.tools` | array | 허용할 도구 이름 (비어 있으면 모든 도구 허용) |
|
||||
| `mcp_servers.<name>.setup` | string | 서버 시작 전에 실행할 설정 명령어 |
|
||||
| `language` | string | 임의의 언어 이름, 예: `English`, `Chinese` (기본값: `English`) |
|
||||
| `telemetry.enabled` | boolean | `true` \| `false` |
|
||||
| `telemetry.exporter` | string | `console` \| `otlp` |
|
||||
|
|
@ -596,6 +739,43 @@ Config file: `~/.opencodereview/config.json`
|
|||
|
||||
환경 변수는 config file보다 우선합니다.
|
||||
|
||||
### MCP Server
|
||||
|
||||
Open Code Review는 [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) 서버를 지원하여 리뷰 에이전트가 stdio 전송을 통해 코드 리뷰 중에 외부 도구를 사용할 수 있습니다.
|
||||
|
||||
CLI로 MCP 서버를 설정합니다:
|
||||
|
||||
```bash
|
||||
# MCP 서버 추가
|
||||
ocr config set mcp_servers.<name>.command <command>
|
||||
ocr config set mcp_servers.<name>.args '["arg1","arg2"]'
|
||||
ocr config set mcp_servers.<name>.env '["KEY=VALUE"]'
|
||||
ocr config set mcp_servers.<name>.tools '["tool_name"]'
|
||||
ocr config set mcp_servers.<name>.setup '<setup command>'
|
||||
|
||||
# MCP 서버 삭제
|
||||
ocr config unset mcp_servers.<name>
|
||||
```
|
||||
|
||||
| 필드 | 필수 | 설명 |
|
||||
|------|------|------|
|
||||
| `command` | 예 | MCP 서버를 시작하는 실행 명령어 |
|
||||
| `args` | 아니오 | 서버에 전달할 커맨드라인 인수 |
|
||||
| `env` | 아니오 | 환경 변수 (`KEY=VALUE` 형식) |
|
||||
| `tools` | 아니오 | 허용할 도구 이름. 비어 있으면 서버의 모든 도구 사용 가능 |
|
||||
| `setup` | 아니오 | 서버 시작 전에 실행할 셸 명령어 (예: 인덱스 빌드) |
|
||||
|
||||
> **참고:** MCP 도구의 이름이 내장 도구와 충돌하면 경고와 함께 건너뜁니다. `setup` 명령어의 타임아웃은 5분입니다.
|
||||
|
||||
**예시: [CodeGraph](https://github.com/nicholasgasior/codegraph)를 추가하여 코드 구조 분석 강화**
|
||||
|
||||
```bash
|
||||
ocr config set mcp_servers.codegraph.command codegraph
|
||||
ocr config set mcp_servers.codegraph.args '["serve","--mcp"]'
|
||||
ocr config set mcp_servers.codegraph.tools '["codegraph_explore"]'
|
||||
ocr config set mcp_servers.codegraph.setup 'codegraph init && codegraph index'
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Purpose |
|
||||
|
|
@ -605,6 +785,7 @@ Config file: `~/.opencodereview/config.json`
|
|||
| `OCR_LLM_AUTH_HEADER` | Anthropic auth header (`x-api-key` 또는 `authorization`) |
|
||||
| `OCR_LLM_EXTRA_HEADERS` | 쉼표로 구분된 `key=value` HTTP 헤더 |
|
||||
| `OCR_LLM_MODEL` | Model name |
|
||||
| `OCR_LLM_TIMEOUT` | 요청당 HTTP timeout(초), config file의 `timeout_sec`를 override |
|
||||
| `OCR_USE_ANTHROPIC` | `true` = Anthropic, `false` = OpenAI |
|
||||
|
||||
## Telemetry
|
||||
|
|
@ -619,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
|
||||
|
||||
|
|
|
|||
209
README.md
|
|
@ -13,10 +13,9 @@
|
|||
<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://www.bestpractices.dev/projects/13328/badge" /></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>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="#supported-platforms"><img alt="Windows" src="https://img.shields.io/badge/Windows-supported-blue.svg" /></a>
|
||||
|
|
@ -38,6 +37,8 @@ Open Code Review is an AI-powered code review CLI tool. It originated as Alibaba
|
|||
|
||||
It reads Git diffs, sends changed files to a configurable LLM via an agent with tool-use capabilities, and generates structured review comments with line-level precision. The agent can read full file contents, search the codebase, inspect other changed files for context, and produce deep reviews — not just surface-level diff feedback. Beyond diff review, `ocr scan` reviews entire files for auditing unfamiliar codebases or directories that have no meaningful diff.
|
||||
|
||||
Visit the [official website](https://alibaba.github.io/open-code-review/) for more details.
|
||||
|
||||

|
||||
|
||||
## Benchmark
|
||||
|
|
@ -90,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
|
||||
|
|
@ -102,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):
|
||||
|
|
@ -263,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
|
||||
|
|
@ -400,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
|
||||
|
||||
|
|
@ -418,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 |
|
||||
|
||||
|
|
@ -431,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
|
||||
|
||||
|
|
@ -490,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
|
||||
|
|
@ -497,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
|
||||
|
||||
|
|
@ -570,6 +669,45 @@ Layers 1–3 share the same JSON format:
|
|||
- Within each layer, rules are evaluated in declaration order — the first match wins.
|
||||
- If a rule file does not exist, it is silently skipped.
|
||||
|
||||
**The `rule` field supports both inline content and file paths.** The system auto-detects which one you mean:
|
||||
|
||||
1. If the value contains newlines → **inline content** (multi-line rules are never file paths).
|
||||
2. If the value is a single line, contains no spaces, and ends with `.md` / `.txt` / `.markdown` → **file path**.
|
||||
- Absolute paths (starting with `/`) are used directly.
|
||||
- Relative paths are resolved against the project root. Path traversal (e.g. `../../etc/passwd.md`) is blocked. If not found, a `[WARN]` is emitted and the rule is cleared (no fallback to inline).
|
||||
- The file must pass validation: whitelisted extension, ≤ 512 KB, and resolved symlink target must also be a whitelisted extension. If validation fails, the rule is cleared.
|
||||
3. Otherwise → **inline content**.
|
||||
|
||||
```json
|
||||
{
|
||||
"rules": [
|
||||
{
|
||||
"path": "**/*mapper*.xml",
|
||||
"rule": "docs/sql-rules.md"
|
||||
},
|
||||
{
|
||||
"path": "**/*.java",
|
||||
"rule": "Always check for null safety and resource leaks"
|
||||
},
|
||||
{
|
||||
"path": "**/*.go",
|
||||
"rule": "shared/go-concurrency.md"
|
||||
},
|
||||
{
|
||||
"path": "**/*.py",
|
||||
"rule": "/Users/me/team-rules/python.md"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `docs/sql-rules.md` — relative path, resolved from `<project>/docs/sql-rules.md`.
|
||||
- `Always check for null safety…` — inline string, used directly.
|
||||
- `shared/go-concurrency.md` — relative path, same resolution.
|
||||
- `/Users/me/team-rules/python.md` — absolute path, used directly.
|
||||
|
||||
> Absolute paths can access files outside the project directory — this is intentional. `rule.json` is authored by project maintainers, i.e. trusted input. Teams can store shared rules at a common path (e.g. `/opt/company-rules/`) instead of copying them into every project.
|
||||
|
||||
### Path Filtering
|
||||
|
||||
Rule files also support `include` and `exclude` fields to control which files enter the review scope:
|
||||
|
|
@ -626,15 +764,22 @@ Config file: `~/.opencodereview/config.json`
|
|||
| `providers.<name>.models` | array | Optional provider model list for interactive selection |
|
||||
| `providers.<name>.auth_header` | string | `x-api-key` \| `authorization` |
|
||||
| `providers.<name>.extra_body` | object | JSON object merged into every request body |
|
||||
| `providers.<name>.timeout_sec` | integer | Per-request HTTP timeout in seconds (default: `300`) |
|
||||
| `providers.<name>.extra_headers` | string | Comma-separated `key=value` HTTP headers |
|
||||
| `custom_providers.<name>.*` | — | Same fields as `providers.<name>.*`, including optional `models` |
|
||||
| `llm.url` | string | `https://api.openai.com/v1/chat/completions` |
|
||||
| `llm.auth_token` | string | `sk-xxxxxxx` |
|
||||
| `llm.auth_header` | string | Anthropic only: `x-api-key` \| `authorization` |
|
||||
| `llm.extra_body` | object | JSON object merged into every request body |
|
||||
| `llm.timeout_sec` | integer | Per-request HTTP timeout in seconds (default: `300`) |
|
||||
| `llm.extra_headers` | string | Comma-separated `key=value` HTTP headers |
|
||||
| `llm.model` | string | `claude-opus-4-6` |
|
||||
| `llm.use_anthropic` | boolean | `true` \| `false` |
|
||||
| `mcp_servers.<name>.command` | string | Command to start the MCP server |
|
||||
| `mcp_servers.<name>.args` | array | Command-line arguments for the MCP server |
|
||||
| `mcp_servers.<name>.env` | array | Environment variables in `KEY=VALUE` format |
|
||||
| `mcp_servers.<name>.tools` | array | Allowed tool names (empty = all tools) |
|
||||
| `mcp_servers.<name>.setup` | string | Setup command to run before starting the server |
|
||||
| `language` | string | Any language name, e.g. `English`, `Chinese` (default: `English`) |
|
||||
| `telemetry.enabled` | boolean | `true` \| `false` |
|
||||
| `telemetry.exporter` | string | `console` \| `otlp` |
|
||||
|
|
@ -643,6 +788,43 @@ Config file: `~/.opencodereview/config.json`
|
|||
|
||||
Environment variables take precedence over the config file.
|
||||
|
||||
### MCP Server
|
||||
|
||||
Open Code Review supports [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) servers, allowing the review agent to use external tools during code review via the stdio transport.
|
||||
|
||||
Configure MCP servers via the CLI:
|
||||
|
||||
```bash
|
||||
# Add an MCP server
|
||||
ocr config set mcp_servers.<name>.command <command>
|
||||
ocr config set mcp_servers.<name>.args '["arg1","arg2"]'
|
||||
ocr config set mcp_servers.<name>.env '["KEY=VALUE"]'
|
||||
ocr config set mcp_servers.<name>.tools '["tool_name"]'
|
||||
ocr config set mcp_servers.<name>.setup '<setup command>'
|
||||
|
||||
# Delete an MCP server
|
||||
ocr config unset mcp_servers.<name>
|
||||
```
|
||||
|
||||
| Field | Required | Description |
|
||||
|-------|----------|-------------|
|
||||
| `command` | Yes | The executable command to start the MCP server |
|
||||
| `args` | No | Command-line arguments passed to the server |
|
||||
| `env` | No | Environment variables in `KEY=VALUE` format |
|
||||
| `tools` | No | Allowed tool names; if empty, all tools from the server are available |
|
||||
| `setup` | No | A shell command to run before starting the server (e.g. build an index) |
|
||||
|
||||
> **Note:** If an MCP tool's name conflicts with a built-in tool, it will be skipped with a warning. The `setup` command has a 5-minute timeout.
|
||||
|
||||
**Example: Add [CodeGraph](https://github.com/nicholasgasior/codegraph) for code structure analysis**
|
||||
|
||||
```bash
|
||||
ocr config set mcp_servers.codegraph.command codegraph
|
||||
ocr config set mcp_servers.codegraph.args '["serve","--mcp"]'
|
||||
ocr config set mcp_servers.codegraph.tools '["codegraph_explore"]'
|
||||
ocr config set mcp_servers.codegraph.setup 'codegraph init && codegraph index'
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Purpose |
|
||||
|
|
@ -652,6 +834,7 @@ Environment variables take precedence over the config file.
|
|||
| `OCR_LLM_AUTH_HEADER` | Anthropic auth header (`x-api-key` or `authorization`) |
|
||||
| `OCR_LLM_EXTRA_HEADERS` | Comma-separated `key=value` HTTP headers |
|
||||
| `OCR_LLM_MODEL` | Model name |
|
||||
| `OCR_LLM_TIMEOUT` | Per-request HTTP timeout in seconds (overrides config file `timeout_sec`) |
|
||||
| `OCR_USE_ANTHROPIC` | `true` = Anthropic, `false` = OpenAI |
|
||||
|
||||
|
||||
|
|
@ -667,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
|
||||
|
||||
|
|
|
|||
204
README.ru-RU.md
|
|
@ -13,10 +13,9 @@
|
|||
<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://www.bestpractices.dev/projects/13328/badge" /></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>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="#supported-platforms"><img alt="Windows" src="https://img.shields.io/badge/Windows-supported-blue.svg" /></a>
|
||||
|
|
@ -38,6 +37,8 @@ Open Code Review — это CLI-инструмент для код-ревью н
|
|||
|
||||
Инструмент читает git-диффы, отправляет изменённые файлы настраиваемой LLM через агента с поддержкой вызова инструментов (tool use) и генерирует структурированные ревью-комментарии с точностью до строки. Агент может читать полное содержимое файлов, искать по кодовой базе, заглядывать в другие изменённые файлы за контекстом и выполнять глубокое ревью — а не только давать поверхностные замечания по диффу. Помимо ревью диффов, `ocr scan` позволяет проверять файлы целиком — удобно для аудита незнакомой кодовой базы или каталогов без значимого диффа.
|
||||
|
||||
Подробнее на [официальном сайте](https://alibaba.github.io/open-code-review/).
|
||||
|
||||

|
||||
|
||||
## Бенчмарк
|
||||
|
|
@ -90,6 +91,10 @@ Open Code Review — это CLI-инструмент для код-ревью н
|
|||
|
||||
## Как использовать
|
||||
|
||||
### Предварительные требования
|
||||
|
||||
- **Git >= 2.41** — Open Code Review использует Git для генерации diff, поиска по коду и операций с репозиторием.
|
||||
|
||||
### CLI
|
||||
|
||||
#### Установка
|
||||
|
|
@ -102,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):
|
||||
|
|
@ -263,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 # сканировать каталог или конкретные файлы
|
||||
|
|
@ -400,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/).
|
||||
|
||||
## Команды
|
||||
|
||||
|
|
@ -418,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` | — | Показать информацию о версии |
|
||||
|
||||
|
|
@ -431,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`).
|
||||
|
|
@ -487,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
|
||||
|
|
@ -494,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
|
||||
|
||||
|
|
@ -567,6 +666,45 @@ OCR разрешает правила ревью по цепочке приор
|
|||
- Внутри каждого уровня правила проверяются в порядке объявления — побеждает первое совпадение.
|
||||
- Если файл правил не существует, он молча пропускается.
|
||||
|
||||
**Поле `rule` поддерживает как встроенный текст, так и пути к файлам.** Система определяет тип автоматически:
|
||||
|
||||
1. Если значение содержит переносы строк → **встроенный текст** (многострочные правила никогда не считаются путями).
|
||||
2. Если значение — одна строка, без пробелов, и заканчивается на `.md` / `.txt` / `.markdown` → **путь к файлу**.
|
||||
- Абсолютные пути (начинающиеся с `/`) используются напрямую.
|
||||
- Относительные пути проверяются в корне проекта. Выход за пределы директории (например, `../../etc/passwd.md`) блокируется. Если не найдены — выводится `[WARN]` и правило очищается (без fallback на inline).
|
||||
- Файл должен пройти проверку: допустимое расширение, ≤ 512 KB, цель симлинка также должна иметь допустимое расширение. При ошибке проверки правило очищается.
|
||||
3. Иначе → **встроенный текст**.
|
||||
|
||||
```json
|
||||
{
|
||||
"rules": [
|
||||
{
|
||||
"path": "**/*mapper*.xml",
|
||||
"rule": "docs/sql-rules.md"
|
||||
},
|
||||
{
|
||||
"path": "**/*.java",
|
||||
"rule": "Always check for null safety and resource leaks"
|
||||
},
|
||||
{
|
||||
"path": "**/*.go",
|
||||
"rule": "shared/go-concurrency.md"
|
||||
},
|
||||
{
|
||||
"path": "**/*.py",
|
||||
"rule": "/Users/me/team-rules/python.md"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `docs/sql-rules.md` — относительный путь, загружается из `<project>/docs/sql-rules.md`.
|
||||
- `Always check for null safety…` — встроенная строка, используется напрямую.
|
||||
- `shared/go-concurrency.md` — относительный путь, аналогично.
|
||||
- `/Users/me/team-rules/python.md` — абсолютный путь, используется напрямую.
|
||||
|
||||
> Абсолютные пути могут указывать на файлы вне директории проекта — это сделано намеренно. `rule.json` пишут мейнтейнеры проекта, это доверенный ввод. Команды могут хранить общие правила по единому пути (например, `/opt/company-rules/`) и не копировать их в каждый проект.
|
||||
|
||||
### Фильтрация путей
|
||||
|
||||
Файлы правил также поддерживают поля `include` и `exclude`, управляющие тем, какие файлы попадают в область ревью:
|
||||
|
|
@ -623,15 +761,22 @@ OCR разрешает правила ревью по цепочке приор
|
|||
| `providers.<name>.models` | array | Необязательный список моделей для интерактивного выбора |
|
||||
| `providers.<name>.auth_header` | string | `x-api-key` \| `authorization` |
|
||||
| `providers.<name>.extra_body` | object | JSON-объект, добавляемый в каждое тело запроса |
|
||||
| `providers.<name>.timeout_sec` | integer | Таймаут HTTP-запроса в секундах, по умолчанию `300` |
|
||||
| `providers.<name>.extra_headers` | string | HTTP-заголовки `key=value` через запятую |
|
||||
| `custom_providers.<name>.*` | — | Те же поля, что и `providers.<name>.*`, включая необязательное `models` |
|
||||
| `llm.url` | string | `https://api.openai.com/v1/chat/completions` |
|
||||
| `llm.auth_token` | string | `sk-xxxxxxx` |
|
||||
| `llm.auth_header` | string | Только для Anthropic: `x-api-key` \| `authorization` |
|
||||
| `llm.extra_body` | object | JSON-объект, добавляемый в каждое тело запроса |
|
||||
| `llm.timeout_sec` | integer | Таймаут HTTP-запроса в секундах, по умолчанию `300` |
|
||||
| `llm.extra_headers` | string | HTTP-заголовки `key=value` через запятую |
|
||||
| `llm.model` | string | `claude-opus-4-6` |
|
||||
| `llm.use_anthropic` | boolean | `true` \| `false` |
|
||||
| `mcp_servers.<name>.command` | string | Команда для запуска MCP-сервера |
|
||||
| `mcp_servers.<name>.args` | array | Аргументы командной строки для MCP-сервера |
|
||||
| `mcp_servers.<name>.env` | array | Переменные окружения в формате `KEY=VALUE` |
|
||||
| `mcp_servers.<name>.tools` | array | Разрешённые имена инструментов (пусто = все инструменты) |
|
||||
| `mcp_servers.<name>.setup` | string | Команда настройки перед запуском сервера |
|
||||
| `language` | string | Любое название языка, например `English`, `Chinese` (по умолчанию: `English`) |
|
||||
| `telemetry.enabled` | boolean | `true` \| `false` |
|
||||
| `telemetry.exporter` | string | `console` \| `otlp` |
|
||||
|
|
@ -640,6 +785,43 @@ OCR разрешает правила ревью по цепочке приор
|
|||
|
||||
Переменные окружения имеют приоритет над файлом конфигурации.
|
||||
|
||||
### MCP-сервер
|
||||
|
||||
Open Code Review поддерживает серверы [Model Context Protocol (MCP)](https://modelcontextprotocol.io/), позволяя агенту ревью использовать внешние инструменты во время проверки кода через stdio-транспорт.
|
||||
|
||||
Настройка MCP-серверов через CLI:
|
||||
|
||||
```bash
|
||||
# Добавить MCP-сервер
|
||||
ocr config set mcp_servers.<name>.command <command>
|
||||
ocr config set mcp_servers.<name>.args '["arg1","arg2"]'
|
||||
ocr config set mcp_servers.<name>.env '["KEY=VALUE"]'
|
||||
ocr config set mcp_servers.<name>.tools '["tool_name"]'
|
||||
ocr config set mcp_servers.<name>.setup '<setup command>'
|
||||
|
||||
# Удалить MCP-сервер
|
||||
ocr config unset mcp_servers.<name>
|
||||
```
|
||||
|
||||
| Поле | Обязательно | Описание |
|
||||
|------|-------------|----------|
|
||||
| `command` | Да | Исполняемая команда для запуска MCP-сервера |
|
||||
| `args` | Нет | Аргументы командной строки для сервера |
|
||||
| `env` | Нет | Переменные окружения в формате `KEY=VALUE` |
|
||||
| `tools` | Нет | Разрешённые имена инструментов; если пусто — доступны все инструменты сервера |
|
||||
| `setup` | Нет | Shell-команда для выполнения перед запуском сервера (например, построение индекса) |
|
||||
|
||||
> **Примечание:** Если имя MCP-инструмента конфликтует со встроенным инструментом, он будет пропущен с предупреждением. Таймаут команды `setup` составляет 5 минут.
|
||||
|
||||
**Пример: добавление [CodeGraph](https://github.com/nicholasgasior/codegraph) для усиления анализа структуры кода**
|
||||
|
||||
```bash
|
||||
ocr config set mcp_servers.codegraph.command codegraph
|
||||
ocr config set mcp_servers.codegraph.args '["serve","--mcp"]'
|
||||
ocr config set mcp_servers.codegraph.tools '["codegraph_explore"]'
|
||||
ocr config set mcp_servers.codegraph.setup 'codegraph init && codegraph index'
|
||||
```
|
||||
|
||||
### Переменные окружения
|
||||
|
||||
| Переменная | Назначение |
|
||||
|
|
@ -649,6 +831,7 @@ OCR разрешает правила ревью по цепочке приор
|
|||
| `OCR_LLM_AUTH_HEADER` | Заголовок авторизации Anthropic (`x-api-key` или `authorization`) |
|
||||
| `OCR_LLM_EXTRA_HEADERS` | HTTP-заголовки `key=value` через запятую |
|
||||
| `OCR_LLM_MODEL` | Имя модели |
|
||||
| `OCR_LLM_TIMEOUT` | Таймаут HTTP-запроса в секундах (переопределяет `timeout_sec` из файла конфигурации) |
|
||||
| `OCR_USE_ANTHROPIC` | `true` = Anthropic, `false` = OpenAI |
|
||||
|
||||
|
||||
|
|
@ -664,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>
|
||||
|
||||
## Лицензия
|
||||
|
||||
|
|
|
|||
201
README.zh-CN.md
|
|
@ -13,10 +13,9 @@
|
|||
<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://www.bestpractices.dev/projects/13328/badge" /></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>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="#supported-platforms"><img alt="Windows" src="https://img.shields.io/badge/Windows-supported-blue.svg" /></a>
|
||||
|
|
@ -38,6 +37,8 @@ Open Code Review 是一款 AI 驱动的代码审查 CLI 工具。它的前身是
|
|||
|
||||
它读取 Git diff,通过具备工具调用能力的 Agent 将变更文件发送至可配置的 LLM,生成具有行级精度的结构化审查意见。Agent 可以读取完整文件内容、搜索代码库、检查其他变更文件以获取上下文,从而进行深度审查——而非仅停留在表面的 diff 反馈。除了 diff 审查,`ocr scan` 可以审查整个文件,适用于审计不熟悉的代码库或没有有意义 diff 的目录。
|
||||
|
||||
访问[官方网站](https://alibaba.github.io/open-code-review/)了解更多信息。
|
||||
|
||||

|
||||
|
||||
## 基准测试
|
||||
|
|
@ -90,6 +91,10 @@ Open Code Review 的核心设计理念是将确定性工程与 Agent 结合,
|
|||
|
||||
## 如何使用
|
||||
|
||||
### 前置条件
|
||||
|
||||
- **Git >= 2.41** — Open Code Review 依赖 Git 进行 diff 生成、代码搜索和仓库操作。
|
||||
|
||||
### CLI
|
||||
|
||||
#### 安装
|
||||
|
|
@ -102,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):
|
||||
|
|
@ -263,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 # 扫描指定目录或文件
|
||||
|
|
@ -398,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/) 目录。
|
||||
|
||||
## 命令
|
||||
|
||||
|
|
@ -416,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` | — | 显示版本信息 |
|
||||
|
||||
|
|
@ -429,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` 的文件系统遍历)。
|
||||
|
|
@ -485,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
|
||||
|
|
@ -492,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
|
||||
|
||||
|
|
@ -555,6 +651,45 @@ OCR 通过四层优先级链解析评审规则。每层采用首次匹配原则
|
|||
- 在每一层内,规则按声明顺序评估 —— 首次匹配生效。
|
||||
- 如果规则文件不存在,将被静默跳过。
|
||||
|
||||
**`rule` 字段同时支持内联内容和文件路径。**系统按以下顺序自动判断:
|
||||
|
||||
1. 如果值包含换行 → **内联内容**(多行规则永远不会被当作文件路径)。
|
||||
2. 如果值是单行、不含空格、且以 `.md` / `.txt` / `.markdown` 结尾 → **文件路径**。
|
||||
- 绝对路径(以 `/` 开头)直接使用。
|
||||
- 相对路径在项目根目录下查找,路径穿越(如 `../../etc/passwd.md`)会被拦截。找不到则 `[WARN]` 并清空该规则(不会回退为内联)。
|
||||
- 文件需通过安全校验:白名单扩展名、≤ 512 KB、symlink 解析后目标也必须是白名单扩展名。校验失败则清空该规则。
|
||||
3. 否则 → **内联内容**。
|
||||
|
||||
```json
|
||||
{
|
||||
"rules": [
|
||||
{
|
||||
"path": "**/*mapper*.xml",
|
||||
"rule": "docs/sql-rules.md"
|
||||
},
|
||||
{
|
||||
"path": "**/*.java",
|
||||
"rule": "始终检查空值安全和资源泄漏"
|
||||
},
|
||||
{
|
||||
"path": "**/*.go",
|
||||
"rule": "shared/go-concurrency.md"
|
||||
},
|
||||
{
|
||||
"path": "**/*.py",
|
||||
"rule": "/Users/me/team-rules/python.md"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `docs/sql-rules.md` — 相对路径,从 `<project>/docs/sql-rules.md` 加载。
|
||||
- `始终检查空值安全…` — 内联字符串,直接使用。
|
||||
- `shared/go-concurrency.md` — 相对路径,同上。
|
||||
- `/Users/me/team-rules/python.md` — 绝对路径,直接使用。
|
||||
|
||||
> 绝对路径可以访问项目目录之外的文件,这是有意为之的设计——`rule.json` 由项目维护者编写,属于受信输入。团队可将共享规则放在统一路径下(如 `/opt/company-rules/`),无需在各项目中复制。
|
||||
|
||||
### 路径过滤
|
||||
|
||||
规则文件同时支持 `include` 和 `exclude` 字段,用于控制哪些文件进入审查范围:
|
||||
|
|
@ -611,15 +746,22 @@ OCR 通过四层优先级链解析评审规则。每层采用首次匹配原则
|
|||
| `providers.<name>.models` | array | 用于交互式选择的可选供应商模型列表 |
|
||||
| `providers.<name>.auth_header` | string | `x-api-key` \| `authorization` |
|
||||
| `providers.<name>.extra_body` | object | 合并到每个请求体的 JSON 对象 |
|
||||
| `providers.<name>.timeout_sec` | integer | 每次请求的 HTTP 超时时间(秒),默认 `300` |
|
||||
| `providers.<name>.extra_headers` | string | 逗号分隔的 `key=value` HTTP 头 |
|
||||
| `custom_providers.<name>.*` | — | 与 `providers.<name>.*` 相同的字段,包括可选的 `models` |
|
||||
| `llm.url` | string | `https://api.openai.com/v1/chat/completions` |
|
||||
| `llm.auth_token` | string | `sk-xxxxxxx` |
|
||||
| `llm.auth_header` | string | 仅 Anthropic:`x-api-key` \| `authorization` |
|
||||
| `llm.extra_body` | object | 合并到每个请求体的 JSON 对象 |
|
||||
| `llm.timeout_sec` | integer | 每次请求的 HTTP 超时时间(秒),默认 `300` |
|
||||
| `llm.extra_headers` | string | 逗号分隔的 `key=value` HTTP 头 |
|
||||
| `llm.model` | string | `claude-opus-4-6` |
|
||||
| `llm.use_anthropic` | boolean | `true` \| `false` |
|
||||
| `mcp_servers.<name>.command` | string | 启动 MCP 服务器的命令 |
|
||||
| `mcp_servers.<name>.args` | array | MCP 服务器的命令行参数 |
|
||||
| `mcp_servers.<name>.env` | array | 环境变量,`KEY=VALUE` 格式 |
|
||||
| `mcp_servers.<name>.tools` | array | 允许使用的工具名称(为空则允许所有工具) |
|
||||
| `mcp_servers.<name>.setup` | string | 启动服务器前运行的初始化命令 |
|
||||
| `language` | string | 任意语言名称,例如 `English`、`Chinese`(默认:`English`) |
|
||||
| `telemetry.enabled` | boolean | `true` \| `false` |
|
||||
| `telemetry.exporter` | string | `console` \| `otlp` |
|
||||
|
|
@ -628,6 +770,43 @@ OCR 通过四层优先级链解析评审规则。每层采用首次匹配原则
|
|||
|
||||
环境变量优先级高于配置文件。
|
||||
|
||||
### MCP Server
|
||||
|
||||
Open Code Review 支持 [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) 服务器,允许评审 Agent 在代码评审过程中通过 stdio 传输协议调用外部工具。
|
||||
|
||||
通过 CLI 配置 MCP 服务器:
|
||||
|
||||
```bash
|
||||
# 添加 MCP 服务器
|
||||
ocr config set mcp_servers.<name>.command <command>
|
||||
ocr config set mcp_servers.<name>.args '["arg1","arg2"]'
|
||||
ocr config set mcp_servers.<name>.env '["KEY=VALUE"]'
|
||||
ocr config set mcp_servers.<name>.tools '["tool_name"]'
|
||||
ocr config set mcp_servers.<name>.setup '<setup command>'
|
||||
|
||||
# 删除 MCP 服务器
|
||||
ocr config unset mcp_servers.<name>
|
||||
```
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `command` | 是 | 启动 MCP 服务器的可执行命令 |
|
||||
| `args` | 否 | 传递给服务器的命令行参数 |
|
||||
| `env` | 否 | 环境变量,`KEY=VALUE` 格式 |
|
||||
| `tools` | 否 | 允许使用的工具名称;为空则服务器的所有工具均可用 |
|
||||
| `setup` | 否 | 启动服务器前运行的 shell 命令(例如构建索引) |
|
||||
|
||||
> **注意:** 如果 MCP 工具的名称与内置工具冲突,该工具将被跳过并输出警告。`setup` 命令的超时时间为 5 分钟。
|
||||
|
||||
**示例:添加 [CodeGraph](https://github.com/nicholasgasior/codegraph) 增强代码结构分析能力**
|
||||
|
||||
```bash
|
||||
ocr config set mcp_servers.codegraph.command codegraph
|
||||
ocr config set mcp_servers.codegraph.args '["serve","--mcp"]'
|
||||
ocr config set mcp_servers.codegraph.tools '["codegraph_explore"]'
|
||||
ocr config set mcp_servers.codegraph.setup 'codegraph init && codegraph index'
|
||||
```
|
||||
|
||||
### 环境变量
|
||||
|
||||
| 变量 | 用途 |
|
||||
|
|
@ -637,6 +816,7 @@ OCR 通过四层优先级链解析评审规则。每层采用首次匹配原则
|
|||
| `OCR_LLM_AUTH_HEADER` | Anthropic 认证头(`x-api-key` 或 `authorization`) |
|
||||
| `OCR_LLM_EXTRA_HEADERS` | 逗号分隔的 `key=value` HTTP 头 |
|
||||
| `OCR_LLM_MODEL` | 模型名称 |
|
||||
| `OCR_LLM_TIMEOUT` | 每次请求的 HTTP 超时时间(秒),覆盖配置文件中的 `timeout_sec` |
|
||||
| `OCR_USE_ANTHROPIC` | `true` = Anthropic,`false` = OpenAI |
|
||||
|
||||
|
||||
|
|
@ -652,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
|
|
@ -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
|
||||
|
||||
|
|
|
|||
16
SECURITY.md
|
|
@ -43,6 +43,22 @@ Out of scope:
|
|||
- Denial-of-service attacks that require local access.
|
||||
- Social engineering attacks.
|
||||
|
||||
## Release Signatures
|
||||
|
||||
All release binaries and checksums are signed using [GitHub Artifact Attestations](https://docs.github.com/en/actions/security-for-github-actions/using-artifact-attestations/using-artifact-attestations-to-establish-provenance-for-builds) (Sigstore). Signatures are keyless — backed by GitHub Actions OIDC, with no long-lived private key. Version tags are signed with SSH keys via `git tag -s`.
|
||||
|
||||
To verify a downloaded binary:
|
||||
|
||||
```bash
|
||||
gh attestation verify opencodereview-linux-amd64 --repo alibaba/open-code-review
|
||||
```
|
||||
|
||||
To verify a version tag:
|
||||
|
||||
```bash
|
||||
git tag -v v1.6.4
|
||||
```
|
||||
|
||||
## Recognition
|
||||
|
||||
We appreciate the security research community's efforts. Reporters who follow responsible disclosure will be credited in the release notes (unless they prefer to remain anonymous).
|
||||
|
|
|
|||
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
|
|
@ -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
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -94,17 +94,23 @@ func runConfigSet(key, value string) error {
|
|||
|
||||
func runConfigUnset(key string) error {
|
||||
parts := strings.SplitN(key, ".", 2)
|
||||
if len(parts) != 2 || parts[0] != "custom_providers" || parts[1] == "" {
|
||||
return fmt.Errorf("unset only supports custom_providers.<name>")
|
||||
if len(parts) != 2 || parts[1] == "" {
|
||||
return fmt.Errorf("unset supports custom_providers.<name> and mcp_servers.<name>")
|
||||
}
|
||||
name := parts[1]
|
||||
|
||||
configPath, err := defaultConfigPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return unsetCustomProvider(configPath, name)
|
||||
switch parts[0] {
|
||||
case "custom_providers":
|
||||
return unsetCustomProvider(configPath, parts[1])
|
||||
case "mcp_servers":
|
||||
return unsetMCPServer(configPath, parts[1])
|
||||
default:
|
||||
return fmt.Errorf("unset supports custom_providers.<name> and mcp_servers.<name>")
|
||||
}
|
||||
}
|
||||
|
||||
func unsetCustomProvider(configPath, name string) error {
|
||||
|
|
@ -130,6 +136,32 @@ func unsetCustomProvider(configPath, name string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func unsetMCPServer(configPath, name string) error {
|
||||
cfg, err := loadOrCreateConfig(configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load config: %w", err)
|
||||
}
|
||||
|
||||
if cfg.MCPServers == nil {
|
||||
return fmt.Errorf("MCP server %q not found", name)
|
||||
}
|
||||
if _, exists := cfg.MCPServers[name]; !exists {
|
||||
return fmt.Errorf("MCP server %q not found", name)
|
||||
}
|
||||
|
||||
delete(cfg.MCPServers, name)
|
||||
if len(cfg.MCPServers) == 0 {
|
||||
cfg.MCPServers = nil
|
||||
}
|
||||
|
||||
if err := saveConfig(configPath, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("Deleted MCP server %q.\n", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteCustomProvider removes a custom provider from cfg in memory.
|
||||
// Returns true if the deleted provider was the active one.
|
||||
func deleteCustomProvider(cfg *Config, name string) (bool, error) {
|
||||
|
|
@ -166,15 +198,25 @@ type ProviderEntry struct {
|
|||
ExtraHeaders map[string]string `json:"extra_headers,omitempty"`
|
||||
}
|
||||
|
||||
// MCPServerConfig holds configuration for a single MCP server (stdio transport).
|
||||
type MCPServerConfig struct {
|
||||
Command string `json:"command"`
|
||||
Args []string `json:"args,omitempty"`
|
||||
Env []string `json:"env,omitempty"`
|
||||
Tools []string `json:"tools,omitempty"`
|
||||
Setup string `json:"setup,omitempty"`
|
||||
}
|
||||
|
||||
// Config represents the user-level configuration file (~/.opencodereview/config.json).
|
||||
type Config struct {
|
||||
Provider string `json:"provider,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Providers map[string]ProviderEntry `json:"providers,omitempty"`
|
||||
CustomProviders map[string]ProviderEntry `json:"custom_providers,omitempty"`
|
||||
Llm LlmConfig `json:"llm,omitempty"`
|
||||
Language string `json:"language,omitempty"`
|
||||
Telemetry *TelemetryConfig `json:"telemetry,omitempty"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Providers map[string]ProviderEntry `json:"providers,omitempty"`
|
||||
CustomProviders map[string]ProviderEntry `json:"custom_providers,omitempty"`
|
||||
Llm LlmConfig `json:"llm,omitempty"`
|
||||
Language string `json:"language,omitempty"`
|
||||
Telemetry *TelemetryConfig `json:"telemetry,omitempty"`
|
||||
MCPServers map[string]MCPServerConfig `json:"mcp_servers,omitempty"`
|
||||
}
|
||||
|
||||
type LlmConfig struct {
|
||||
|
|
@ -234,6 +276,9 @@ func setConfigValue(cfg *Config, key, value string) error {
|
|||
if strings.HasPrefix(key, "custom_providers.") {
|
||||
return setCustomProviderValue(cfg, key, value)
|
||||
}
|
||||
if strings.HasPrefix(key, "mcp_servers.") {
|
||||
return setMCPServerValue(cfg, key, value)
|
||||
}
|
||||
|
||||
switch key {
|
||||
case "provider":
|
||||
|
|
@ -329,7 +374,7 @@ func setConfigValue(cfg *Config, key, value string) error {
|
|||
}
|
||||
cfg.Llm.ExtraBody = m
|
||||
default:
|
||||
return fmt.Errorf("unknown config key: %s\nSupported keys: provider, model, providers.<name>.<field>, custom_providers.<name>.<field>, llm.url, llm.auth_token, llm.auth_header, llm.model, llm.use_anthropic, llm.extra_body, llm.extra_headers, language, telemetry.enabled, telemetry.exporter, telemetry.otlp_endpoint, telemetry.content_logging\nProvider fields: api_key, url, protocol, model, models, auth_header, extra_body, extra_headers", key)
|
||||
return fmt.Errorf("unknown config key: %s\nSupported keys: provider, model, providers.<name>.<field>, custom_providers.<name>.<field>, mcp_servers.<name>.<field>, llm.url, llm.auth_token, llm.auth_header, llm.model, llm.use_anthropic, llm.extra_body, llm.extra_headers, language, telemetry.enabled, telemetry.exporter, telemetry.otlp_endpoint, telemetry.content_logging\nProvider fields: api_key, url, protocol, model, models, auth_header, extra_body, extra_headers\nMCP server fields: command, args, env, tools, setup", key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -435,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] == "" {
|
||||
|
|
@ -491,6 +526,70 @@ func setCustomProviderField(cfg *Config, name, field, key, value string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func setMCPServerValue(cfg *Config, key, value string) error {
|
||||
parts := strings.SplitN(key, ".", 3)
|
||||
if len(parts) != 3 || parts[1] == "" || parts[2] == "" {
|
||||
return fmt.Errorf("invalid MCP server key %q: expected mcp_servers.<name>.<field>", key)
|
||||
}
|
||||
name, field := parts[1], parts[2]
|
||||
|
||||
if cfg.MCPServers == nil {
|
||||
cfg.MCPServers = make(map[string]MCPServerConfig)
|
||||
}
|
||||
entry := cfg.MCPServers[name]
|
||||
|
||||
switch field {
|
||||
case "command":
|
||||
if value == "" {
|
||||
return fmt.Errorf("MCP server command cannot be empty")
|
||||
}
|
||||
entry.Command = value
|
||||
case "args":
|
||||
var args []string
|
||||
if err := json.Unmarshal([]byte(value), &args); err != nil {
|
||||
return fmt.Errorf("invalid JSON array for %s: %w", key, err)
|
||||
}
|
||||
entry.Args = args
|
||||
case "env":
|
||||
var env []string
|
||||
if err := json.Unmarshal([]byte(value), &env); err != nil {
|
||||
return fmt.Errorf("invalid JSON array for %s: %w", key, err)
|
||||
}
|
||||
for _, e := range env {
|
||||
idx := strings.Index(e, "=")
|
||||
if idx <= 0 {
|
||||
return fmt.Errorf("invalid env entry %q: must be in KEY=VALUE format", e)
|
||||
}
|
||||
}
|
||||
entry.Env = env
|
||||
case "tools":
|
||||
var tools []string
|
||||
if err := json.Unmarshal([]byte(value), &tools); err != nil {
|
||||
return fmt.Errorf("invalid JSON array for %s: %w", key, err)
|
||||
}
|
||||
seen := make(map[string]struct{}, len(tools))
|
||||
filtered := make([]string, 0, len(tools))
|
||||
for _, t := range tools {
|
||||
if t == "" {
|
||||
return fmt.Errorf("tool names in %s must not be empty", key)
|
||||
}
|
||||
if _, dup := seen[t]; dup {
|
||||
continue
|
||||
}
|
||||
seen[t] = struct{}{}
|
||||
filtered = append(filtered, t)
|
||||
}
|
||||
entry.Tools = filtered
|
||||
case "setup":
|
||||
entry.Setup = value
|
||||
default:
|
||||
return fmt.Errorf("unknown MCP server field %q: supported fields are command, args, env, tools, setup", field)
|
||||
}
|
||||
|
||||
cfg.MCPServers[name] = entry
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Config) ensureTelemetry() {
|
||||
if c.Telemetry == nil {
|
||||
c.Telemetry = &TelemetryConfig{}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
|
|
@ -417,6 +418,609 @@ func TestUnsetInvalidKey(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestMergeModelLists(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
lists [][]string
|
||||
want []string
|
||||
}{
|
||||
{"empty", nil, nil},
|
||||
{"single list", [][]string{{"a", "b"}}, []string{"a", "b"}},
|
||||
{"merge with dedup", [][]string{{"a", "b"}, {"b", "c"}}, []string{"a", "b", "c"}},
|
||||
{"three lists", [][]string{{"x"}, {"y"}, {"x", "z"}}, []string{"x", "y", "z"}},
|
||||
{"empty strings filtered", [][]string{{"a", "", "b"}}, []string{"a", "b"}},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := mergeModelLists(tc.lists...)
|
||||
if len(got) != len(tc.want) {
|
||||
t.Fatalf("mergeModelLists() = %v, want %v", got, tc.want)
|
||||
}
|
||||
for i := range tc.want {
|
||||
if got[i] != tc.want[i] {
|
||||
t.Errorf("[%d] = %q, want %q", i, got[i], tc.want[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetMCPServerValue_Command(t *testing.T) {
|
||||
cfg := &Config{}
|
||||
if err := setMCPServerValue(cfg, "mcp_servers.my-server.command", "npx"); err != nil {
|
||||
t.Fatalf("setMCPServerValue: %v", err)
|
||||
}
|
||||
if cfg.MCPServers["my-server"].Command != "npx" {
|
||||
t.Errorf("Command = %q, want %q", cfg.MCPServers["my-server"].Command, "npx")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetMCPServerValue_CommandEmpty(t *testing.T) {
|
||||
cfg := &Config{}
|
||||
if err := setMCPServerValue(cfg, "mcp_servers.my-server.command", ""); err == nil {
|
||||
t.Fatal("expected error for empty command")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetMCPServerValue_Args(t *testing.T) {
|
||||
cfg := &Config{}
|
||||
if err := setMCPServerValue(cfg, "mcp_servers.my-server.args", `["--port","8080"]`); err != nil {
|
||||
t.Fatalf("setMCPServerValue: %v", err)
|
||||
}
|
||||
args := cfg.MCPServers["my-server"].Args
|
||||
if len(args) != 2 || args[0] != "--port" || args[1] != "8080" {
|
||||
t.Errorf("Args = %v", args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetMCPServerValue_ArgsInvalidJSON(t *testing.T) {
|
||||
cfg := &Config{}
|
||||
if err := setMCPServerValue(cfg, "mcp_servers.my-server.args", "not-json"); err == nil {
|
||||
t.Fatal("expected error for invalid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetMCPServerValue_Env(t *testing.T) {
|
||||
cfg := &Config{}
|
||||
if err := setMCPServerValue(cfg, "mcp_servers.my-server.env", `["FOO=bar","BAZ=qux"]`); err != nil {
|
||||
t.Fatalf("setMCPServerValue: %v", err)
|
||||
}
|
||||
env := cfg.MCPServers["my-server"].Env
|
||||
if len(env) != 2 || env[0] != "FOO=bar" {
|
||||
t.Errorf("Env = %v", env)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetMCPServerValue_EnvInvalidJSON(t *testing.T) {
|
||||
cfg := &Config{}
|
||||
if err := setMCPServerValue(cfg, "mcp_servers.my-server.env", "not-json"); err == nil {
|
||||
t.Fatal("expected error for invalid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetMCPServerValue_EnvInvalidFormat(t *testing.T) {
|
||||
cfg := &Config{}
|
||||
if err := setMCPServerValue(cfg, "mcp_servers.my-server.env", `["NOEQUALS"]`); err == nil {
|
||||
t.Fatal("expected error for env entry without KEY=VALUE format")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetMCPServerValue_Tools(t *testing.T) {
|
||||
cfg := &Config{}
|
||||
if err := setMCPServerValue(cfg, "mcp_servers.my-server.tools", `["search","read","search"]`); err != nil {
|
||||
t.Fatalf("setMCPServerValue: %v", err)
|
||||
}
|
||||
tools := cfg.MCPServers["my-server"].Tools
|
||||
if len(tools) != 2 || tools[0] != "search" || tools[1] != "read" {
|
||||
t.Errorf("Tools = %v (expected deduped)", tools)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetMCPServerValue_ToolsInvalidJSON(t *testing.T) {
|
||||
cfg := &Config{}
|
||||
if err := setMCPServerValue(cfg, "mcp_servers.my-server.tools", "not-json"); err == nil {
|
||||
t.Fatal("expected error for invalid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetMCPServerValue_ToolsEmptyName(t *testing.T) {
|
||||
cfg := &Config{}
|
||||
if err := setMCPServerValue(cfg, "mcp_servers.my-server.tools", `["search",""]`); err == nil {
|
||||
t.Fatal("expected error for empty tool name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetMCPServerValue_Setup(t *testing.T) {
|
||||
cfg := &Config{}
|
||||
if err := setMCPServerValue(cfg, "mcp_servers.my-server.setup", "init-script.sh"); err != nil {
|
||||
t.Fatalf("setMCPServerValue: %v", err)
|
||||
}
|
||||
if cfg.MCPServers["my-server"].Setup != "init-script.sh" {
|
||||
t.Errorf("Setup = %q", cfg.MCPServers["my-server"].Setup)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetMCPServerValue_UnknownField(t *testing.T) {
|
||||
cfg := &Config{}
|
||||
if err := setMCPServerValue(cfg, "mcp_servers.my-server.unknown", "val"); err == nil {
|
||||
t.Fatal("expected error for unknown field")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetMCPServerValue_InvalidKey(t *testing.T) {
|
||||
cfg := &Config{}
|
||||
tests := []string{
|
||||
"mcp_servers",
|
||||
"mcp_servers.",
|
||||
"mcp_servers..command",
|
||||
"mcp_servers.name",
|
||||
}
|
||||
for _, key := range tests {
|
||||
if err := setMCPServerValue(cfg, key, "val"); err == nil {
|
||||
t.Errorf("expected error for key %q", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetMCPServerValue_ExistingServer(t *testing.T) {
|
||||
cfg := &Config{
|
||||
MCPServers: map[string]MCPServerConfig{
|
||||
"srv": {Command: "old-cmd"},
|
||||
},
|
||||
}
|
||||
if err := setMCPServerValue(cfg, "mcp_servers.srv.command", "new-cmd"); err != nil {
|
||||
t.Fatalf("setMCPServerValue: %v", err)
|
||||
}
|
||||
if cfg.MCPServers["srv"].Command != "new-cmd" {
|
||||
t.Errorf("Command = %q, want %q", cfg.MCPServers["srv"].Command, "new-cmd")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsetMCPServer(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := dir + "/config.json"
|
||||
|
||||
cfg := &Config{
|
||||
MCPServers: map[string]MCPServerConfig{
|
||||
"srv1": {Command: "cmd1"},
|
||||
"srv2": {Command: "cmd2"},
|
||||
},
|
||||
}
|
||||
if err := saveConfig(configPath, cfg); err != nil {
|
||||
t.Fatalf("saveConfig: %v", err)
|
||||
}
|
||||
|
||||
if err := unsetMCPServer(configPath, "srv1"); err != nil {
|
||||
t.Fatalf("unsetMCPServer: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := loadOrCreateConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("reload: %v", err)
|
||||
}
|
||||
if _, exists := cfg.MCPServers["srv1"]; exists {
|
||||
t.Error("srv1 should have been deleted")
|
||||
}
|
||||
if _, exists := cfg.MCPServers["srv2"]; !exists {
|
||||
t.Error("srv2 should still exist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsetMCPServer_LastEntry(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := dir + "/config.json"
|
||||
|
||||
cfg := &Config{
|
||||
MCPServers: map[string]MCPServerConfig{
|
||||
"only": {Command: "cmd"},
|
||||
},
|
||||
}
|
||||
if err := saveConfig(configPath, cfg); err != nil {
|
||||
t.Fatalf("saveConfig: %v", err)
|
||||
}
|
||||
|
||||
if err := unsetMCPServer(configPath, "only"); err != nil {
|
||||
t.Fatalf("unsetMCPServer: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := loadOrCreateConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("reload: %v", err)
|
||||
}
|
||||
if cfg.MCPServers != nil {
|
||||
t.Errorf("MCPServers should be nil after deleting last entry, got %v", cfg.MCPServers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsetMCPServer_NotFound(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := dir + "/config.json"
|
||||
|
||||
cfg := &Config{}
|
||||
if err := saveConfig(configPath, cfg); err != nil {
|
||||
t.Fatalf("saveConfig: %v", err)
|
||||
}
|
||||
|
||||
if err := unsetMCPServer(configPath, "nonexistent"); err == nil {
|
||||
t.Fatal("expected error for nil MCPServers")
|
||||
}
|
||||
|
||||
cfg = &Config{
|
||||
MCPServers: map[string]MCPServerConfig{
|
||||
"other": {Command: "cmd"},
|
||||
},
|
||||
}
|
||||
if err := saveConfig(configPath, cfg); err != nil {
|
||||
t.Fatalf("saveConfig: %v", err)
|
||||
}
|
||||
|
||||
if err := unsetMCPServer(configPath, "nonexistent"); err == nil {
|
||||
t.Fatal("expected error for missing server")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunConfigUnset_UnknownPrefix(t *testing.T) {
|
||||
if err := runConfigUnset("providers.anthropic"); err == nil {
|
||||
t.Fatal("expected error for unsupported prefix")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetConfigValueMCPServer(t *testing.T) {
|
||||
cfg := &Config{}
|
||||
if err := setConfigValue(cfg, "mcp_servers.my-server.command", "npx"); err != nil {
|
||||
t.Fatalf("setConfigValue: %v", err)
|
||||
}
|
||||
if cfg.MCPServers["my-server"].Command != "npx" {
|
||||
t.Errorf("Command = %q", cfg.MCPServers["my-server"].Command)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureTelemetry(t *testing.T) {
|
||||
cfg := &Config{}
|
||||
if cfg.Telemetry != nil {
|
||||
t.Fatal("Telemetry should be nil initially")
|
||||
}
|
||||
cfg.ensureTelemetry()
|
||||
if cfg.Telemetry == nil {
|
||||
t.Fatal("Telemetry should be non-nil after ensureTelemetry()")
|
||||
}
|
||||
cfg.ensureTelemetry()
|
||||
if cfg.Telemetry == nil {
|
||||
t.Fatal("Telemetry should remain non-nil on second call")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetConfigValueLlmURL(t *testing.T) {
|
||||
cfg := &Config{}
|
||||
if err := setConfigValue(cfg, "llm.url", "https://example.com/v1"); err != nil {
|
||||
t.Fatalf("setConfigValue: %v", err)
|
||||
}
|
||||
if cfg.Llm.URL != "https://example.com/v1" {
|
||||
t.Errorf("URL = %q", cfg.Llm.URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetConfigValueLlmAuthToken(t *testing.T) {
|
||||
cfg := &Config{}
|
||||
if err := setConfigValue(cfg, "llm.auth_token", "tok-123"); err != nil {
|
||||
t.Fatalf("setConfigValue: %v", err)
|
||||
}
|
||||
if cfg.Llm.AuthToken != "tok-123" {
|
||||
t.Errorf("AuthToken = %q", cfg.Llm.AuthToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetConfigValueLlmModel(t *testing.T) {
|
||||
cfg := &Config{}
|
||||
if err := setConfigValue(cfg, "llm.model", "my-model"); err != nil {
|
||||
t.Fatalf("setConfigValue: %v", err)
|
||||
}
|
||||
if cfg.Llm.Model != "my-model" {
|
||||
t.Errorf("Model = %q", cfg.Llm.Model)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetConfigValueLlmUseAnthropic(t *testing.T) {
|
||||
cfg := &Config{}
|
||||
if err := setConfigValue(cfg, "llm.use_anthropic", "false"); err != nil {
|
||||
t.Fatalf("setConfigValue: %v", err)
|
||||
}
|
||||
if cfg.Llm.UseAnthropic == nil || *cfg.Llm.UseAnthropic != false {
|
||||
t.Errorf("UseAnthropic = %v", cfg.Llm.UseAnthropic)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetConfigValueLlmUseAnthropicInvalid(t *testing.T) {
|
||||
cfg := &Config{}
|
||||
if err := setConfigValue(cfg, "llm.use_anthropic", "notbool"); err == nil {
|
||||
t.Fatal("expected error for invalid boolean")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetConfigValueLanguage(t *testing.T) {
|
||||
cfg := &Config{}
|
||||
if err := setConfigValue(cfg, "language", "English"); err != nil {
|
||||
t.Fatalf("setConfigValue: %v", err)
|
||||
}
|
||||
if cfg.Language != "English" {
|
||||
t.Errorf("Language = %q", cfg.Language)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetConfigValueTelemetryEnabled(t *testing.T) {
|
||||
cfg := &Config{}
|
||||
if err := setConfigValue(cfg, "telemetry.enabled", "true"); err != nil {
|
||||
t.Fatalf("setConfigValue: %v", err)
|
||||
}
|
||||
if cfg.Telemetry == nil || !cfg.Telemetry.Enabled {
|
||||
t.Error("Telemetry.Enabled should be true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetConfigValueTelemetryEnabledInvalid(t *testing.T) {
|
||||
cfg := &Config{}
|
||||
if err := setConfigValue(cfg, "telemetry.enabled", "notbool"); err == nil {
|
||||
t.Fatal("expected error for invalid boolean")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetConfigValueTelemetryExporter(t *testing.T) {
|
||||
cfg := &Config{}
|
||||
if err := setConfigValue(cfg, "telemetry.exporter", "otlp"); err != nil {
|
||||
t.Fatalf("setConfigValue: %v", err)
|
||||
}
|
||||
if cfg.Telemetry.Exporter != "otlp" {
|
||||
t.Errorf("Exporter = %q", cfg.Telemetry.Exporter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetConfigValueTelemetryOTLPEndpoint(t *testing.T) {
|
||||
cfg := &Config{}
|
||||
if err := setConfigValue(cfg, "telemetry.otlp_endpoint", "localhost:4317"); err != nil {
|
||||
t.Fatalf("setConfigValue: %v", err)
|
||||
}
|
||||
if cfg.Telemetry.OTLPEndpoint != "localhost:4317" {
|
||||
t.Errorf("OTLPEndpoint = %q", cfg.Telemetry.OTLPEndpoint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetConfigValueTelemetryContentLogging(t *testing.T) {
|
||||
cfg := &Config{}
|
||||
if err := setConfigValue(cfg, "telemetry.content_logging", "true"); err != nil {
|
||||
t.Fatalf("setConfigValue: %v", err)
|
||||
}
|
||||
if !cfg.Telemetry.ContentLog {
|
||||
t.Error("ContentLog should be true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetConfigValueTelemetryContentLoggingInvalid(t *testing.T) {
|
||||
cfg := &Config{}
|
||||
if err := setConfigValue(cfg, "telemetry.content_logging", "notbool"); err == nil {
|
||||
t.Fatal("expected error for invalid boolean")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetConfigValueLlmExtraBody(t *testing.T) {
|
||||
cfg := &Config{}
|
||||
if err := setConfigValue(cfg, "llm.extra_body", `{"key":"val"}`); err != nil {
|
||||
t.Fatalf("setConfigValue: %v", err)
|
||||
}
|
||||
if cfg.Llm.ExtraBody == nil {
|
||||
t.Fatal("ExtraBody should not be nil")
|
||||
}
|
||||
if cfg.Llm.ExtraBody["key"] != "val" {
|
||||
t.Errorf("ExtraBody[\"key\"] = %v", cfg.Llm.ExtraBody["key"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetConfigValueLlmExtraBodyInvalid(t *testing.T) {
|
||||
cfg := &Config{}
|
||||
if err := setConfigValue(cfg, "llm.extra_body", "not-json"); err == nil {
|
||||
t.Fatal("expected error for invalid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetConfigValueUnknownKey(t *testing.T) {
|
||||
cfg := &Config{}
|
||||
if err := setConfigValue(cfg, "unknown.key", "val"); err == nil {
|
||||
t.Fatal("expected error for unknown key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetConfigValueProviderClearsModel(t *testing.T) {
|
||||
cfg := &Config{Provider: "old-provider", Model: "old-model"}
|
||||
if err := setConfigValue(cfg, "provider", "new-provider"); err != nil {
|
||||
t.Fatalf("setConfigValue: %v", err)
|
||||
}
|
||||
if cfg.Model != "" {
|
||||
t.Errorf("Model should be cleared on provider change, got %q", cfg.Model)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunConfigUnset_InvalidKey(t *testing.T) {
|
||||
if err := runConfigUnset("provider"); err == nil {
|
||||
t.Fatal("expected error for non custom_providers key")
|
||||
}
|
||||
if err := runConfigUnset("custom_providers."); err == nil {
|
||||
t.Fatal("expected error for empty provider name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunConfig_EmptyArgs(t *testing.T) {
|
||||
err := runConfig(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("runConfig with nil args should print usage, got error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunConfig_ProviderWithArgs(t *testing.T) {
|
||||
err := runConfig([]string{"provider", "extra"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when provider has args")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunConfig_ModelWithArgs(t *testing.T) {
|
||||
err := runConfig([]string{"model", "extra"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when model has args")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteCustomProvider_NotFound(t *testing.T) {
|
||||
cfg := &Config{}
|
||||
_, err := deleteCustomProvider(cfg, "nonexistent")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nil CustomProviders")
|
||||
}
|
||||
|
||||
cfg.CustomProviders = map[string]ProviderEntry{"other": {}}
|
||||
_, err = deleteCustomProvider(cfg, "nonexistent")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing provider")
|
||||
}
|
||||
}
|
||||
|
||||
func TestActiveModelForProvider(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg *Config
|
||||
provider string
|
||||
entry ProviderEntry
|
||||
want string
|
||||
}{
|
||||
{"entry model", nil, "p", ProviderEntry{Model: "m1"}, "m1"},
|
||||
{"cfg model", &Config{Provider: "p", Model: "m2"}, "p", ProviderEntry{}, "m2"},
|
||||
{"entry takes precedence", &Config{Provider: "p", Model: "m2"}, "p", ProviderEntry{Model: "m1"}, "m1"},
|
||||
{"different provider", &Config{Provider: "other", Model: "m2"}, "p", ProviderEntry{}, ""},
|
||||
{"no model", &Config{Provider: "p"}, "p", ProviderEntry{}, ""},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := activeModelForProvider(tc.cfg, tc.provider, tc.entry)
|
||||
if got != tc.want {
|
||||
t.Errorf("got %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeModelList(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
models []string
|
||||
want []string
|
||||
}{
|
||||
{"dedup", []string{"a", "b", "a"}, []string{"a", "b"}},
|
||||
{"trim spaces", []string{" a ", " b "}, []string{"a", "b"}},
|
||||
{"filter empty", []string{"a", "", "b"}, []string{"a", "b"}},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := normalizeModelList(tc.models)
|
||||
if len(got) != len(tc.want) {
|
||||
t.Fatalf("got %v, want %v", got, tc.want)
|
||||
}
|
||||
for i := range tc.want {
|
||||
if got[i] != tc.want[i] {
|
||||
t.Errorf("[%d] = %q, want %q", i, got[i], tc.want[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseModelListValue(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
want int
|
||||
}{
|
||||
{"empty", "", 0},
|
||||
{"json array", `["a","b"]`, 2},
|
||||
{"comma separated", "a,b,c", 3},
|
||||
{"bracket unquoted", "[a,b]", 2},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := parseModelListValue(tc.value)
|
||||
if err != nil {
|
||||
t.Fatalf("parseModelListValue: %v", err)
|
||||
}
|
||||
if len(got) != tc.want {
|
||||
t.Errorf("got %d models, want %d: %v", len(got), tc.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConfigPath_Default(t *testing.T) {
|
||||
t.Setenv("OCR_CONFIG_PATH", "")
|
||||
p, err := resolveConfigPath()
|
||||
if err != nil {
|
||||
t.Fatalf("resolveConfigPath: %v", err)
|
||||
}
|
||||
if p == "" {
|
||||
t.Fatal("expected non-empty default config path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConfigPath_Env(t *testing.T) {
|
||||
t.Setenv("OCR_CONFIG_PATH", "/tmp/test-config.json")
|
||||
p, err := resolveConfigPath()
|
||||
if err != nil {
|
||||
t.Fatalf("resolveConfigPath: %v", err)
|
||||
}
|
||||
if p != "/tmp/test-config.json" {
|
||||
t.Errorf("path = %q, want /tmp/test-config.json", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadOrCreateConfig_NewFile(t *testing.T) {
|
||||
cfg, err := loadOrCreateConfig(t.TempDir() + "/nonexistent.json")
|
||||
if err != nil {
|
||||
t.Fatalf("loadOrCreateConfig: %v", err)
|
||||
}
|
||||
if cfg == nil {
|
||||
t.Fatal("expected non-nil config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadOrCreateConfig_InvalidJSON(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := dir + "/bad.json"
|
||||
if err := os.WriteFile(path, []byte("{invalid"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := loadOrCreateConfig(path)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAppConfig_NotExist(t *testing.T) {
|
||||
cfg, err := LoadAppConfig(t.TempDir() + "/none.json")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadAppConfig: %v", err)
|
||||
}
|
||||
if cfg != nil {
|
||||
t.Fatal("expected nil config for non-existent file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAppConfig_InvalidJSON(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := dir + "/bad.json"
|
||||
if err := os.WriteFile(path, []byte("not json"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := LoadAppConfig(path)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureModelInList(t *testing.T) {
|
||||
models := []string{"test-model", "test-model-2", "bbb", "aaa", "test-model-3"}
|
||||
|
||||
|
|
|
|||
30
cmd/opencodereview/config_dispatch_test.go
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRunConfig_UnknownSubcommand(t *testing.T) {
|
||||
err := runConfig([]string{"delete", "foo"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown subcommand")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unknown") {
|
||||
t.Errorf("error = %q, expected to contain 'unknown'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunConfig_InvalidSetMissingValue(t *testing.T) {
|
||||
err := runConfig([]string{"set", "provider"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for set without value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunConfig_InvalidUnsetMissingKey(t *testing.T) {
|
||||
err := runConfig([]string{"unset"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unset without key")
|
||||
}
|
||||
}
|
||||
298
cmd/opencodereview/emit_run_result_test.go
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"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"
|
||||
)
|
||||
|
||||
type mockResultProvider struct {
|
||||
diffs []model.Diff
|
||||
filesReviewed int64
|
||||
inputTokens int64
|
||||
outputTokens int64
|
||||
totalTokens int64
|
||||
cacheReadTokens int64
|
||||
cacheWriteTokens int64
|
||||
warnings []agent.AgentWarning
|
||||
projectSummary string
|
||||
toolCalls map[string]int64
|
||||
resumeInfo *agent.ResumeInfo
|
||||
sessionID string
|
||||
}
|
||||
|
||||
func (m *mockResultProvider) Diffs() []model.Diff { return m.diffs }
|
||||
func (m *mockResultProvider) FilesReviewed() int64 { return m.filesReviewed }
|
||||
func (m *mockResultProvider) TotalInputTokens() int64 { return m.inputTokens }
|
||||
func (m *mockResultProvider) TotalOutputTokens() int64 { return m.outputTokens }
|
||||
func (m *mockResultProvider) TotalTokensUsed() int64 { return m.totalTokens }
|
||||
func (m *mockResultProvider) TotalCacheReadTokens() int64 { return m.cacheReadTokens }
|
||||
func (m *mockResultProvider) TotalCacheWriteTokens() int64 { return m.cacheWriteTokens }
|
||||
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}
|
||||
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.Status != "skipped" {
|
||||
t.Errorf("status = %q, want skipped", out.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitRunResult_JSONWithComments(t *testing.T) {
|
||||
ag := &mockResultProvider{
|
||||
filesReviewed: 3,
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
totalTokens: 150,
|
||||
warnings: []agent.AgentWarning{{Type: "info", Message: "note"}},
|
||||
toolCalls: map[string]int64{"file_read": 2},
|
||||
}
|
||||
comments := []model.LlmComment{{Path: "main.go", Content: "fix", StartLine: 1, EndLine: 2}}
|
||||
got := captureStdout(t, func() {
|
||||
err := emitRunResult(context.Background(), ag, comments, 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 len(out.Comments) != 1 {
|
||||
t.Errorf("expected 1 comment, got %d", len(out.Comments))
|
||||
}
|
||||
if out.Summary == nil || out.Summary.FilesReviewed != 3 {
|
||||
t.Errorf("summary.FilesReviewed = %v", out.Summary)
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
err := emitRunResult(context.Background(), ag, nil, time.Now(), "text", "developer", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
if !strings.Contains(got, "Looks good to me") {
|
||||
t.Errorf("expected 'Looks good to me', got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
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}}
|
||||
got := captureStdout(t, func() {
|
||||
err := emitRunResult(context.Background(), ag, comments, time.Now(), "text", "developer", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
if !strings.Contains(got, "a.go") {
|
||||
t.Errorf("expected path, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "rename") {
|
||||
t.Errorf("expected comment content, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitRunResult_TextWithProjectSummary(t *testing.T) {
|
||||
ag := &mockResultProvider{
|
||||
filesReviewed: 5,
|
||||
projectSummary: "All tests pass, code quality is good.",
|
||||
}
|
||||
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, "Project Summary") {
|
||||
t.Errorf("expected 'Project Summary', got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "All tests pass") {
|
||||
t.Errorf("expected summary content, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitRunResult_AgentTextRestoresQuiet(t *testing.T) {
|
||||
ag := &mockResultProvider{filesReviewed: 1}
|
||||
q := newQuietHandle("text", "agent")
|
||||
got := captureStdout(t, func() {
|
||||
err := emitRunResult(context.Background(), ag, nil, time.Now(), "text", "agent", q)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
if q.fn != nil {
|
||||
t.Error("expected quiet handle to be restored")
|
||||
}
|
||||
_ = got
|
||||
}
|
||||
|
||||
func TestEmitRunResult_AgentJSONDoesNotRestore(t *testing.T) {
|
||||
ag := &mockResultProvider{
|
||||
filesReviewed: 1,
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
totalTokens: 15,
|
||||
}
|
||||
q := newQuietHandle("json", "agent")
|
||||
got := captureStdout(t, func() {
|
||||
err := emitRunResult(context.Background(), ag, nil, time.Now(), "json", "agent", q)
|
||||
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)
|
||||
}
|
||||
q.Restore()
|
||||
}
|
||||
|
||||
func TestEmitRunResult_NilQuietHandle(t *testing.T) {
|
||||
ag := &mockResultProvider{filesReviewed: 1}
|
||||
got := captureStdout(t, func() {
|
||||
err := emitRunResult(context.Background(), ag, nil, time.Now(), "text", "agent", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
_ = 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 ---
|
||||
|
|
@ -275,6 +292,7 @@ func printConfigUsage() {
|
|||
Usage:
|
||||
ocr config set <key> <value>
|
||||
ocr config unset custom_providers.<name> Delete a custom provider
|
||||
ocr config unset mcp_servers.<name> Delete an MCP server
|
||||
ocr config provider Interactive provider setup
|
||||
ocr config model Interactive model selection
|
||||
|
||||
|
|
@ -301,6 +319,14 @@ Examples:
|
|||
# Delete a custom provider
|
||||
ocr config unset custom_providers.my-gateway
|
||||
|
||||
# MCP server configuration (stdio transport)
|
||||
ocr config set mcp_servers.codegraph.command npx
|
||||
ocr config set mcp_servers.codegraph.args '["-y","@anthropic/codegraph-mcp"]'
|
||||
ocr config set mcp_servers.codegraph.env '["CODEGRAPH_TOKEN=xxx"]'
|
||||
|
||||
# Delete an MCP server
|
||||
ocr config unset mcp_servers.codegraph
|
||||
|
||||
# Legacy endpoint configuration
|
||||
ocr config set llm.url https://xx/v1/openai/chat/completions
|
||||
ocr config set llm.auth_token xxxxxxxxxx
|
||||
|
|
@ -310,6 +336,7 @@ Examples:
|
|||
ocr config set language English
|
||||
ocr config set telemetry.enabled true
|
||||
|
||||
Supported keys: provider, model, providers.<name>.<field>, custom_providers.<name>.<field>, llm.url, llm.auth_token, llm.auth_header, llm.model, llm.use_anthropic, llm.extra_body, language, telemetry.enabled, telemetry.exporter, telemetry.otlp_endpoint, telemetry.content_logging
|
||||
Provider fields: api_key, url, protocol, model, models, auth_header, extra_body`)
|
||||
Supported keys: provider, model, providers.<name>.<field>, custom_providers.<name>.<field>, mcp_servers.<name>.<field>, llm.url, llm.auth_token, llm.auth_header, llm.model, llm.use_anthropic, llm.extra_body, llm.extra_headers, language, telemetry.enabled, telemetry.exporter, telemetry.otlp_endpoint, telemetry.content_logging
|
||||
Provider fields: api_key, url, protocol, model, models, auth_header, extra_body, extra_headers
|
||||
MCP server fields: command, args, env, tools, setup`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,23 @@
|
|||
package main
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"testing"
|
||||
"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"})
|
||||
|
|
@ -18,3 +35,191 @@ func TestParseReviewFlagsModelOverride(t *testing.T) {
|
|||
t.Errorf("audience = %q, want %q", opts.audience, "human")
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
t.Fatal("expected error for invalid audience")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseReviewFlags_NegativeMaxTools(t *testing.T) {
|
||||
_, err := parseReviewFlags([]string{"--max-tools", "-1"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for negative max-tools")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseReviewFlags_MaxToolsBelowMin(t *testing.T) {
|
||||
opts, err := parseReviewFlags([]string{"--max-tools", "5"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if opts.maxTools != 10 {
|
||||
t.Errorf("maxTools = %d, want 10 (clamped to min)", opts.maxTools)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseReviewFlags_NegativeMaxGitProcs(t *testing.T) {
|
||||
_, err := parseReviewFlags([]string{"--max-git-procs", "-1"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for negative max-git-procs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseReviewFlags_ConflictingModes(t *testing.T) {
|
||||
_, err := parseReviewFlags([]string{"--from", "main", "--to", "dev", "--commit", "abc"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for conflicting modes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseReviewFlags_FromWithoutTo(t *testing.T) {
|
||||
_, err := parseReviewFlags([]string{"--from", "main"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for --from without --to")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseReviewFlags_ToWithoutFrom(t *testing.T) {
|
||||
_, err := parseReviewFlags([]string{"--to", "dev"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for --to without --from")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseReviewFlags_Help(t *testing.T) {
|
||||
opts, err := parseReviewFlags([]string{"-h"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !opts.showHelp {
|
||||
t.Error("expected showHelp=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseReviewFlags_ShortFlags(t *testing.T) {
|
||||
opts, err := parseReviewFlags([]string{"-c", "abc123", "-f", "json", "-p"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if opts.commit != "abc123" {
|
||||
t.Errorf("commit = %q, want abc123", opts.commit)
|
||||
}
|
||||
if opts.outputFormat != "json" {
|
||||
t.Errorf("outputFormat = %q, want json", opts.outputFormat)
|
||||
}
|
||||
if !opts.preview {
|
||||
t.Error("expected preview=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseConfigArgs_Empty(t *testing.T) {
|
||||
_, err := parseConfigArgs(nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty args")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseConfigArgs_Set(t *testing.T) {
|
||||
act, err := parseConfigArgs([]string{"set", "llm.model", "gpt-4"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if act.subCmd != "set" || act.key != "llm.model" || act.value != "gpt-4" {
|
||||
t.Errorf("got %+v", act)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseConfigArgs_SetMissingValue(t *testing.T) {
|
||||
_, err := parseConfigArgs([]string{"set", "llm.model"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseConfigArgs_Unset(t *testing.T) {
|
||||
act, err := parseConfigArgs([]string{"unset", "custom_providers.foo"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if act.subCmd != "unset" || act.key != "custom_providers.foo" {
|
||||
t.Errorf("got %+v", act)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseConfigArgs_UnsetMissingKey(t *testing.T) {
|
||||
_, err := parseConfigArgs([]string{"unset"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseConfigArgs_UnknownSubCmd(t *testing.T) {
|
||||
_, err := parseConfigArgs([]string{"delete", "foo"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown subcommand")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurationVar(t *testing.T) {
|
||||
fs := newOcrFlagSet("test")
|
||||
var d time.Duration
|
||||
fs.DurationVar(&d, "timeout", 5*time.Second, "max duration")
|
||||
if err := fs.Parse([]string{"--timeout", "10s"}); err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if d != 10*time.Second {
|
||||
t.Errorf("d = %v, want 10s", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintDefaults(t *testing.T) {
|
||||
fs := newOcrFlagSet("test")
|
||||
var s string
|
||||
fs.StringVar(&s, "name", "default", "a name")
|
||||
fs.PrintDefaults()
|
||||
}
|
||||
|
||||
func TestExpandShortFlags(t *testing.T) {
|
||||
m := map[string]string{"c": "commit", "f": "format"}
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want []string
|
||||
}{
|
||||
{"expands short", []string{"-c", "abc"}, []string{"--commit", "abc"}},
|
||||
{"keeps long", []string{"--format", "json"}, []string{"--format", "json"}},
|
||||
{"unknown short kept", []string{"-x", "val"}, []string{"-x", "val"}},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := expandShortFlags(tc.args, m)
|
||||
if len(got) != len(tc.want) {
|
||||
t.Fatalf("got %v, want %v", got, tc.want)
|
||||
}
|
||||
for i := range tc.want {
|
||||
if got[i] != tc.want[i] {
|
||||
t.Errorf("[%d] = %q, want %q", i, got[i], tc.want[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
168
cmd/opencodereview/git_test.go
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func initTestGitRepo(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
cmds := [][]string{
|
||||
{"git", "-C", dir, "init"},
|
||||
{"git", "-C", dir, "config", "user.email", "test@test.com"},
|
||||
{"git", "-C", dir, "config", "user.name", "Test"},
|
||||
}
|
||||
for _, args := range cmds {
|
||||
cmd := exec.Command(args[0], args[1:]...)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("git init: %v: %s", err, out)
|
||||
}
|
||||
}
|
||||
f := filepath.Join(dir, "README.md")
|
||||
if err := os.WriteFile(f, []byte("hello"), 0o644); err != nil {
|
||||
t.Fatalf("write README: %v", err)
|
||||
}
|
||||
cmd := exec.Command("git", "-C", dir, "add", ".")
|
||||
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")
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("git commit: %v: %s", err, out)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestRunGitCmd_Success(t *testing.T) {
|
||||
dir := initTestGitRepo(t)
|
||||
out, err := runGitCmd(dir, "rev-parse", "--git-dir")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
t.Error("expected non-empty output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunGitCmd_Failure(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
_, err := runGitCmd(dir, "rev-parse", "--git-dir")
|
||||
if err == nil {
|
||||
t.Error("expected error for non-git dir")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCommitMessage(t *testing.T) {
|
||||
dir := initTestGitRepo(t)
|
||||
msg, err := getCommitMessage(dir, "HEAD")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if msg != "initial commit" {
|
||||
t.Errorf("msg = %q, want 'initial commit'", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCommitMessage_InvalidCommit(t *testing.T) {
|
||||
dir := initTestGitRepo(t)
|
||||
_, err := getCommitMessage(dir, "nonexistent-ref-xyz")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid commit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRepoDir_ValidGitRepo(t *testing.T) {
|
||||
dir := initTestGitRepo(t)
|
||||
resolved, err := resolveRepoDir(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resolved == "" {
|
||||
t.Error("expected non-empty resolved path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRepoDir_NotGitRepo(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
_, err := resolveRepoDir(dir)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-git dir")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not a git repository") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRepoDir_EmptyUsesWd(t *testing.T) {
|
||||
dir := initTestGitRepo(t)
|
||||
origDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("getwd: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := os.Chdir(origDir); err != nil {
|
||||
t.Errorf("restore chdir: %v", err)
|
||||
}
|
||||
}()
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatalf("chdir: %v", err)
|
||||
}
|
||||
|
||||
resolved, err := resolveRepoDir("")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resolved == "" {
|
||||
t.Error("expected non-empty resolved path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireGitRepo_Valid(t *testing.T) {
|
||||
dir := initTestGitRepo(t)
|
||||
if err := requireGitRepo(dir); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireGitRepo_Invalid(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
err := requireGitRepo(dir)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-git dir")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateReviewRefs_ValidCommit(t *testing.T) {
|
||||
dir := initTestGitRepo(t)
|
||||
err := validateReviewRefs(dir, reviewOptions{commit: "HEAD"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateReviewRefs_InvalidCommit(t *testing.T) {
|
||||
dir := initTestGitRepo(t)
|
||||
err := validateReviewRefs(dir, reviewOptions{commit: "nonexistent-ref-xyz"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid commit ref")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateReviewRefs_EmptySkipped(t *testing.T) {
|
||||
dir := initTestGitRepo(t)
|
||||
err := validateReviewRefs(dir, reviewOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildToolRegistry(t *testing.T) {
|
||||
reg := buildToolRegistry(nil, nil)
|
||||
if reg == nil {
|
||||
t.Fatal("expected non-nil registry")
|
||||
}
|
||||
}
|
||||
|
|
@ -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{
|
||||
|
|
|
|||
578
cmd/opencodereview/output_helpers_test.go
Normal file
|
|
@ -0,0 +1,578 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/open-code-review/open-code-review/internal/agent"
|
||||
"github.com/open-code-review/open-code-review/internal/model"
|
||||
)
|
||||
|
||||
func TestHasSubtaskErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
warnings []agent.AgentWarning
|
||||
want bool
|
||||
}{
|
||||
{"nil warnings", nil, false},
|
||||
{"empty", []agent.AgentWarning{}, false},
|
||||
{"no subtask errors", []agent.AgentWarning{{Type: "other", Message: "msg"}}, false},
|
||||
{"has subtask error", []agent.AgentWarning{{Type: "subtask_error", Message: "fail"}}, true},
|
||||
{"mixed", []agent.AgentWarning{{Type: "warn"}, {Type: "subtask_error"}}, true},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := hasSubtaskErrors(tc.warnings)
|
||||
if got != tc.want {
|
||||
t.Errorf("hasSubtaskErrors() = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapByRunes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
text string
|
||||
maxW int
|
||||
lines int
|
||||
}{
|
||||
{"empty", "", 80, 0},
|
||||
{"short line", "hello", 80, 1},
|
||||
{"exact width", strings.Repeat("a", 10), 10, 1},
|
||||
{"wraps long line", strings.Repeat("word ", 25), 20, 7},
|
||||
{"respects newlines", "line1\nline2\nline3", 80, 3},
|
||||
{"wrap with newlines", "short\n" + strings.Repeat("x", 50), 20, 4},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := wrapByRunes(tc.text, tc.maxW)
|
||||
if len(got) != tc.lines {
|
||||
t.Errorf("wrapByRunes() got %d lines, want %d\nlines: %v", len(got), tc.lines, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapSingleRuneLine(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
line string
|
||||
maxW int
|
||||
min int
|
||||
}{
|
||||
{"short line unchanged", "hello", 100, 1},
|
||||
{"wraps at space", "hello world foo bar baz", 12, 2},
|
||||
{"no space to wrap", strings.Repeat("x", 30), 10, 3},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := wrapSingleRuneLine(tc.line, tc.maxW)
|
||||
if len(got) < tc.min {
|
||||
t.Errorf("got %d lines, want at least %d", len(got), tc.min)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuneWrapCut(t *testing.T) {
|
||||
// Short line returns full length
|
||||
runes := []rune("short")
|
||||
cut := runeWrapCut(runes, 100)
|
||||
if cut != len(runes) {
|
||||
t.Errorf("expected %d, got %d", len(runes), cut)
|
||||
}
|
||||
|
||||
// Cuts at space
|
||||
runes = []rune("hello world test")
|
||||
cut = runeWrapCut(runes, 11)
|
||||
if runes[cut] != ' ' && cut != 11 {
|
||||
t.Errorf("expected cut at space boundary, got %d (char=%c)", cut, runes[cut])
|
||||
}
|
||||
}
|
||||
|
||||
func TestVisibleRunesLen(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want int
|
||||
}{
|
||||
{"hello", 5},
|
||||
{"", 0},
|
||||
{"\x01\x02\x03", 0},
|
||||
{"a\x01b", 2},
|
||||
{"\x7f", 0},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
got := visibleRunesLen([]rune(tc.input))
|
||||
if got != tc.want {
|
||||
t.Errorf("visibleRunesLen(%q) = %d, want %d", tc.input, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitToLines(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want int
|
||||
}{
|
||||
{"a\nb\nc", 3},
|
||||
{"a\nb\nc\n", 3},
|
||||
{"single", 1},
|
||||
{"crlf\r\nline", 2},
|
||||
{"", 0},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
got := splitToLines(tc.input)
|
||||
if len(got) != tc.want {
|
||||
t.Errorf("splitToLines(%q) = %d lines, want %d", tc.input, len(got), tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDiffLines(t *testing.T) {
|
||||
t.Run("empty suggestion returns nil", func(t *testing.T) {
|
||||
c := model.LlmComment{ExistingCode: "old", SuggestionCode: ""}
|
||||
got := buildDiffLines(c)
|
||||
if got != nil {
|
||||
t.Errorf("expected nil, got %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty existing returns nil", func(t *testing.T) {
|
||||
c := model.LlmComment{ExistingCode: "", SuggestionCode: "new"}
|
||||
got := buildDiffLines(c)
|
||||
if got != nil {
|
||||
t.Errorf("expected nil, got %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("diff computed", func(t *testing.T) {
|
||||
c := model.LlmComment{
|
||||
ExistingCode: "line1\nline2\n",
|
||||
SuggestionCode: "line1\nmodified\n",
|
||||
}
|
||||
got := buildDiffLines(c)
|
||||
if len(got) == 0 {
|
||||
t.Error("expected non-empty diff lines")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestOutputJSONWithWarnings_NoCommentsSubtaskError(t *testing.T) {
|
||||
old := os.Stdout
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
|
||||
warnings := []agent.AgentWarning{{Type: "subtask_error", File: "x.go", Message: "fail"}}
|
||||
err := outputJSONWithWarnings(nil, warnings, 1, 10, 5, 15, 0, 0, time.Second, "", nil, "abc123trace", nil, "")
|
||||
_ = w.Close()
|
||||
os.Stdout = old
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("error: %v", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
_, _ = buf.ReadFrom(r)
|
||||
|
||||
var out jsonOutput
|
||||
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) {
|
||||
tests := []struct {
|
||||
status string
|
||||
substr string
|
||||
}{
|
||||
{"added", "[A]"},
|
||||
{"modified", "[M]"},
|
||||
{"deleted", "[D]"},
|
||||
{"renamed", "[R]"},
|
||||
{"binary", "[B]"},
|
||||
{"scan", "[S]"},
|
||||
{"unknown", "[?]"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
got := statusBadge(tc.status)
|
||||
if !strings.Contains(got, tc.substr) {
|
||||
t.Errorf("statusBadge(%q) = %q, expected to contain %q", tc.status, got, tc.substr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutputJSON(t *testing.T) {
|
||||
// Redirect stdout to capture output
|
||||
old := os.Stdout
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
|
||||
comments := []model.LlmComment{
|
||||
{Path: "a.go", Content: "fix bug", StartLine: 1, EndLine: 5},
|
||||
}
|
||||
err := outputJSON(comments)
|
||||
|
||||
_ = w.Close()
|
||||
os.Stdout = old
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("outputJSON error: %v", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
_, _ = buf.ReadFrom(r)
|
||||
|
||||
var out jsonOutput
|
||||
if err := json.Unmarshal(buf.Bytes(), &out); err != nil {
|
||||
t.Fatalf("unmarshal output: %v", err)
|
||||
}
|
||||
if out.Status != "success" {
|
||||
t.Errorf("status = %q, want success", out.Status)
|
||||
}
|
||||
if len(out.Comments) != 1 {
|
||||
t.Errorf("expected 1 comment, got %d", len(out.Comments))
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutputJSON_NoComments(t *testing.T) {
|
||||
old := os.Stdout
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
|
||||
err := outputJSON(nil)
|
||||
|
||||
_ = w.Close()
|
||||
os.Stdout = old
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("outputJSON error: %v", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
_, _ = buf.ReadFrom(r)
|
||||
|
||||
var out jsonOutput
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutputJSONWithWarnings(t *testing.T) {
|
||||
old := os.Stdout
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
|
||||
comments := []model.LlmComment{{Path: "b.go", Content: "test"}}
|
||||
warnings := []agent.AgentWarning{{Type: "subtask_error", File: "c.go", Message: "failed"}}
|
||||
err := outputJSONWithWarnings(comments, warnings, 5, 100, 50, 150, 10, 5, 3*time.Second, "summary", map[string]int64{"file_read": 3}, "trace-xyz-789", nil, "")
|
||||
_ = w.Close()
|
||||
os.Stdout = old
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("error: %v", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
_, _ = buf.ReadFrom(r)
|
||||
|
||||
var out jsonOutput
|
||||
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 out.Summary == nil {
|
||||
t.Fatal("expected non-nil summary")
|
||||
}
|
||||
if out.Summary.FilesReviewed != 5 {
|
||||
t.Errorf("FilesReviewed = %d, want 5", out.Summary.FilesReviewed)
|
||||
}
|
||||
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) {
|
||||
old := os.Stdout
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
|
||||
warnings := []agent.AgentWarning{{Type: "warning", Message: "something"}}
|
||||
err := outputJSONWithWarnings(nil, warnings, 2, 50, 20, 70, 0, 0, time.Second, "", nil, "", nil, "")
|
||||
_ = w.Close()
|
||||
os.Stdout = old
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("error: %v", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
_, _ = buf.ReadFrom(r)
|
||||
|
||||
var out jsonOutput
|
||||
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)
|
||||
}
|
||||
if out.Message == "" {
|
||||
t.Error("expected non-empty message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutputJSONNoFiles(t *testing.T) {
|
||||
old := os.Stdout
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
|
||||
err := outputJSONNoFiles("test-trace-id-456")
|
||||
|
||||
_ = w.Close()
|
||||
os.Stdout = old
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("error: %v", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
_, _ = buf.ReadFrom(r)
|
||||
|
||||
var out jsonOutput
|
||||
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 {
|
||||
t.Helper()
|
||||
old := os.Stdout
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf("os.Pipe: %v", err)
|
||||
}
|
||||
os.Stdout = w
|
||||
fn()
|
||||
_ = w.Close()
|
||||
os.Stdout = old
|
||||
var buf bytes.Buffer
|
||||
_, _ = buf.ReadFrom(r)
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func TestOutputText_NoComments(t *testing.T) {
|
||||
got := captureStdout(t, func() {
|
||||
outputText(nil)
|
||||
})
|
||||
if !strings.Contains(got, "Looks good to me") {
|
||||
t.Errorf("expected 'Looks good to me', got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutputText_WithComments(t *testing.T) {
|
||||
comments := []model.LlmComment{
|
||||
{Path: "main.go", StartLine: 10, EndLine: 15, Content: "potential nil dereference"},
|
||||
}
|
||||
got := captureStdout(t, func() {
|
||||
outputText(comments)
|
||||
})
|
||||
if !strings.Contains(got, "main.go") {
|
||||
t.Errorf("expected path in output, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "potential nil dereference") {
|
||||
t.Errorf("expected comment content in output, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutputTextWithWarnings_NoCommentsNoErrors(t *testing.T) {
|
||||
warnings := []agent.AgentWarning{{Type: "warning", File: "x.go", Message: "slow"}}
|
||||
got := captureStdout(t, func() {
|
||||
outputTextWithWarnings(nil, warnings)
|
||||
})
|
||||
if !strings.Contains(got, "Looks good to me") {
|
||||
t.Errorf("expected 'Looks good to me', got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutputTextWithWarnings_NoCommentsWithSubtaskError(t *testing.T) {
|
||||
warnings := []agent.AgentWarning{{Type: "subtask_error", File: "y.go", Message: "failed"}}
|
||||
got := captureStdout(t, func() {
|
||||
outputTextWithWarnings(nil, warnings)
|
||||
})
|
||||
if !strings.Contains(got, "could not be reviewed") {
|
||||
t.Errorf("expected subtask error message, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutputTextWithWarnings_WithComments(t *testing.T) {
|
||||
comments := []model.LlmComment{
|
||||
{Path: "a.go", StartLine: 1, EndLine: 3, Content: "fix this"},
|
||||
}
|
||||
warnings := []agent.AgentWarning{{Type: "info", File: "b.go", Message: "note"}}
|
||||
got := captureStdout(t, func() {
|
||||
outputTextWithWarnings(comments, warnings)
|
||||
})
|
||||
if !strings.Contains(got, "a.go") {
|
||||
t.Errorf("expected comment path, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "fix this") {
|
||||
t.Errorf("expected comment content, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderComment_EmptyContentNoDiff(t *testing.T) {
|
||||
got := captureStdout(t, func() {
|
||||
renderComment(model.LlmComment{Path: "skip.go", StartLine: 1, EndLine: 1, Content: "", ExistingCode: "", SuggestionCode: ""})
|
||||
})
|
||||
if got != "" {
|
||||
t.Errorf("expected empty output for empty comment, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderComment_ContentOnly(t *testing.T) {
|
||||
got := captureStdout(t, func() {
|
||||
renderComment(model.LlmComment{Path: "file.go", StartLine: 5, EndLine: 10, Content: "consider renaming"})
|
||||
})
|
||||
if !strings.Contains(got, "file.go:5-10") {
|
||||
t.Errorf("expected path:line range, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "consider renaming") {
|
||||
t.Errorf("expected content, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderComment_WithDiff(t *testing.T) {
|
||||
got := captureStdout(t, func() {
|
||||
renderComment(model.LlmComment{
|
||||
Path: "diff.go",
|
||||
StartLine: 1,
|
||||
EndLine: 2,
|
||||
Content: "rename var",
|
||||
ExistingCode: "old := 1\n",
|
||||
SuggestionCode: "new := 1\n",
|
||||
})
|
||||
})
|
||||
if !strings.Contains(got, "diff.go:1-2") {
|
||||
t.Errorf("expected path:line range, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "rename var") {
|
||||
t.Errorf("expected content, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintDiffLine(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
prefix string
|
||||
content string
|
||||
}{
|
||||
{"added", "+", "new line"},
|
||||
{"deleted", "-", "old line"},
|
||||
{"context", " ", "context line"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := captureStdout(t, func() {
|
||||
printDiffLine(tc.prefix, tc.content, "\033[92m", "\033[48;2;0;60;0m")
|
||||
})
|
||||
if !strings.Contains(got, tc.prefix) {
|
||||
t.Errorf("expected prefix %q in output, got %q", tc.prefix, got)
|
||||
}
|
||||
if !strings.Contains(got, tc.content) {
|
||||
t.Errorf("expected content %q in output, got %q", tc.content, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutputPreviewText_NoFiles(t *testing.T) {
|
||||
p := &agent.DiffPreview{TotalFiles: 0}
|
||||
got := captureStdout(t, func() {
|
||||
outputPreviewText(p)
|
||||
})
|
||||
if !strings.Contains(got, "No files changed") {
|
||||
t.Errorf("expected 'No files changed', got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutputPreviewText_WithReviewableFiles(t *testing.T) {
|
||||
p := &agent.DiffPreview{
|
||||
Entries: []agent.DiffPreviewEntry{
|
||||
{Path: "main.go", Status: "modified", Insertions: 10, Deletions: 3, WillReview: true},
|
||||
{Path: "util.go", Status: "added", Insertions: 20, Deletions: 0, WillReview: true},
|
||||
},
|
||||
TotalInsertions: 30,
|
||||
TotalDeletions: 3,
|
||||
TotalFiles: 2,
|
||||
ReviewableCount: 2,
|
||||
ExcludedCount: 0,
|
||||
}
|
||||
got := captureStdout(t, func() {
|
||||
outputPreviewText(p)
|
||||
})
|
||||
if !strings.Contains(got, "2 file(s) changed") {
|
||||
t.Errorf("expected file count, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "Will review (2)") {
|
||||
t.Errorf("expected 'Will review' section, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "main.go") || !strings.Contains(got, "util.go") {
|
||||
t.Errorf("expected file paths, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutputPreviewText_WithExcludedFiles(t *testing.T) {
|
||||
p := &agent.DiffPreview{
|
||||
Entries: []agent.DiffPreviewEntry{
|
||||
{Path: "src.go", Status: "modified", Insertions: 5, Deletions: 1, WillReview: true},
|
||||
{Path: "vendor/lib.go", Status: "added", Insertions: 100, Deletions: 0, WillReview: false, ExcludeReason: model.ExcludeDefaultPath},
|
||||
},
|
||||
TotalInsertions: 105,
|
||||
TotalDeletions: 1,
|
||||
TotalFiles: 2,
|
||||
ReviewableCount: 1,
|
||||
ExcludedCount: 1,
|
||||
}
|
||||
got := captureStdout(t, func() {
|
||||
outputPreviewText(p)
|
||||
})
|
||||
if !strings.Contains(got, "Will review (1)") {
|
||||
t.Errorf("expected 'Will review (1)', got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "Excluded from review (1)") {
|
||||
t.Errorf("expected 'Excluded from review (1)', got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "vendor/lib.go") {
|
||||
t.Errorf("expected excluded file path, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "default_path") {
|
||||
t.Errorf("expected exclude reason, got %q", got)
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
18
cmd/opencodereview/procattr_unix.go
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func configureProcessGroup(cmd *exec.Cmd) {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
cmd.Cancel = func() error {
|
||||
if cmd.Process == nil {
|
||||
return nil
|
||||
}
|
||||
return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||
}
|
||||
}
|
||||
12
cmd/opencodereview/procattr_windows.go
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import "os/exec"
|
||||
|
||||
func configureProcessGroup(cmd *exec.Cmd) {
|
||||
// On Windows, exec.CommandContext sends os.Kill which terminates the
|
||||
// direct child. Grandchild processes (e.g. from sh -c) may survive.
|
||||
// Full process-tree cleanup would need Windows Job Objects, but sh -c
|
||||
// is rare on Windows so this is an acceptable limitation.
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
378
cmd/opencodereview/provider_cmd_test.go
Normal file
|
|
@ -0,0 +1,378 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMaskKey(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
key string
|
||||
want string
|
||||
}{
|
||||
{"empty", "", "(not set)"},
|
||||
{"short", "abcd", "***"},
|
||||
{"exactly 8", "12345678", "***"},
|
||||
{"normal", "sk-ant-secret-key-1234", "sk-a***1234"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := maskKey(tc.key)
|
||||
if got != tc.want {
|
||||
t.Errorf("maskKey(%q) = %q, want %q", tc.key, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveConfig(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "sub", "config.json")
|
||||
|
||||
cfg := &Config{
|
||||
Provider: "anthropic",
|
||||
Model: "claude-opus-4-6",
|
||||
Language: "English",
|
||||
}
|
||||
|
||||
if err := saveConfig(path, cfg); err != nil {
|
||||
t.Fatalf("saveConfig: %v", err)
|
||||
}
|
||||
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("stat: %v", err)
|
||||
}
|
||||
if perm := info.Mode().Perm(); perm != 0o600 {
|
||||
t.Errorf("perm = %o, want 600", perm)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
var loaded Config
|
||||
if err := json.Unmarshal(data, &loaded); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if loaded.Provider != "anthropic" {
|
||||
t.Errorf("Provider = %q", loaded.Provider)
|
||||
}
|
||||
if loaded.Language != "English" {
|
||||
t.Errorf("Language = %q", loaded.Language)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyProviderDeletions(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "config.json")
|
||||
|
||||
cfg := &Config{
|
||||
Provider: "keep",
|
||||
CustomProviders: map[string]ProviderEntry{
|
||||
"del1": {URL: "https://a.example.com"},
|
||||
"del2": {URL: "https://b.example.com"},
|
||||
"keep": {URL: "https://c.example.com"},
|
||||
},
|
||||
}
|
||||
if err := saveConfig(configPath, cfg); err != nil {
|
||||
t.Fatalf("saveConfig: %v", err)
|
||||
}
|
||||
|
||||
clearedActive, err := applyProviderDeletions(configPath, cfg, []string{"del1", "del2"})
|
||||
if err != nil {
|
||||
t.Fatalf("applyProviderDeletions: %v", err)
|
||||
}
|
||||
if clearedActive {
|
||||
t.Error("should not have cleared active provider")
|
||||
}
|
||||
if _, exists := cfg.CustomProviders["del1"]; exists {
|
||||
t.Error("del1 should have been deleted")
|
||||
}
|
||||
if _, exists := cfg.CustomProviders["del2"]; exists {
|
||||
t.Error("del2 should have been deleted")
|
||||
}
|
||||
if _, exists := cfg.CustomProviders["keep"]; !exists {
|
||||
t.Error("keep should still exist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyProviderDeletions_ActiveCleared(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "config.json")
|
||||
|
||||
cfg := &Config{
|
||||
Provider: "active-one",
|
||||
CustomProviders: map[string]ProviderEntry{
|
||||
"active-one": {URL: "https://x.example.com"},
|
||||
},
|
||||
}
|
||||
if err := saveConfig(configPath, cfg); err != nil {
|
||||
t.Fatalf("saveConfig: %v", err)
|
||||
}
|
||||
|
||||
clearedActive, err := applyProviderDeletions(configPath, cfg, []string{"active-one"})
|
||||
if err != nil {
|
||||
t.Fatalf("applyProviderDeletions: %v", err)
|
||||
}
|
||||
if !clearedActive {
|
||||
t.Error("should have cleared active provider")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyProviderDeletions_SkipsNotFound(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "config.json")
|
||||
|
||||
cfg := &Config{
|
||||
CustomProviders: map[string]ProviderEntry{
|
||||
"exists": {URL: "https://a.example.com"},
|
||||
},
|
||||
}
|
||||
if err := saveConfig(configPath, cfg); err != nil {
|
||||
t.Fatalf("saveConfig: %v", err)
|
||||
}
|
||||
|
||||
_, err := applyProviderDeletions(configPath, cfg, []string{"nonexistent"})
|
||||
if err != nil {
|
||||
t.Fatalf("applyProviderDeletions should not fail, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveModels(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
existing []string
|
||||
remove []string
|
||||
want []string
|
||||
}{
|
||||
{"remove one", []string{"a", "b", "c"}, []string{"b"}, []string{"a", "c"}},
|
||||
{"remove none", []string{"a", "b"}, []string{"x"}, []string{"a", "b"}},
|
||||
{"remove all", []string{"a", "b"}, []string{"a", "b"}, []string{}},
|
||||
{"empty existing", nil, []string{"a"}, []string{}},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := removeModels(tc.existing, tc.remove)
|
||||
if len(got) != len(tc.want) {
|
||||
t.Fatalf("removeModels() = %v, want %v", got, tc.want)
|
||||
}
|
||||
for i := range tc.want {
|
||||
if got[i] != tc.want[i] {
|
||||
t.Errorf("[%d] = %q, want %q", i, got[i], tc.want[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyManualConfig_MissingURL(t *testing.T) {
|
||||
err := applyManualConfig("", &Config{}, providerTUIResult{url: "", model: "m"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing URL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyManualConfig_MissingModel(t *testing.T) {
|
||||
err := applyManualConfig("", &Config{}, providerTUIResult{url: "https://example.com", model: ""})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing model")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyCustomProviderConfig_MissingProvider(t *testing.T) {
|
||||
err := applyCustomProviderConfig("", &Config{}, providerTUIResult{provider: "", model: "m"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing provider")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyCustomProviderConfig_MissingModel(t *testing.T) {
|
||||
err := applyCustomProviderConfig("", &Config{}, providerTUIResult{provider: "p", model: ""})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing model")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyOfficialProviderConfig_MissingFields(t *testing.T) {
|
||||
err := applyOfficialProviderConfig("", &Config{}, providerTUIResult{provider: "", model: ""})
|
||||
if err == nil {
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
1947
cmd/opencodereview/provider_tui_funcs_test.go
Normal file
|
|
@ -5,12 +5,17 @@ import (
|
|||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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 {
|
||||
|
|
@ -42,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
|
||||
|
|
@ -61,11 +82,25 @@ func runReview(args []string) error {
|
|||
}
|
||||
tools := buildToolRegistry(rt.Collector, fileReader)
|
||||
|
||||
mcpClients := initMCPClients(context.Background(), rt.AppCfg, tools, cc.RepoDir, Version)
|
||||
defer func() {
|
||||
for _, mc := range mcpClients {
|
||||
if err := mc.Close(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[ocr] WARNING: failed to close MCP server %q: %v\n", mc.Name(), err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
mcpToolDefs := mcp.CollectToolDefs(mcpClients, tools)
|
||||
rt.PlanToolDefs = append(rt.PlanToolDefs, mcpToolDefs...)
|
||||
rt.MainToolDefs = append(rt.MainToolDefs, mcpToolDefs...)
|
||||
|
||||
ag := agent.New(agent.Args{
|
||||
RepoDir: cc.RepoDir,
|
||||
From: opts.from,
|
||||
To: opts.to,
|
||||
Commit: opts.commit,
|
||||
ReviewMode: reviewModeFromOptions(opts),
|
||||
Template: *cc.Template,
|
||||
SystemRule: cc.Resolver,
|
||||
FileFilter: cc.FileFilter,
|
||||
|
|
@ -80,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
|
||||
|
|
@ -89,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.
|
||||
|
|
@ -180,6 +257,58 @@ func runPreview(cc *commonContext, opts reviewOptions) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func initMCPClients(ctx context.Context, cfg *Config, tools *tool.Registry, repoDir, version string) []*mcp.Client {
|
||||
if cfg == nil || len(cfg.MCPServers) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
mcpNames := make([]string, 0, len(cfg.MCPServers))
|
||||
for name := range cfg.MCPServers {
|
||||
mcpNames = append(mcpNames, name)
|
||||
}
|
||||
sort.Strings(mcpNames)
|
||||
|
||||
var clients []*mcp.Client
|
||||
for _, name := range mcpNames {
|
||||
serverCfg := cfg.MCPServers[name]
|
||||
if serverCfg.Command == "" {
|
||||
fmt.Fprintf(os.Stderr, "[ocr] WARNING: MCP server %q has no command configured, skipping\n", name)
|
||||
continue
|
||||
}
|
||||
if serverCfg.Setup != "" {
|
||||
fmt.Fprintf(os.Stderr, "[ocr] Running setup for MCP server %q: %s\n", name, serverCfg.Setup)
|
||||
setupCtx, setupCancel := context.WithTimeout(ctx, 5*time.Minute)
|
||||
setupCmd := shellCommand(setupCtx, serverCfg.Setup)
|
||||
setupCmd.Dir = repoDir
|
||||
configureProcessGroup(setupCmd)
|
||||
output, err := setupCmd.CombinedOutput()
|
||||
setupCancel()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[ocr] ERROR: MCP server %q setup command failed.\n", name)
|
||||
fmt.Fprintf(os.Stderr, "[ocr] Command: %s\n", serverCfg.Setup)
|
||||
fmt.Fprintf(os.Stderr, "[ocr] Working directory: %s\n", repoDir)
|
||||
fmt.Fprintf(os.Stderr, "[ocr] Error: %v\n", err)
|
||||
if len(output) > 0 {
|
||||
fmt.Fprintf(os.Stderr, "[ocr] Output:\n%s\n", string(output))
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "[ocr] Skipping MCP server %q — review will proceed without it.\n", name)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
initCtx, initCancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
mc, err := mcp.NewClient(initCtx, name, serverCfg.Command, serverCfg.Args, serverCfg.Env, repoDir, version)
|
||||
initCancel()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[ocr] WARNING: failed to start MCP server %q: %v\n", name, err)
|
||||
continue
|
||||
}
|
||||
clients = append(clients, mc)
|
||||
mcp.RegisterAll(tools, mc, serverCfg.Tools)
|
||||
}
|
||||
return clients
|
||||
}
|
||||
|
||||
func buildToolRegistry(collector *tool.CommentCollector, fr *tool.FileReader) *tool.Registry {
|
||||
reg := tool.NewRegistry()
|
||||
reg.Register(tool.NewFileRead(fr))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -140,3 +140,110 @@ func TestParseScanFlags_HelpFlag(t *testing.T) {
|
|||
t.Error("opts.showHelp should be true when -h is supplied")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseScanFlags_RejectsNegativeMaxTokensBudget(t *testing.T) {
|
||||
_, err := parseScanFlags([]string{"--max-tokens-budget", "-100"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for negative --max-tokens-budget")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--max-tokens-budget") {
|
||||
t.Errorf("error message = %q; want it to mention --max-tokens-budget", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseScanFlags_BooleanFlags(t *testing.T) {
|
||||
opts, err := parseScanFlags([]string{"--no-plan", "--no-dedup", "--no-summary", "--preview"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !opts.noPlan {
|
||||
t.Error("noPlan should be true")
|
||||
}
|
||||
if !opts.noDedup {
|
||||
t.Error("noDedup should be true")
|
||||
}
|
||||
if !opts.noSummary {
|
||||
t.Error("noSummary should be true")
|
||||
}
|
||||
if !opts.preview {
|
||||
t.Error("preview should be true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseScanFlags_ModelOverride(t *testing.T) {
|
||||
opts, err := parseScanFlags([]string{"--model", "claude-opus-4-6"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if opts.model != "claude-opus-4-6" {
|
||||
t.Errorf("model = %q, want claude-opus-4-6", opts.model)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseScanFlags_AllStringFlags(t *testing.T) {
|
||||
opts, err := parseScanFlags([]string{
|
||||
"--tools", "/tmp/tools.json",
|
||||
"--rule", "/tmp/rule.json",
|
||||
"--repo", "/tmp/repo",
|
||||
"--exclude", "*.md,*.txt",
|
||||
"--batch", "by-language",
|
||||
"--background", "test context",
|
||||
"--audience", "agent",
|
||||
"-f", "json",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if opts.toolConfigPath != "/tmp/tools.json" {
|
||||
t.Errorf("toolConfigPath = %q", opts.toolConfigPath)
|
||||
}
|
||||
if opts.rulePath != "/tmp/rule.json" {
|
||||
t.Errorf("rulePath = %q", opts.rulePath)
|
||||
}
|
||||
if opts.repoDir != "/tmp/repo" {
|
||||
t.Errorf("repoDir = %q", opts.repoDir)
|
||||
}
|
||||
if opts.excludes != "*.md,*.txt" {
|
||||
t.Errorf("excludes = %q", opts.excludes)
|
||||
}
|
||||
if opts.batch != "by-language" {
|
||||
t.Errorf("batch = %q", opts.batch)
|
||||
}
|
||||
if opts.background != "test context" {
|
||||
t.Errorf("background = %q", opts.background)
|
||||
}
|
||||
if opts.audience != "agent" {
|
||||
t.Errorf("audience = %q", opts.audience)
|
||||
}
|
||||
if opts.outputFormat != "json" {
|
||||
t.Errorf("outputFormat = %q", opts.outputFormat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseScanFlags_IntFlags(t *testing.T) {
|
||||
opts, err := parseScanFlags([]string{
|
||||
"--concurrency", "16",
|
||||
"--timeout", "20",
|
||||
"--max-tools", "50",
|
||||
"--max-git-procs", "32",
|
||||
"--max-tokens-budget", "100000",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if opts.concurrency != 16 {
|
||||
t.Errorf("concurrency = %d", opts.concurrency)
|
||||
}
|
||||
if opts.perFileTimeout != 20 {
|
||||
t.Errorf("perFileTimeout = %d", opts.perFileTimeout)
|
||||
}
|
||||
if opts.maxTools != 50 {
|
||||
t.Errorf("maxTools = %d", opts.maxTools)
|
||||
}
|
||||
if opts.maxGitProcs != 32 {
|
||||
t.Errorf("maxGitProcs = %d", opts.maxGitProcs)
|
||||
}
|
||||
if opts.maxTokensBudget != 100000 {
|
||||
t.Errorf("maxTokensBudget = %d", opts.maxTokensBudget)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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
|
|
@ -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 != "" {
|
||||
|
|
|
|||
220
cmd/opencodereview/shared_test.go
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/open-code-review/open-code-review/internal/config/rules"
|
||||
)
|
||||
|
||||
func TestApplyCLIExcludes_Empty(t *testing.T) {
|
||||
cc := &commonContext{FileFilter: &rules.FileFilter{Exclude: []string{"a"}}}
|
||||
applyCLIExcludes(cc, nil)
|
||||
if len(cc.FileFilter.Exclude) != 1 {
|
||||
t.Errorf("expected 1 exclude, got %d", len(cc.FileFilter.Exclude))
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyCLIExcludes_AppendsPatterns(t *testing.T) {
|
||||
cc := &commonContext{FileFilter: &rules.FileFilter{Exclude: []string{"a"}}}
|
||||
applyCLIExcludes(cc, []string{"b", "c"})
|
||||
if len(cc.FileFilter.Exclude) != 3 {
|
||||
t.Errorf("expected 3 excludes, got %d", len(cc.FileFilter.Exclude))
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyCLIExcludes_NilFileFilter(t *testing.T) {
|
||||
cc := &commonContext{}
|
||||
applyCLIExcludes(cc, []string{"x"})
|
||||
if cc.FileFilter == nil {
|
||||
t.Fatal("expected FileFilter to be created")
|
||||
}
|
||||
if len(cc.FileFilter.Exclude) != 1 || cc.FileFilter.Exclude[0] != "x" {
|
||||
t.Errorf("expected [x], got %v", cc.FileFilter.Exclude)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewQuietHandle_NoOp(t *testing.T) {
|
||||
h := newQuietHandle("text", "developer")
|
||||
if h.fn != nil {
|
||||
t.Error("expected no-op handle for text/developer")
|
||||
}
|
||||
h.Restore()
|
||||
}
|
||||
|
||||
func TestNewQuietHandle_JSON(t *testing.T) {
|
||||
h := newQuietHandle("json", "developer")
|
||||
if h.fn == nil {
|
||||
t.Error("expected fn to be set for json format")
|
||||
}
|
||||
h.Restore()
|
||||
if h.fn != nil {
|
||||
t.Error("expected fn to be nil after Restore")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewQuietHandle_Agent(t *testing.T) {
|
||||
h := newQuietHandle("text", "agent")
|
||||
if h.fn == nil {
|
||||
t.Error("expected fn to be set for agent audience")
|
||||
}
|
||||
h.Restore()
|
||||
}
|
||||
|
||||
func TestQuietHandle_NilReceiver(t *testing.T) {
|
||||
var h *quietHandle
|
||||
h.Restore()
|
||||
}
|
||||
|
||||
func TestQuietHandle_IdempotentRestore(t *testing.T) {
|
||||
h := newQuietHandle("json", "developer")
|
||||
h.Restore()
|
||||
h.Restore()
|
||||
if h.fn != nil {
|
||||
t.Error("expected nil after double restore")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWorkingDir_CurrentDir(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
origDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("getwd: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := os.Chdir(origDir); err != nil {
|
||||
t.Errorf("restore chdir: %v", err)
|
||||
}
|
||||
}()
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatalf("chdir: %v", err)
|
||||
}
|
||||
|
||||
absPath, isGit, err := resolveWorkingDir("", false)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if absPath == "" {
|
||||
t.Error("expected non-empty absPath")
|
||||
}
|
||||
if isGit {
|
||||
t.Error("temp dir should not be a git repo")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWorkingDir_RequireGitFails(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
_, _, err := resolveWorkingDir(dir, true)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-git dir with requireGit=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWorkingDir_NonExistent(t *testing.T) {
|
||||
_, _, err := resolveWorkingDir(filepath.Join(t.TempDir(), "no-such-dir"), false)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-existent path")
|
||||
}
|
||||
}
|
||||
|
||||
// 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")
|
||||
if err := os.Mkdir(gitDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
absPath, isGit, err := resolveWorkingDir(dir, false)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if absPath == "" {
|
||||
t.Error("expected non-empty absPath")
|
||||
}
|
||||
_ = isGit
|
||||
}
|
||||
12
cmd/opencodereview/shell_unix.go
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
func shellCommand(ctx context.Context, script string) *exec.Cmd {
|
||||
return exec.CommandContext(ctx, "sh", "-c", script)
|
||||
}
|
||||
12
cmd/opencodereview/shell_windows.go
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
func shellCommand(ctx context.Context, script string) *exec.Cmd {
|
||||
return exec.CommandContext(ctx, "cmd", "/c", script)
|
||||
}
|
||||
248
cmd/opencodereview/smallfiles_test.go
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPrintVersion_Dev(t *testing.T) {
|
||||
origVersion := Version
|
||||
origCommit := GitCommit
|
||||
origDate := BuildDate
|
||||
defer func() {
|
||||
Version = origVersion
|
||||
GitCommit = origCommit
|
||||
BuildDate = origDate
|
||||
}()
|
||||
|
||||
Version = "dev"
|
||||
GitCommit = ""
|
||||
BuildDate = ""
|
||||
|
||||
got := captureStdout(t, func() {
|
||||
printVersion()
|
||||
})
|
||||
if !strings.Contains(got, "open-code-review dev") {
|
||||
t.Errorf("expected 'open-code-review dev', got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, runtime.GOOS+"/"+runtime.GOARCH) {
|
||||
t.Errorf("expected OS/ARCH, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintVersion_WithCommitAndDate(t *testing.T) {
|
||||
origVersion := Version
|
||||
origCommit := GitCommit
|
||||
origDate := BuildDate
|
||||
defer func() {
|
||||
Version = origVersion
|
||||
GitCommit = origCommit
|
||||
BuildDate = origDate
|
||||
}()
|
||||
|
||||
Version = "1.2.3"
|
||||
GitCommit = "abc1234"
|
||||
BuildDate = "2026-01-01"
|
||||
|
||||
got := captureStdout(t, func() {
|
||||
printVersion()
|
||||
})
|
||||
if !strings.Contains(got, "1.2.3") {
|
||||
t.Errorf("expected version, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "abc1234") {
|
||||
t.Errorf("expected commit, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "2026-01-01") {
|
||||
t.Errorf("expected build date, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseViewerFlags_Defaults(t *testing.T) {
|
||||
opts, err := parseViewerFlags(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if opts.addr != "localhost:5483" {
|
||||
t.Errorf("addr = %q, want localhost:5483", opts.addr)
|
||||
}
|
||||
if opts.showHelp {
|
||||
t.Error("showHelp should be false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseViewerFlags_CustomAddr(t *testing.T) {
|
||||
opts, err := parseViewerFlags([]string{"--addr", ":3000"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if opts.addr != ":3000" {
|
||||
t.Errorf("addr = %q, want :3000", opts.addr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseViewerFlags_Help(t *testing.T) {
|
||||
opts, err := parseViewerFlags([]string{"-h"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !opts.showHelp {
|
||||
t.Error("expected showHelp=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunLLM_NoArgs(t *testing.T) {
|
||||
got := captureStdout(t, func() {
|
||||
err := runLLM(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
if !strings.Contains(got, "LLM utility") {
|
||||
t.Errorf("expected usage text, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunLLM_UnknownSubcommand(t *testing.T) {
|
||||
err := runLLM([]string{"bogus"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown subcommand")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unknown llm sub-command") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRules_NoArgs(t *testing.T) {
|
||||
got := captureStdout(t, func() {
|
||||
err := runRules(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
if !strings.Contains(got, "ocr rules") {
|
||||
t.Errorf("expected usage text, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRules_Help(t *testing.T) {
|
||||
got := captureStdout(t, func() {
|
||||
err := runRules([]string{"-h"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
if !strings.Contains(got, "ocr rules") {
|
||||
t.Errorf("expected usage text, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRules_UnknownSubcommand(t *testing.T) {
|
||||
err := runRules([]string{"bogus"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown subcommand")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRules_HelpAltFlag(t *testing.T) {
|
||||
got := captureStdout(t, func() {
|
||||
err := runRules([]string{"--help"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
if !strings.Contains(got, "ocr rules") {
|
||||
t.Errorf("expected usage text, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRulesCheck_Help(t *testing.T) {
|
||||
got := captureStdout(t, func() {
|
||||
err := runRulesCheck([]string{"-h"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
if !strings.Contains(got, "ocr rules check") {
|
||||
t.Errorf("expected usage text, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRulesCheck_NoArgs(t *testing.T) {
|
||||
got := captureStdout(t, func() {
|
||||
err := runRulesCheck(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
if !strings.Contains(got, "ocr rules check") {
|
||||
t.Errorf("expected usage text, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunLLMProviders(t *testing.T) {
|
||||
got := captureStdout(t, func() {
|
||||
runLLMProviders()
|
||||
})
|
||||
if !strings.Contains(got, "Built-in providers") {
|
||||
t.Errorf("expected provider listing, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunViewer_Help(t *testing.T) {
|
||||
got := captureStdout(t, func() {
|
||||
err := runViewer([]string{"-h"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
if !strings.Contains(got, "Session history") {
|
||||
t.Errorf("expected usage text, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintReviewUsage(t *testing.T) {
|
||||
got := captureStdout(t, func() {
|
||||
printReviewUsage()
|
||||
})
|
||||
if !strings.Contains(got, "ocr review") {
|
||||
t.Errorf("expected usage text, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintTopLevelUsage(t *testing.T) {
|
||||
got := captureStdout(t, func() {
|
||||
printTopLevelUsage()
|
||||
})
|
||||
if !strings.Contains(got, "OpenCodeReview") {
|
||||
t.Errorf("expected usage text, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintViewerUsage(t *testing.T) {
|
||||
got := captureStdout(t, func() {
|
||||
printViewerUsage()
|
||||
})
|
||||
if !strings.Contains(got, "Session history") {
|
||||
t.Errorf("expected viewer usage text, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintRulesCheckUsage(t *testing.T) {
|
||||
got := captureStdout(t, func() {
|
||||
printRulesCheckUsage()
|
||||
})
|
||||
if !strings.Contains(got, "ocr rules check") {
|
||||
t.Errorf("expected usage text, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintScanUsage(t *testing.T) {
|
||||
got := captureStdout(t, func() {
|
||||
printScanUsage()
|
||||
})
|
||||
if !strings.Contains(got, "ocr scan") {
|
||||
t.Errorf("expected usage text, got %q", got)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,193 +0,0 @@
|
|||
package main
|
||||
|
||||
// go build ./cmd/testdiff/ -o /tmp/testdiff && /tmp/testdiff ...
|
||||
// Or just: go run ./cmd/testdiff/ ...
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/open-code-review/open-code-review/internal/diff"
|
||||
"github.com/open-code-review/open-code-review/internal/model"
|
||||
)
|
||||
|
||||
func main() {
|
||||
args := parseArgs(os.Args[1:])
|
||||
if args.showHelp || len(args.raw) == 0 {
|
||||
printUsage()
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
repoDir, err := resolveRepo(args.repo)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
provider := buildProvider(repoDir, args)
|
||||
diffs, err := provider.GetDiff(context.Background())
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if len(diffs) == 0 {
|
||||
fmt.Println("(no changes)")
|
||||
return
|
||||
}
|
||||
|
||||
if args.summary {
|
||||
printSummary(diffs)
|
||||
return
|
||||
}
|
||||
|
||||
if args.format == "json" {
|
||||
out, _ := json.MarshalIndent(diffs, "", " ")
|
||||
fmt.Println(string(out))
|
||||
return
|
||||
}
|
||||
|
||||
printText(diffs)
|
||||
}
|
||||
|
||||
// ---- argument parsing ----
|
||||
|
||||
type cliArgs struct {
|
||||
repo string
|
||||
from string
|
||||
to string
|
||||
commit string
|
||||
format string // "text" or "json"
|
||||
summary bool // just print file list and stats
|
||||
showHelp bool
|
||||
raw []string
|
||||
}
|
||||
|
||||
func parseArgs(args []string) cliArgs {
|
||||
result := cliArgs{raw: args, format: "text"}
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "-h", "--help":
|
||||
result.showHelp = true
|
||||
return result
|
||||
case "-repo":
|
||||
i++
|
||||
result.repo = args[i]
|
||||
case "-from":
|
||||
i++
|
||||
result.from = args[i]
|
||||
case "-to":
|
||||
i++
|
||||
result.to = args[i]
|
||||
case "-commit":
|
||||
i++
|
||||
result.commit = args[i]
|
||||
case "-format":
|
||||
i++
|
||||
result.format = args[i]
|
||||
case "-summary":
|
||||
result.summary = true
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func printUsage() {
|
||||
fmt.Println(`testdiff — quick diff parsing test helper.
|
||||
|
||||
Usage:
|
||||
go run ./cmd/testdiff [flags]
|
||||
|
||||
Examples:
|
||||
# Workspace mode (default if no refs given, runs from CWD)
|
||||
go run ./cmd/testdiff
|
||||
|
||||
# Range mode
|
||||
go run ./cmd/testdiff -from master -to dev-ref
|
||||
|
||||
# Single commit vs its parent
|
||||
go run ./cmd/testdiff -commit abc1234
|
||||
|
||||
# Summary only (file paths and line counts)
|
||||
go run ./cmd/testdiff -from master -to dev-ref -summary
|
||||
|
||||
Flags:
|
||||
-repo DIR git repository root (default: auto-detect via git rev-parse)
|
||||
-from REF source ref (e.g. 'main')
|
||||
-to REF target ref (e.g. 'feature-branch')
|
||||
-commit SHA single commit to review (vs its parent)
|
||||
-format FMT output format: text or json (default: text)
|
||||
-summary show file list and insertions/deletions only`)
|
||||
}
|
||||
|
||||
func resolveRepo(input string) (string, error) {
|
||||
if input == "" {
|
||||
out, err := exec.Command("git", "rev-parse", "--show-toplevel").CombinedOutput()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("not in a git repo (%s)", strings.TrimSpace(string(out)))
|
||||
}
|
||||
input = strings.TrimSpace(string(out))
|
||||
}
|
||||
abs, err := filepath.Abs(input)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return abs, nil
|
||||
}
|
||||
|
||||
func buildProvider(repoDir string, args cliArgs) *diff.Provider {
|
||||
switch {
|
||||
case args.commit != "":
|
||||
return diff.NewCommitProvider(repoDir, args.commit, nil)
|
||||
case args.from != "" && args.to != "":
|
||||
return diff.NewProvider(repoDir, args.from, args.to, nil)
|
||||
default:
|
||||
return diff.NewWorkspaceProvider(repoDir, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- output helpers ----
|
||||
|
||||
func printSummary(diffs []model.Diff) {
|
||||
var totalAdd, totalDel int64
|
||||
for _, d := range diffs {
|
||||
status := "M"
|
||||
if d.IsNew {
|
||||
status = "A"
|
||||
} else if d.IsDeleted {
|
||||
status = "D"
|
||||
}
|
||||
path := d.NewPath
|
||||
if path == "/dev/null" {
|
||||
path = d.OldPath
|
||||
}
|
||||
fmt.Printf(" %s %-4s +%d/-%d %s\n", status, "", d.Insertions, d.Deletions, path)
|
||||
totalAdd += d.Insertions
|
||||
totalDel += d.Deletions
|
||||
}
|
||||
fmt.Printf("\n%d file(s), +%d/-%d lines\n", len(diffs), totalAdd, totalDel)
|
||||
}
|
||||
|
||||
func printText(diffs []model.Diff) {
|
||||
for i, d := range diffs {
|
||||
path := d.NewPath
|
||||
if path == "/dev/null" {
|
||||
path = d.OldPath
|
||||
}
|
||||
status := "MODIFIED"
|
||||
if d.IsNew {
|
||||
status = "ADDED"
|
||||
} else if d.IsDeleted {
|
||||
status = "DELETED"
|
||||
}
|
||||
fmt.Printf("--- %s (%s, +%d/-%d) ---\n", path, status, d.Insertions, d.Deletions)
|
||||
fmt.Print(d.Diff)
|
||||
if i < len(diffs)-1 {
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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,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' })}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
50
go.mod
|
|
@ -1,24 +1,27 @@
|
|||
module github.com/open-code-review/open-code-review
|
||||
|
||||
go 1.25.0
|
||||
go 1.25.5
|
||||
|
||||
require (
|
||||
charm.land/bubbles/v2 v2.1.0
|
||||
charm.land/bubbletea/v2 v2.0.7
|
||||
charm.land/lipgloss/v2 v2.0.3
|
||||
github.com/anthropics/anthropic-sdk-go v1.47.0
|
||||
charm.land/lipgloss/v2 v2.0.4
|
||||
github.com/anthropics/anthropic-sdk-go v1.55.1
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.0
|
||||
github.com/openai/openai-go/v3 v3.39.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.43.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0
|
||||
go.opentelemetry.io/otel/metric v1.43.0
|
||||
go.opentelemetry.io/otel/sdk v1.43.0
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0
|
||||
go.opentelemetry.io/otel/trace v1.43.0
|
||||
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
|
||||
go.opentelemetry.io/otel/sdk v1.44.0
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0
|
||||
go.opentelemetry.io/otel/trace v1.44.0
|
||||
)
|
||||
|
||||
require (
|
||||
|
|
@ -35,33 +38,38 @@ require (
|
|||
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/dlclark/regexp2 v1.10.0 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.0 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/google/jsonschema-go v0.4.3 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
|
||||
github.com/invopop/jsonschema v0.14.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.23 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/pb33f/ordered-map/v2 v2.3.1 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/segmentio/asm v1.1.3 // indirect
|
||||
github.com/segmentio/encoding v0.5.4 // indirect
|
||||
github.com/standard-webhooks/standard-webhooks/libraries v0.0.1 // indirect
|
||||
github.com/tidwall/gjson v1.18.0 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/tidwall/sjson v1.2.5 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.2 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/net v0.55.0 // indirect
|
||||
golang.org/x/oauth2 v0.36.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
|
||||
google.golang.org/grpc v1.80.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
|
||||
google.golang.org/grpc v1.81.1 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
)
|
||||
|
|
|
|||
102
go.sum
|
|
@ -2,10 +2,10 @@ charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g=
|
|||
charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY=
|
||||
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.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU=
|
||||
charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA=
|
||||
github.com/anthropics/anthropic-sdk-go v1.47.0 h1:p1F48S/5UAGK3h2NzvZP8rqKnZqB7RkyYvOEM8dOEaQ=
|
||||
github.com/anthropics/anthropic-sdk-go v1.47.0/go.mod h1:3EfIfmFqxH6rbiLcIP4tPFyXL/IHakx2wDG4OU+TIEI=
|
||||
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.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=
|
||||
|
|
@ -40,8 +40,8 @@ github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJ
|
|||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dlclark/regexp2 v1.10.0 h1:+/GIL799phkJqYW+3YbOd8LCcbHzT0Pbo8zl70MHsq0=
|
||||
github.com/dlclark/regexp2 v1.10.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI=
|
||||
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
|
|
@ -49,24 +49,30 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
|||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
|
||||
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
|
||||
github.com/invopop/jsonschema v0.14.0 h1:MHQqLhvpNUZfw+hM3AZDYK7jxO8FZoQeQM77g8iyZjg=
|
||||
github.com/invopop/jsonschema v0.14.0/go.mod h1:ygm6C2EaVNMBDPpaPlnOA2pFAxBnxGjFlMZABxm9n2I=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
|
||||
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU=
|
||||
github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/openai/openai-go/v3 v3.39.0 h1:WgLGgMOOdQDkZyo8YIhzUNXRXlEc+OJfU4EKP5Qp6AA=
|
||||
github.com/openai/openai-go/v3 v3.39.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo=
|
||||
github.com/openai/openai-go/v3 v3.41.0 h1:9GkxcN02U5NG0WGdQjZ0cTSu/pMXEyzL2LfF0ruZCck=
|
||||
github.com/openai/openai-go/v3 v3.41.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo=
|
||||
github.com/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP1nBjY=
|
||||
github.com/pb33f/ordered-map/v2 v2.3.1/go.mod h1:qxFQgd0PkVUtOMCkTapqotNgzRhMPL7VvaHKbd1HnmQ=
|
||||
github.com/pkoukk/tiktoken-go v0.1.8 h1:85ENo+3FpWgAACBaEUVp+lctuTcYUO7BtmfhlN/QTRo=
|
||||
|
|
@ -75,6 +81,10 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
|||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
|
||||
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
|
||||
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
|
||||
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
|
||||
github.com/standard-webhooks/standard-webhooks/libraries v0.0.1 h1:uOfcYT+3QungH6tIGSVCR/Y3KJmgJiHcojJbMTPDZAI=
|
||||
github.com/standard-webhooks/standard-webhooks/libraries v0.0.1/go.mod h1:L1MQhA6x4dn9r007T033lsaZMv9EmBAdXyU/+EF40fo=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
|
|
@ -91,28 +101,36 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
|||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 h1:8UQVDcZxOJLtX6gxtDt3vY2WTgvZqMQRzjsqiIHQdkc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0/go.mod h1:2lmweYCiHYpEjQ/lSJBYhj9jP1zvCvQW4BqL9dnT7FQ=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+JPfvEmvy+0LzyUA25/SGawPwIUbZ6u0Wug54sLc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 h1:TC+BewnDpeiAmcscXbGMfxkO+mwYUwE/VySwvw88PfA=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0/go.mod h1:J/ZyF4vfPwsSr9xJSPyQ4LqtcTPULFR64KwTikGLe+A=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 h1:mS47AX77OtFfKG4vtp+84kuGSFZHTyxtXIN269vChY0=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0/go.mod h1:PJnsC41lAGncJlPUniSwM81gc80GkgWJWr3cu2nKEtU=
|
||||
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
||||
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=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0/go.mod h1:L0hRV50XdVIODHUfWEqGRCXQvj2rV82STVo12FMFBU0=
|
||||
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
|
||||
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
|
||||
go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA=
|
||||
go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
|
||||
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
|
||||
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
|
||||
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
|
||||
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
|
|
@ -121,22 +139,26 @@ go.yaml.in/yaml/v4 v4.0.0-rc.2 h1:/FrI8D64VSr4HtGIlUtlFMGsm7H7pWTbj6vOLVZcA6s=
|
|||
go.yaml.in/yaml/v4 v4.0.0-rc.2/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
|
||||
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
|
||||
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 2.7 MiB After Width: | Height: | Size: 385 KiB |
BIN
imgs/benchmark-ja.png
Normal file
|
After Width: | Height: | Size: 385 KiB |
|
Before Width: | Height: | Size: 2.7 MiB After Width: | Height: | Size: 386 KiB |
|
Before Width: | Height: | Size: 823 KiB After Width: | Height: | Size: 48 KiB |
BIN
imgs/highlights-ja.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 825 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 2.7 KiB |
|
|
@ -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
|
||||
|
|
|
|||
760
internal/agent/agent_test.go
Normal file
|
|
@ -0,0 +1,760 @@
|
|||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/open-code-review/open-code-review/internal/config/template"
|
||||
"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"
|
||||
)
|
||||
|
||||
type fakeAgentClient struct {
|
||||
responses []*llm.ChatResponse
|
||||
calls int
|
||||
}
|
||||
|
||||
func (f *fakeAgentClient) CompletionsWithCtx(_ context.Context, _ llm.ChatRequest) (*llm.ChatResponse, error) {
|
||||
if f.calls >= len(f.responses) {
|
||||
content := ""
|
||||
return &llm.ChatResponse{
|
||||
Choices: []llm.Choice{{Message: llm.ResponseMessage{Content: &content}}},
|
||||
Model: "fake",
|
||||
}, nil
|
||||
}
|
||||
resp := f.responses[f.calls]
|
||||
f.calls++
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func agentTaskDoneResponse() *llm.ChatResponse {
|
||||
content := ""
|
||||
return &llm.ChatResponse{
|
||||
Choices: []llm.Choice{{
|
||||
Message: llm.ResponseMessage{
|
||||
Content: &content,
|
||||
ToolCalls: []llm.ToolCall{{
|
||||
ID: "call_done",
|
||||
Type: "function",
|
||||
Function: llm.FunctionCall{
|
||||
Name: "task_done",
|
||||
Arguments: `{}`,
|
||||
},
|
||||
}},
|
||||
},
|
||||
}},
|
||||
Model: "fake",
|
||||
Usage: &llm.UsageInfo{PromptTokens: 10, CompletionTokens: 5},
|
||||
}
|
||||
}
|
||||
|
||||
func codeCommentResponse(path string) *llm.ChatResponse {
|
||||
content := ""
|
||||
args := map[string]any{
|
||||
"path": path,
|
||||
"comments": []any{
|
||||
map[string]any{
|
||||
"content": "potential null pointer",
|
||||
"existing_code": "foo := bar.Baz()",
|
||||
},
|
||||
},
|
||||
}
|
||||
argsJSON, _ := json.Marshal(args)
|
||||
return &llm.ChatResponse{
|
||||
Choices: []llm.Choice{{
|
||||
Message: llm.ResponseMessage{
|
||||
Content: &content,
|
||||
ToolCalls: []llm.ToolCall{{
|
||||
ID: "call_comment",
|
||||
Type: "function",
|
||||
Function: llm.FunctionCall{
|
||||
Name: "code_comment",
|
||||
Arguments: string(argsJSON),
|
||||
},
|
||||
}},
|
||||
},
|
||||
}},
|
||||
Model: "fake",
|
||||
Usage: &llm.UsageInfo{PromptTokens: 50, CompletionTokens: 20},
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFilterCommentsJSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
comments []model.LlmComment
|
||||
wantIDs []string
|
||||
}{
|
||||
{
|
||||
name: "empty slice",
|
||||
comments: nil,
|
||||
wantIDs: nil,
|
||||
},
|
||||
{
|
||||
name: "single comment",
|
||||
comments: []model.LlmComment{
|
||||
{Content: "fix this", ExistingCode: "old code"},
|
||||
},
|
||||
wantIDs: []string{"c-0"},
|
||||
},
|
||||
{
|
||||
name: "multiple comments sequential IDs",
|
||||
comments: []model.LlmComment{
|
||||
{Content: "issue A"},
|
||||
{Content: "issue B", ExistingCode: "existing"},
|
||||
{Content: "issue C"},
|
||||
},
|
||||
wantIDs: []string{"c-0", "c-1", "c-2"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := buildFilterCommentsJSON(tt.comments)
|
||||
|
||||
var items []struct {
|
||||
ID string `json:"id"`
|
||||
Content string `json:"content"`
|
||||
ExistingCode string `json:"existing_code,omitempty"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(got), &items); err != nil {
|
||||
t.Fatalf("invalid JSON: %v\nraw: %s", err, got)
|
||||
}
|
||||
|
||||
if len(items) != len(tt.comments) {
|
||||
t.Fatalf("len = %d, want %d", len(items), len(tt.comments))
|
||||
}
|
||||
|
||||
for i, item := range items {
|
||||
if tt.wantIDs != nil && item.ID != tt.wantIDs[i] {
|
||||
t.Errorf("items[%d].ID = %q, want %q", i, item.ID, tt.wantIDs[i])
|
||||
}
|
||||
if item.Content != tt.comments[i].Content {
|
||||
t.Errorf("items[%d].Content = %q, want %q", i, item.Content, tt.comments[i].Content)
|
||||
}
|
||||
if item.ExistingCode != tt.comments[i].ExistingCode {
|
||||
t.Errorf("items[%d].ExistingCode = %q, want %q", i, item.ExistingCode, tt.comments[i].ExistingCode)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFilterResponse(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
total int
|
||||
wantSet map[int]struct{}
|
||||
}{
|
||||
{
|
||||
name: "valid JSON array",
|
||||
raw: `["c-0","c-2","c-4"]`,
|
||||
total: 5,
|
||||
wantSet: map[int]struct{}{0: {}, 2: {}, 4: {}},
|
||||
},
|
||||
{
|
||||
name: "markdown fenced JSON",
|
||||
raw: "```json\n[\"c-1\"]\n```",
|
||||
total: 3,
|
||||
wantSet: map[int]struct{}{1: {}},
|
||||
},
|
||||
{
|
||||
name: "out-of-range indices ignored",
|
||||
raw: `["c-0","c-10","c-99"]`,
|
||||
total: 5,
|
||||
wantSet: map[int]struct{}{0: {}},
|
||||
},
|
||||
{
|
||||
name: "negative index ignored",
|
||||
raw: `["c--1","c-0"]`,
|
||||
total: 2,
|
||||
wantSet: map[int]struct{}{0: {}},
|
||||
},
|
||||
{
|
||||
name: "invalid ID format ignored",
|
||||
raw: `["x-0","c-abc","c-1"]`,
|
||||
total: 3,
|
||||
wantSet: map[int]struct{}{1: {}},
|
||||
},
|
||||
{
|
||||
name: "invalid JSON returns nil",
|
||||
raw: `not json`,
|
||||
total: 5,
|
||||
wantSet: nil,
|
||||
},
|
||||
{
|
||||
name: "empty array",
|
||||
raw: `[]`,
|
||||
total: 5,
|
||||
wantSet: map[int]struct{}{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := parseFilterResponse(tt.raw, tt.total)
|
||||
if tt.wantSet == nil {
|
||||
if got != nil {
|
||||
t.Errorf("expected nil, got %v", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(got) != len(tt.wantSet) {
|
||||
t.Fatalf("len = %d, want %d; got %v", len(got), len(tt.wantSet), got)
|
||||
}
|
||||
for idx := range tt.wantSet {
|
||||
if _, ok := got[idx]; !ok {
|
||||
t.Errorf("missing index %d in result", idx)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtFromPath(t *testing.T) {
|
||||
a := New(Args{})
|
||||
|
||||
tests := []struct {
|
||||
path string
|
||||
want string
|
||||
}{
|
||||
{"main.go", ".go"},
|
||||
{"src/app.tsx", ".tsx"},
|
||||
{"path/to/FILE.JSON", ".json"},
|
||||
{"Makefile", ""},
|
||||
{".gitignore", ""},
|
||||
{"dir/.hidden", ""},
|
||||
{"archive.tar.gz", ".gz"},
|
||||
{"no-ext", ""},
|
||||
{"path/to/", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.path, func(t *testing.T) {
|
||||
got := a.extFromPath(tt.path)
|
||||
if got != tt.want {
|
||||
t.Errorf("extFromPath(%q) = %q, want %q", tt.path, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatToolDefs(t *testing.T) {
|
||||
t.Run("empty defs returns empty string", func(t *testing.T) {
|
||||
got := formatToolDefs(nil)
|
||||
if got != "" {
|
||||
t.Errorf("expected empty, got %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("single tool with parameters", func(t *testing.T) {
|
||||
defs := []llm.ToolDef{
|
||||
{
|
||||
Type: "function",
|
||||
Function: llm.FunctionDef{
|
||||
Name: "file_read",
|
||||
Description: "Read a file from the repository",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"path": map[string]any{
|
||||
"type": "string",
|
||||
"description": "File path to read",
|
||||
},
|
||||
"start_line": map[string]any{
|
||||
"type": "integer",
|
||||
"description": "Starting line number",
|
||||
},
|
||||
},
|
||||
"required": []any{"path"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
got := formatToolDefs(defs)
|
||||
if !strings.Contains(got, "### Available Tools") {
|
||||
t.Error("missing header")
|
||||
}
|
||||
if !strings.Contains(got, "**file_read**") {
|
||||
t.Error("missing tool name")
|
||||
}
|
||||
if !strings.Contains(got, "Read a file from the repository") {
|
||||
t.Error("missing description")
|
||||
}
|
||||
if !strings.Contains(got, "path") {
|
||||
t.Error("missing parameter name")
|
||||
}
|
||||
if !strings.Contains(got, "(required)") {
|
||||
t.Error("missing required marker")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("tool without parameters", func(t *testing.T) {
|
||||
defs := []llm.ToolDef{
|
||||
{
|
||||
Type: "function",
|
||||
Function: llm.FunctionDef{
|
||||
Name: "task_done",
|
||||
Description: "Signal task completion",
|
||||
Parameters: map[string]any{},
|
||||
},
|
||||
},
|
||||
}
|
||||
got := formatToolDefs(defs)
|
||||
if !strings.Contains(got, "**task_done**") {
|
||||
t.Error("missing tool name")
|
||||
}
|
||||
if strings.Contains(got, "Parameters:") {
|
||||
t.Error("should not show Parameters section for empty params")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multiple tools", func(t *testing.T) {
|
||||
defs := []llm.ToolDef{
|
||||
{Type: "function", Function: llm.FunctionDef{Name: "tool_a", Description: "desc a"}},
|
||||
{Type: "function", Function: llm.FunctionDef{Name: "tool_b", Description: "desc b"}},
|
||||
}
|
||||
got := formatToolDefs(defs)
|
||||
if !strings.Contains(got, "tool_a") || !strings.Contains(got, "tool_b") {
|
||||
t.Errorf("missing tools in output: %s", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuildToolDefs(t *testing.T) {
|
||||
funcDef := json.RawMessage(`{"name":"test_tool","description":"a tool","parameters":{}}`)
|
||||
|
||||
entries := []toolsconfig.ToolConfigEntry{
|
||||
{Name: "plan_only", PlanTask: true, MainTask: false, Definition: funcDef},
|
||||
{Name: "main_only", PlanTask: false, MainTask: true, Definition: funcDef},
|
||||
{Name: "both", PlanTask: true, MainTask: true, Definition: funcDef},
|
||||
{Name: "neither", PlanTask: false, MainTask: false, Definition: funcDef},
|
||||
}
|
||||
|
||||
t.Run("planOnly=true returns plan_task tools", func(t *testing.T) {
|
||||
defs := BuildToolDefs(entries, true)
|
||||
if len(defs) != 2 {
|
||||
t.Fatalf("expected 2 defs, got %d", len(defs))
|
||||
}
|
||||
names := make(map[string]bool)
|
||||
for _, d := range defs {
|
||||
names[d.Function.Name] = true
|
||||
}
|
||||
if !names["test_tool"] {
|
||||
t.Error("expected test_tool in plan defs")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("planOnly=false returns main_task tools", func(t *testing.T) {
|
||||
defs := BuildToolDefs(entries, false)
|
||||
if len(defs) != 2 {
|
||||
t.Fatalf("expected 2 defs, got %d", len(defs))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid definition JSON is skipped", func(t *testing.T) {
|
||||
bad := []toolsconfig.ToolConfigEntry{
|
||||
{Name: "bad", PlanTask: true, MainTask: true, Definition: json.RawMessage(`{invalid}`)},
|
||||
{Name: "good", PlanTask: true, MainTask: true, Definition: funcDef},
|
||||
}
|
||||
defs := BuildToolDefs(bad, true)
|
||||
if len(defs) != 1 {
|
||||
t.Fatalf("expected 1 def (bad skipped), got %d", len(defs))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty entries returns nil", func(t *testing.T) {
|
||||
defs := BuildToolDefs(nil, true)
|
||||
if defs != nil {
|
||||
t.Errorf("expected nil, got %v", defs)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestFilterLargeDiffs(t *testing.T) {
|
||||
a := New(Args{
|
||||
Template: template.Template{MaxTokens: 100},
|
||||
})
|
||||
|
||||
diffs := []model.Diff{
|
||||
{NewPath: "small.go", Diff: "short diff"},
|
||||
{NewPath: "large.go", Diff: strings.Repeat("word ", 500)},
|
||||
}
|
||||
|
||||
kept := a.filterLargeDiffs(diffs)
|
||||
if len(kept) != 1 {
|
||||
t.Fatalf("expected 1 kept diff, got %d", len(kept))
|
||||
}
|
||||
if kept[0].NewPath != "small.go" {
|
||||
t.Errorf("kept wrong file: %s", kept[0].NewPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterLargeDiffs_ZeroMaxTokens(t *testing.T) {
|
||||
a := New(Args{
|
||||
Template: template.Template{MaxTokens: 0},
|
||||
})
|
||||
|
||||
diffs := []model.Diff{{NewPath: "a.go", Diff: "some diff"}}
|
||||
kept := a.filterLargeDiffs(diffs)
|
||||
if len(kept) != 1 {
|
||||
t.Errorf("expected all kept when MaxTokens=0, got %d", len(kept))
|
||||
}
|
||||
}
|
||||
|
||||
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{
|
||||
{NewPath: "main.go", Insertions: 10, Deletions: 2},
|
||||
{NewPath: "deleted.go", IsDeleted: true, Deletions: 20},
|
||||
{NewPath: "binary.bin", IsBinary: true},
|
||||
{NewPath: "helper.go", Insertions: 5},
|
||||
}
|
||||
|
||||
count := a.countReviewable(diffs)
|
||||
if count != 2 {
|
||||
t.Errorf("countReviewable = %d, want 2", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildChangeFilesExcept(t *testing.T) {
|
||||
a := New(Args{})
|
||||
a.diffs = []model.Diff{
|
||||
{NewPath: "main.go", OldPath: "main.go"},
|
||||
{NewPath: "helper.go", OldPath: "helper.go", IsNew: true},
|
||||
{NewPath: "removed.go", OldPath: "removed.go", IsDeleted: true},
|
||||
{NewPath: "renamed.go", OldPath: "old_name.go"},
|
||||
{NewPath: "bin.dat", OldPath: "bin.dat", IsBinary: true},
|
||||
}
|
||||
|
||||
got := a.buildChangeFilesExcept("main.go")
|
||||
if strings.Contains(got, "main.go") {
|
||||
t.Error("excluded file should not appear")
|
||||
}
|
||||
if !strings.Contains(got, "ADDED") {
|
||||
t.Error("expected ADDED status for new file")
|
||||
}
|
||||
if !strings.Contains(got, "DELETED") {
|
||||
t.Error("expected DELETED status")
|
||||
}
|
||||
if !strings.Contains(got, "RENAMED") {
|
||||
t.Error("expected RENAMED status")
|
||||
}
|
||||
if strings.Contains(got, "bin.dat") {
|
||||
t.Error("binary files should be skipped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchSubtasks_WithFakeLLM(t *testing.T) {
|
||||
client := &fakeAgentClient{responses: []*llm.ChatResponse{
|
||||
codeCommentResponse("main.go"),
|
||||
agentTaskDoneResponse(),
|
||||
}}
|
||||
|
||||
collector := tool.NewCommentCollector()
|
||||
reg := tool.NewRegistry()
|
||||
reg.Register(&tool.CodeCommentProvider{Collector: collector})
|
||||
|
||||
a := New(Args{
|
||||
LLMClient: client,
|
||||
Model: "fake",
|
||||
CommentCollector: collector,
|
||||
Tools: reg,
|
||||
Template: template.Template{
|
||||
MaxTokens: 100000,
|
||||
MaxToolRequestTimes: 10,
|
||||
MainTask: template.LlmConversation{
|
||||
Messages: []template.ChatMessage{
|
||||
{Role: "user", Content: "Review {{diff}} for {{current_file_path}}"},
|
||||
},
|
||||
},
|
||||
},
|
||||
MainToolDefs: []llm.ToolDef{
|
||||
{Type: "function", Function: llm.FunctionDef{Name: "task_done", Description: "done"}},
|
||||
{Type: "function", Function: llm.FunctionDef{Name: "code_comment", Description: "comment"}},
|
||||
},
|
||||
})
|
||||
|
||||
a.diffs = []model.Diff{
|
||||
{NewPath: "main.go", OldPath: "main.go", Diff: "+new line", Insertions: 1},
|
||||
}
|
||||
a.currentDate = "2025-06-26 10:00"
|
||||
|
||||
comments, err := a.dispatchSubtasks(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("dispatchSubtasks: %v", err)
|
||||
}
|
||||
if len(comments) != 1 {
|
||||
t.Fatalf("expected 1 comment, got %d", len(comments))
|
||||
}
|
||||
if comments[0].Path != "main.go" {
|
||||
t.Errorf("Path = %q, want main.go", comments[0].Path)
|
||||
}
|
||||
if !strings.Contains(comments[0].Content, "null pointer") {
|
||||
t.Errorf("Content = %q", comments[0].Content)
|
||||
}
|
||||
}
|
||||
|
||||
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{
|
||||
LLMClient: client,
|
||||
Model: "fake",
|
||||
Template: template.Template{
|
||||
MaxTokens: 100000,
|
||||
MaxToolRequestTimes: 5,
|
||||
MainTask: template.LlmConversation{
|
||||
Messages: []template.ChatMessage{
|
||||
{Role: "user", Content: "Review {{diff}}"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
a.diffs = []model.Diff{
|
||||
{NewPath: "removed.go", IsDeleted: true},
|
||||
}
|
||||
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.Errorf("expected 0 comments for deleted file, got %d", len(comments))
|
||||
}
|
||||
if client.calls != 0 {
|
||||
t.Errorf("expected 0 LLM calls, got %d", client.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgent_TokenAccumulation(t *testing.T) {
|
||||
client := &fakeAgentClient{responses: []*llm.ChatResponse{
|
||||
agentTaskDoneResponse(),
|
||||
}}
|
||||
|
||||
a := New(Args{
|
||||
LLMClient: client,
|
||||
Model: "fake",
|
||||
Template: template.Template{
|
||||
MaxTokens: 100000,
|
||||
MaxToolRequestTimes: 10,
|
||||
MainTask: template.LlmConversation{
|
||||
Messages: []template.ChatMessage{
|
||||
{Role: "user", Content: "Review {{diff}}"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
a.diffs = []model.Diff{
|
||||
{NewPath: "a.go", Diff: "+x", Insertions: 1},
|
||||
}
|
||||
a.currentDate = "2025-06-26 10:00"
|
||||
|
||||
_, err := a.dispatchSubtasks(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a.TotalInputTokens() != 10 {
|
||||
t.Errorf("TotalInputTokens = %d, want 10", a.TotalInputTokens())
|
||||
}
|
||||
if a.TotalOutputTokens() != 5 {
|
||||
t.Errorf("TotalOutputTokens = %d, want 5", a.TotalOutputTokens())
|
||||
}
|
||||
}
|
||||
657
internal/agent/coverage_test.go
Normal file
|
|
@ -0,0 +1,657 @@
|
|||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/open-code-review/open-code-review/internal/config/rules"
|
||||
"github.com/open-code-review/open-code-review/internal/config/template"
|
||||
"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"
|
||||
)
|
||||
|
||||
func TestAgent_Getters(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
sess := session.New(tmpDir, "main", "test-model", session.SessionOptions{ReviewMode: "diff"})
|
||||
collector := tool.NewCommentCollector()
|
||||
|
||||
a := New(Args{
|
||||
LLMClient: &fakeAgentClient{},
|
||||
Model: "test-model",
|
||||
CommentCollector: collector,
|
||||
Session: sess,
|
||||
Template: template.Template{
|
||||
MaxTokens: 10000,
|
||||
MaxToolRequestTimes: 10,
|
||||
MainTask: template.LlmConversation{
|
||||
Messages: []template.ChatMessage{{Role: "user", Content: "test"}},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
a.diffs = []model.Diff{
|
||||
{NewPath: "a.go", Diff: "+code"},
|
||||
{NewPath: "b.go", Diff: "+more"},
|
||||
}
|
||||
|
||||
if a.Session() != sess {
|
||||
t.Error("Session() does not return expected session")
|
||||
}
|
||||
if a.FilesReviewed() != 2 {
|
||||
t.Errorf("FilesReviewed() = %d, want 2", a.FilesReviewed())
|
||||
}
|
||||
if len(a.Diffs()) != 2 {
|
||||
t.Errorf("Diffs() len = %d, want 2", len(a.Diffs()))
|
||||
}
|
||||
if a.ProjectSummary() != "" {
|
||||
t.Errorf("ProjectSummary() = %q, want empty", a.ProjectSummary())
|
||||
}
|
||||
if a.TotalTokensUsed() != 0 {
|
||||
t.Errorf("TotalTokensUsed() = %d, want 0", a.TotalTokensUsed())
|
||||
}
|
||||
if a.TotalCacheReadTokens() != 0 {
|
||||
t.Errorf("TotalCacheReadTokens() = %d, want 0", a.TotalCacheReadTokens())
|
||||
}
|
||||
if a.TotalCacheWriteTokens() != 0 {
|
||||
t.Errorf("TotalCacheWriteTokens() = %d, want 0", a.TotalCacheWriteTokens())
|
||||
}
|
||||
if len(a.Warnings()) != 0 {
|
||||
t.Errorf("Warnings() should be empty initially, got %d", len(a.Warnings()))
|
||||
}
|
||||
if len(a.ToolCalls()) != 0 {
|
||||
t.Errorf("ToolCalls() should be empty initially, got %d", len(a.ToolCalls()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgent_RecordWarning(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
sess := session.New(tmpDir, "main", "test-model", session.SessionOptions{ReviewMode: "diff"})
|
||||
a := New(Args{
|
||||
LLMClient: &fakeAgentClient{},
|
||||
Model: "test-model",
|
||||
Session: sess,
|
||||
Template: template.Template{MaxTokens: 10000, MaxToolRequestTimes: 5, MainTask: template.LlmConversation{Messages: []template.ChatMessage{{Role: "user", Content: "t"}}}},
|
||||
})
|
||||
|
||||
a.recordWarning("error", "main.go", "something")
|
||||
warnings := a.Warnings()
|
||||
if len(warnings) != 1 {
|
||||
t.Fatalf("expected 1 warning, got %d", len(warnings))
|
||||
}
|
||||
if warnings[0].Type != "error" || warnings[0].File != "main.go" {
|
||||
t.Errorf("unexpected warning: %+v", warnings[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCommentWorkerPool(t *testing.T) {
|
||||
pool := NewCommentWorkerPool(2)
|
||||
if pool == nil {
|
||||
t.Fatal("NewCommentWorkerPool returned nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectDiffMap(t *testing.T) {
|
||||
reg := tool.NewRegistry()
|
||||
emptyDM := tool.NewDiffMap(nil)
|
||||
frd := tool.NewFileReadDiff(emptyDM)
|
||||
reg.Register(frd)
|
||||
|
||||
a := New(Args{
|
||||
LLMClient: &fakeAgentClient{},
|
||||
Tools: reg,
|
||||
Template: template.Template{MaxTokens: 10000, MaxToolRequestTimes: 5, MainTask: template.LlmConversation{Messages: []template.ChatMessage{{Role: "user", Content: "t"}}}},
|
||||
})
|
||||
a.diffs = []model.Diff{
|
||||
{NewPath: "main.go", OldPath: "main.go", Diff: "+new code"},
|
||||
{NewPath: "/dev/null", OldPath: "deleted.go", Diff: "-deleted"},
|
||||
}
|
||||
|
||||
a.injectDiffMap()
|
||||
|
||||
result, err := frd.Execute(context.Background(), map[string]any{
|
||||
"path_array": []any{"main.go"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(result, "+new code") {
|
||||
t.Errorf("DiffMap did not contain main.go diff, got: %q", result)
|
||||
}
|
||||
|
||||
result2, _ := frd.Execute(context.Background(), map[string]any{
|
||||
"path_array": []any{"deleted.go"},
|
||||
})
|
||||
if !strings.Contains(result2, "not found") {
|
||||
t.Errorf("/dev/null path should not be in DiffMap, got: %q", result2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterDiffs(t *testing.T) {
|
||||
a := New(Args{
|
||||
FileFilter: &rules.FileFilter{
|
||||
Exclude: []string{"vendor/**"},
|
||||
},
|
||||
})
|
||||
a.diffs = []model.Diff{
|
||||
{NewPath: "main.go"},
|
||||
{NewPath: "vendor/dep.go"},
|
||||
{NewPath: "image.png", IsBinary: true},
|
||||
{NewPath: "handler.go"},
|
||||
}
|
||||
|
||||
kept := a.filterDiffs(a.diffs)
|
||||
|
||||
names := make(map[string]bool)
|
||||
for _, d := range kept {
|
||||
names[d.NewPath] = true
|
||||
}
|
||||
if names["vendor/dep.go"] {
|
||||
t.Error("vendor file should be filtered")
|
||||
}
|
||||
if names["image.png"] {
|
||||
t.Error("binary file should be filtered")
|
||||
}
|
||||
if !names["main.go"] || !names["handler.go"] {
|
||||
t.Error("valid files should be kept")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSystemRule(t *testing.T) {
|
||||
t.Run("nil SystemRule returns empty", func(t *testing.T) {
|
||||
a := New(Args{SystemRule: nil})
|
||||
if got := a.resolveSystemRule("main.go"); got != "" {
|
||||
t.Errorf("expected empty, got %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("with resolver", func(t *testing.T) {
|
||||
rule, err := rules.LoadDefault()
|
||||
if err != nil {
|
||||
t.Skipf("cannot load default rules: %v", err)
|
||||
}
|
||||
a := New(Args{SystemRule: rule})
|
||||
got := a.resolveSystemRule("main.go")
|
||||
if got == "" {
|
||||
t.Error("expected non-empty rule for .go file")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestFindDiff(t *testing.T) {
|
||||
a := New(Args{})
|
||||
a.diffs = []model.Diff{
|
||||
{NewPath: "a.go", OldPath: "a.go", Diff: "+a"},
|
||||
{NewPath: "b.go", OldPath: "old_b.go", Diff: "+b"},
|
||||
}
|
||||
|
||||
if d := a.findDiff("a.go"); d == nil || d.NewPath != "a.go" {
|
||||
t.Error("findDiff should find by NewPath")
|
||||
}
|
||||
if d := a.findDiff("old_b.go"); d == nil || d.NewPath != "b.go" {
|
||||
t.Error("findDiff should find by OldPath")
|
||||
}
|
||||
if d := a.findDiff("nonexist.go"); d != nil {
|
||||
t.Error("findDiff should return nil for missing path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteReviewFilter_NoFilterTask(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
sess := session.New(tmpDir, "main", "test", session.SessionOptions{ReviewMode: "diff"})
|
||||
client := &fakeAgentClient{}
|
||||
a := New(Args{
|
||||
LLMClient: client,
|
||||
Model: "test",
|
||||
Session: sess,
|
||||
Template: template.Template{
|
||||
ReviewFilterTask: nil,
|
||||
MaxTokens: 10000,
|
||||
MaxToolRequestTimes: 5,
|
||||
MainTask: template.LlmConversation{Messages: []template.ChatMessage{{Role: "user", Content: "t"}}},
|
||||
},
|
||||
})
|
||||
|
||||
a.executeReviewFilter(context.Background(), model.Diff{NewPath: "a.go"}, "a.go")
|
||||
if client.calls != 0 {
|
||||
t.Errorf("no LLM calls expected when ReviewFilterTask is nil, got %d", client.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteReviewFilter_NoComments(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
sess := session.New(tmpDir, "main", "test", session.SessionOptions{ReviewMode: "diff"})
|
||||
client := &fakeAgentClient{}
|
||||
a := New(Args{
|
||||
LLMClient: client,
|
||||
Model: "test",
|
||||
Session: sess,
|
||||
Template: template.Template{
|
||||
ReviewFilterTask: &template.LlmConversation{
|
||||
Messages: []template.ChatMessage{{Role: "user", Content: "Filter {{comments}} for {{path}} in {{diff}}"}},
|
||||
},
|
||||
MaxTokens: 10000,
|
||||
MaxToolRequestTimes: 5,
|
||||
MainTask: template.LlmConversation{Messages: []template.ChatMessage{{Role: "user", Content: "t"}}},
|
||||
},
|
||||
})
|
||||
|
||||
a.executeReviewFilter(context.Background(), model.Diff{NewPath: "a.go", Diff: "+x"}, "a.go")
|
||||
if client.calls != 0 {
|
||||
t.Errorf("no LLM calls expected when no comments exist, got %d", client.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteReviewFilter_RemovesComments(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
sess := session.New(tmpDir, "main", "test", session.SessionOptions{ReviewMode: "diff"})
|
||||
|
||||
filterResp := `["c-1"]`
|
||||
client := &fakeAgentClient{
|
||||
responses: []*llm.ChatResponse{{
|
||||
Choices: []llm.Choice{{
|
||||
Message: llm.ResponseMessage{Content: &filterResp},
|
||||
}},
|
||||
Usage: &llm.UsageInfo{PromptTokens: 10, CompletionTokens: 5},
|
||||
}},
|
||||
}
|
||||
|
||||
collector := tool.NewCommentCollector()
|
||||
collector.Add(model.LlmComment{Path: "a.go", Content: "keep this"})
|
||||
collector.Add(model.LlmComment{Path: "a.go", Content: "remove this"})
|
||||
collector.Add(model.LlmComment{Path: "a.go", Content: "also keep"})
|
||||
|
||||
a := New(Args{
|
||||
LLMClient: client,
|
||||
Model: "test",
|
||||
Session: sess,
|
||||
CommentCollector: collector,
|
||||
Template: template.Template{
|
||||
ReviewFilterTask: &template.LlmConversation{
|
||||
Messages: []template.ChatMessage{{Role: "user", Content: "Filter: {{comments}} path={{path}} diff={{diff}}"}},
|
||||
},
|
||||
MaxTokens: 10000,
|
||||
MaxToolRequestTimes: 5,
|
||||
MainTask: template.LlmConversation{Messages: []template.ChatMessage{{Role: "user", Content: "t"}}},
|
||||
},
|
||||
})
|
||||
|
||||
a.executeReviewFilter(context.Background(), model.Diff{NewPath: "a.go", Diff: "+code"}, "a.go")
|
||||
|
||||
comments := collector.CommentsForPath("a.go")
|
||||
if len(comments) != 2 {
|
||||
t.Fatalf("expected 2 comments after filter, got %d", len(comments))
|
||||
}
|
||||
for _, c := range comments {
|
||||
if c.Content == "remove this" {
|
||||
t.Error("filtered comment should have been removed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteReviewFilter_LLMError(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
sess := session.New(tmpDir, "main", "test", session.SessionOptions{ReviewMode: "diff"})
|
||||
|
||||
client := &fakeAgentClient{
|
||||
responses: nil,
|
||||
}
|
||||
|
||||
collector := tool.NewCommentCollector()
|
||||
collector.Add(model.LlmComment{Path: "a.go", Content: "comment"})
|
||||
|
||||
a := New(Args{
|
||||
LLMClient: client,
|
||||
Model: "test",
|
||||
Session: sess,
|
||||
CommentCollector: collector,
|
||||
Template: template.Template{
|
||||
ReviewFilterTask: &template.LlmConversation{
|
||||
Messages: []template.ChatMessage{{Role: "user", Content: "{{comments}} {{path}} {{diff}}"}},
|
||||
},
|
||||
MaxTokens: 10000,
|
||||
MaxToolRequestTimes: 5,
|
||||
MainTask: template.LlmConversation{Messages: []template.ChatMessage{{Role: "user", Content: "t"}}},
|
||||
},
|
||||
})
|
||||
|
||||
a.executeReviewFilter(context.Background(), model.Diff{NewPath: "a.go", Diff: "+x"}, "a.go")
|
||||
|
||||
comments := collector.CommentsForPath("a.go")
|
||||
if len(comments) != 1 {
|
||||
t.Errorf("comments should be unchanged on LLM error, got %d", len(comments))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutePlanPhase(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
sess := session.New(tmpDir, "main", "test", session.SessionOptions{ReviewMode: "diff"})
|
||||
|
||||
planText := "review plan output"
|
||||
client := &fakeAgentClient{
|
||||
responses: []*llm.ChatResponse{{
|
||||
Choices: []llm.Choice{{
|
||||
Message: llm.ResponseMessage{Content: &planText},
|
||||
}},
|
||||
Usage: &llm.UsageInfo{PromptTokens: 20, CompletionTokens: 10},
|
||||
}},
|
||||
}
|
||||
|
||||
a := New(Args{
|
||||
LLMClient: client,
|
||||
Model: "test",
|
||||
Session: sess,
|
||||
Background: "test background",
|
||||
Template: template.Template{
|
||||
PlanTask: &template.LlmConversation{
|
||||
Messages: []template.ChatMessage{
|
||||
{Role: "system", Content: "You are a planner. Date: {{current_system_date_time}}"},
|
||||
{Role: "user", Content: "Plan review for {{current_file_path}}. Rule: {{system_rule}}. Changes: {{change_files}}. Diff: {{diff}}. Background: {{requirement_background}}. Tools: {{plan_tools}}"},
|
||||
},
|
||||
},
|
||||
MaxTokens: 10000,
|
||||
MaxToolRequestTimes: 5,
|
||||
MainTask: template.LlmConversation{Messages: []template.ChatMessage{{Role: "user", Content: "t"}}},
|
||||
},
|
||||
})
|
||||
a.currentDate = "2025-06-26 10:00"
|
||||
|
||||
result, err := a.executePlanPhase(context.Background(), "main.go", "+new code", "helper.go", "check for bugs")
|
||||
if err != nil {
|
||||
t.Fatalf("executePlanPhase: %v", err)
|
||||
}
|
||||
if result != "review plan output" {
|
||||
t.Errorf("result = %q", result)
|
||||
}
|
||||
if a.TotalInputTokens() != 20 {
|
||||
t.Errorf("TotalInputTokens = %d, want 20", a.TotalInputTokens())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutePlanPhase_LLMError(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
sess := session.New(tmpDir, "main", "test", session.SessionOptions{ReviewMode: "diff"})
|
||||
|
||||
client := &fakeAgentClient{responses: nil}
|
||||
|
||||
a := New(Args{
|
||||
LLMClient: client,
|
||||
Model: "test",
|
||||
Session: sess,
|
||||
Template: template.Template{
|
||||
PlanTask: &template.LlmConversation{
|
||||
Messages: []template.ChatMessage{{Role: "user", Content: "{{diff}}"}},
|
||||
},
|
||||
MaxTokens: 10000,
|
||||
MaxToolRequestTimes: 5,
|
||||
MainTask: template.LlmConversation{Messages: []template.ChatMessage{{Role: "user", Content: "t"}}},
|
||||
},
|
||||
})
|
||||
|
||||
_, err := a.executePlanPhase(context.Background(), "a.go", "+x", "", "")
|
||||
if err != nil {
|
||||
t.Logf("expected no-error from empty response, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteSubtask_EmptyMainTask(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
sess := session.New(tmpDir, "main", "test", session.SessionOptions{ReviewMode: "diff"})
|
||||
|
||||
a := New(Args{
|
||||
LLMClient: &fakeAgentClient{},
|
||||
Model: "test",
|
||||
Session: sess,
|
||||
Template: template.Template{
|
||||
MaxTokens: 10000,
|
||||
MaxToolRequestTimes: 5,
|
||||
MainTask: template.LlmConversation{Messages: nil},
|
||||
},
|
||||
})
|
||||
a.currentDate = "2025-06-26 10:00"
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteSubtask_TokenThresholdExceeded(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
sess := session.New(tmpDir, "main", "test", session.SessionOptions{ReviewMode: "diff"})
|
||||
|
||||
a := New(Args{
|
||||
LLMClient: &fakeAgentClient{},
|
||||
Model: "test",
|
||||
Session: sess,
|
||||
Template: template.Template{
|
||||
MaxTokens: 10,
|
||||
MaxToolRequestTimes: 5,
|
||||
MainTask: template.LlmConversation{
|
||||
Messages: []template.ChatMessage{
|
||||
{Role: "user", Content: "Review: {{diff}}"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
a.currentDate = "2025-06-26 10:00"
|
||||
a.diffs = []model.Diff{{NewPath: "a.go", Diff: strings.Repeat("code ", 200), Insertions: 100}}
|
||||
|
||||
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
|
||||
for _, w := range warnings {
|
||||
if w.Type == "token_threshold_exceeded" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("expected token_threshold_exceeded warning")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteSubtask_WithPlanPhase(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
sess := session.New(tmpDir, "main", "test", session.SessionOptions{ReviewMode: "diff"})
|
||||
|
||||
planText := "my plan"
|
||||
doneContent := ""
|
||||
client := &fakeAgentClient{
|
||||
responses: []*llm.ChatResponse{
|
||||
{
|
||||
Choices: []llm.Choice{{Message: llm.ResponseMessage{Content: &planText}}},
|
||||
Usage: &llm.UsageInfo{PromptTokens: 5, CompletionTokens: 3},
|
||||
},
|
||||
{
|
||||
Choices: []llm.Choice{{
|
||||
Message: llm.ResponseMessage{
|
||||
Content: &doneContent,
|
||||
ToolCalls: []llm.ToolCall{{
|
||||
ID: "c1", Type: "function",
|
||||
Function: llm.FunctionCall{Name: "task_done", Arguments: "{}"},
|
||||
}},
|
||||
},
|
||||
}},
|
||||
Usage: &llm.UsageInfo{PromptTokens: 10, CompletionTokens: 5},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
reg := tool.NewRegistry()
|
||||
a := New(Args{
|
||||
LLMClient: client,
|
||||
Model: "test",
|
||||
Session: sess,
|
||||
Tools: reg,
|
||||
Template: template.Template{
|
||||
MaxTokens: 100000,
|
||||
MaxToolRequestTimes: 10,
|
||||
PlanModeLineThreshold: 0,
|
||||
PlanTask: &template.LlmConversation{
|
||||
Messages: []template.ChatMessage{
|
||||
{Role: "user", Content: "Plan for {{current_file_path}}: {{diff}}"},
|
||||
},
|
||||
},
|
||||
MainTask: template.LlmConversation{
|
||||
Messages: []template.ChatMessage{
|
||||
{Role: "user", Content: "Review {{current_file_path}} with plan {{plan_guidance}}: {{diff}}"},
|
||||
},
|
||||
},
|
||||
},
|
||||
MainToolDefs: []llm.ToolDef{
|
||||
{Type: "function", Function: llm.FunctionDef{Name: "task_done", Description: "done"}},
|
||||
},
|
||||
})
|
||||
a.currentDate = "2025-06-26 10:00"
|
||||
a.diffs = []model.Diff{{NewPath: "main.go", OldPath: "main.go", Diff: "+new code", Insertions: 5}}
|
||||
|
||||
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) {
|
||||
tmpDir := t.TempDir()
|
||||
sess := session.New(tmpDir, "main", "test", session.SessionOptions{ReviewMode: "diff"})
|
||||
|
||||
a := New(Args{
|
||||
LLMClient: &fakeAgentClient{},
|
||||
Model: "test",
|
||||
Session: sess,
|
||||
Template: template.Template{
|
||||
MaxTokens: 10000,
|
||||
MaxToolRequestTimes: 5,
|
||||
MainTask: template.LlmConversation{Messages: []template.ChatMessage{{Role: "user", Content: "{{diff}}"}}},
|
||||
},
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
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) {
|
||||
tmpDir := t.TempDir()
|
||||
sess := session.New(tmpDir, "main", "test", session.SessionOptions{ReviewMode: "diff"})
|
||||
|
||||
filterResp := `[]`
|
||||
client := &fakeAgentClient{
|
||||
responses: []*llm.ChatResponse{{
|
||||
Choices: []llm.Choice{{Message: llm.ResponseMessage{Content: &filterResp}}},
|
||||
Usage: &llm.UsageInfo{PromptTokens: 5, CompletionTokens: 2},
|
||||
}},
|
||||
}
|
||||
|
||||
collector := tool.NewCommentCollector()
|
||||
collector.Add(model.LlmComment{Path: "a.go", Content: "comment"})
|
||||
|
||||
a := New(Args{
|
||||
LLMClient: client,
|
||||
Model: "test",
|
||||
Session: sess,
|
||||
CommentCollector: collector,
|
||||
Template: template.Template{
|
||||
ReviewFilterTask: &template.LlmConversation{
|
||||
Timeout: 30,
|
||||
Messages: []template.ChatMessage{{Role: "user", Content: "{{comments}} {{path}} {{diff}}"}},
|
||||
},
|
||||
MaxTokens: 10000,
|
||||
MaxToolRequestTimes: 5,
|
||||
MainTask: template.LlmConversation{Messages: []template.ChatMessage{{Role: "user", Content: "t"}}},
|
||||
},
|
||||
})
|
||||
|
||||
a.executeReviewFilter(context.Background(), model.Diff{NewPath: "a.go", Diff: "+x"}, "a.go")
|
||||
|
||||
comments := collector.CommentsForPath("a.go")
|
||||
if len(comments) != 1 {
|
||||
t.Errorf("expected 1 comment unchanged, got %d", len(comments))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchSubtasks_AllFilteredBySize(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
sess := session.New(tmpDir, "main", "test", session.SessionOptions{ReviewMode: "diff"})
|
||||
|
||||
a := New(Args{
|
||||
LLMClient: &fakeAgentClient{},
|
||||
Model: "test",
|
||||
Session: sess,
|
||||
Template: template.Template{
|
||||
MaxTokens: 10,
|
||||
MaxToolRequestTimes: 5,
|
||||
MainTask: template.LlmConversation{Messages: []template.ChatMessage{{Role: "user", Content: "{{diff}}"}}},
|
||||
},
|
||||
})
|
||||
a.diffs = []model.Diff{
|
||||
{NewPath: "big.go", Diff: strings.Repeat("word ", 500), Insertions: 100},
|
||||
}
|
||||
|
||||
_, err := a.dispatchSubtasks(context.Background())
|
||||
if err == nil || !strings.Contains(err.Error(), "all diffs filtered out") {
|
||||
t.Errorf("expected 'all diffs filtered out' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchSubtasks_AllFailed(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
sess := session.New(tmpDir, "main", "test", session.SessionOptions{ReviewMode: "diff"})
|
||||
|
||||
a := New(Args{
|
||||
LLMClient: &fakeAgentClient{},
|
||||
Model: "test",
|
||||
Session: sess,
|
||||
Template: template.Template{
|
||||
MaxTokens: 100000,
|
||||
MaxToolRequestTimes: 5,
|
||||
MainTask: template.LlmConversation{Messages: nil},
|
||||
},
|
||||
})
|
||||
a.diffs = []model.Diff{
|
||||
{NewPath: "a.go", Diff: "+x", Insertions: 1},
|
||||
}
|
||||
a.currentDate = "2025-06-26"
|
||||
|
||||
_, err := a.dispatchSubtasks(context.Background())
|
||||
if err == nil || !strings.Contains(err.Error(), "failed") {
|
||||
t.Errorf("expected failure error, got: %v", err)
|
||||
}
|
||||
}
|
||||
5
internal/agent/init_test.go
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
package agent
|
||||
|
||||
import "github.com/open-code-review/open-code-review/internal/session"
|
||||
|
||||
func init() { session.UseTestSessions() }
|
||||
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
|
|
@ -327,6 +327,7 @@ func loadGlobalRule() (*ProjectRule, error) {
|
|||
if err := json.Unmarshal(data, &pr); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal global rule: %w", err)
|
||||
}
|
||||
resolveRuleEntries(pr.Rules, filepath.Dir(path))
|
||||
return &pr, nil
|
||||
}
|
||||
|
||||
|
|
@ -339,9 +340,15 @@ func loadRuleFile(path string) (*ProjectRule, error) {
|
|||
if err := json.Unmarshal(data, &pr); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal rule file %s: %w", path, err)
|
||||
}
|
||||
resolveRuleEntries(pr.Rules, filepath.Dir(path))
|
||||
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)
|
||||
|
|
@ -355,6 +362,7 @@ func loadProjectRule(repoDir string) (*ProjectRule, error) {
|
|||
if err := json.Unmarshal(data, &pr); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal project rule: %w", err)
|
||||
}
|
||||
resolveRuleEntries(pr.Rules, repoDir)
|
||||
return &pr, nil
|
||||
}
|
||||
|
||||
|
|
@ -438,3 +446,113 @@ func matchProjectRuleEntry(pr *ProjectRule, path string) *ProjectRuleEntry {
|
|||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// allowedRuleExts is the set of file extensions permitted for rule file references.
|
||||
var allowedRuleExts = map[string]bool{".md": true, ".txt": true, ".markdown": true}
|
||||
|
||||
// looksLikeFilePath returns true when s is likely a file path (not inline content).
|
||||
// Heuristic: multi-line text is always inline; single-line text without spaces
|
||||
// ending in .md/.txt/.markdown is treated as a file path. Values containing spaces
|
||||
// (e.g. "Follow rules from team.md") are treated as inline to avoid false positives.
|
||||
func looksLikeFilePath(s string) bool {
|
||||
if strings.Contains(s, "\n") {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(s, " ") {
|
||||
return false
|
||||
}
|
||||
return allowedRuleExts[strings.ToLower(filepath.Ext(s))]
|
||||
}
|
||||
|
||||
// resolveRuleEntries scans each entry's Rule field. When the value looks like a file
|
||||
// path, it reads the file content and replaces the Rule. Absolute paths are used
|
||||
// directly; relative paths are resolved against repoDir only. Multi-line and short
|
||||
// inline rules are left unchanged. If the file cannot be read, the Rule is cleared
|
||||
// (set to empty) and a warning is emitted.
|
||||
func resolveRuleEntries(entries []ProjectRuleEntry, repoDir string) {
|
||||
for i := range entries {
|
||||
e := &entries[i]
|
||||
if strings.TrimSpace(e.Rule) == "" || !looksLikeFilePath(e.Rule) {
|
||||
continue
|
||||
}
|
||||
if content := tryReadRuleFile(e.Rule, repoDir); content != nil {
|
||||
e.Rule = *content
|
||||
} else {
|
||||
e.Rule = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// tryReadRuleFile attempts to read a rule file. Absolute paths are used directly.
|
||||
// Relative paths are resolved against repoDir and validated to stay within repoDir.
|
||||
// Returns nil when the file cannot be read safely or does not exist.
|
||||
func tryReadRuleFile(rule string, repoDir string) *string {
|
||||
if repoDir == "" {
|
||||
if !filepath.IsAbs(rule) {
|
||||
fmt.Fprintf(os.Stderr, "[ocr] WARNING: cannot resolve relative rule path %q without a repo dir\n", rule)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if filepath.IsAbs(rule) {
|
||||
content, err := readRuleFileSafe(rule)
|
||||
if err == nil {
|
||||
return &content
|
||||
}
|
||||
if os.IsNotExist(err) {
|
||||
fmt.Fprintf(os.Stderr, "[ocr] WARNING: rule file not found: %s\n", rule)
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "[ocr] WARNING: cannot read rule file %s: %v\n", rule, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Relative path: resolve against repoDir, validate no traversal.
|
||||
resolved := filepath.Clean(filepath.Join(repoDir, rule))
|
||||
cleanRepo := filepath.Clean(repoDir)
|
||||
if !strings.HasPrefix(resolved, cleanRepo+string(os.PathSeparator)) {
|
||||
fmt.Fprintf(os.Stderr, "[ocr] WARNING: rule file path escapes repo dir: %s\n", rule)
|
||||
return nil
|
||||
}
|
||||
|
||||
content, err := readRuleFileSafe(resolved)
|
||||
if err == nil {
|
||||
return &content
|
||||
}
|
||||
if os.IsNotExist(err) {
|
||||
fmt.Fprintf(os.Stderr, "[ocr] WARNING: rule file not found: %s\n", rule)
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "[ocr] WARNING: cannot read rule file %s: %v\n", resolved, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// readRuleFileSafe reads and validates a rule file. It enforces extension whitelist
|
||||
// (.md / .txt / .markdown), a 512 KB size cap, and resolves symlinks before checking
|
||||
// the path. Symlinks are resolved first, then size is checked via Stat before reading.
|
||||
// Returns the trimmed content on success.
|
||||
func readRuleFileSafe(path string) (string, error) {
|
||||
resolved, err := filepath.EvalSymlinks(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if !allowedRuleExts[strings.ToLower(filepath.Ext(resolved))] {
|
||||
return "", fmt.Errorf("unsupported extension %q, only .md/.txt/.markdown allowed", filepath.Ext(resolved))
|
||||
}
|
||||
|
||||
const maxSize = 512 * 1024
|
||||
info, err := os.Stat(resolved)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if info.Size() > maxSize {
|
||||
return "", fmt.Errorf("file too large (%d bytes, max %d)", info.Size(), maxSize)
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(resolved)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return strings.TrimRight(string(content), "\n"), nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -945,3 +958,406 @@ func TestNewResolver_BraceExpansionInProjectRule(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── resolveRuleEntries tests ──
|
||||
|
||||
func TestResolveRuleEntries_BasicFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ruleFile := filepath.Join(dir, "sql-rules.md")
|
||||
if err := os.WriteFile(ruleFile, []byte("Check for SQL injection\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
entries := []ProjectRuleEntry{
|
||||
{Path: "**/*.xml", Rule: "sql-rules.md"},
|
||||
{Path: "**/*.go", Rule: "Always check for nil"},
|
||||
}
|
||||
resolveRuleEntries(entries, dir)
|
||||
|
||||
if entries[0].Rule != "Check for SQL injection" {
|
||||
t.Errorf("expected file content, got %q", entries[0].Rule)
|
||||
}
|
||||
if entries[1].Rule != "Always check for nil" {
|
||||
t.Errorf("inline rule should not change, got %q", entries[1].Rule)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRuleEntries_MultiLineInline(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// Create a file with the same name as the inline rule to make sure
|
||||
// multi-line detection prevents file lookup.
|
||||
if err := os.WriteFile(filepath.Join(dir, "security.md"), []byte("file content"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
entries := []ProjectRuleEntry{
|
||||
{Path: "**/*.ts", Rule: "security.md\nBut this is multi-line\nso it should stay inline"},
|
||||
}
|
||||
resolveRuleEntries(entries, dir)
|
||||
|
||||
if entries[0].Rule != "security.md\nBut this is multi-line\nso it should stay inline" {
|
||||
t.Errorf("multi-line rule should stay inline, got %q", entries[0].Rule)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRuleEntries_MissingFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
entries := []ProjectRuleEntry{
|
||||
{Path: "**/*.xml", Rule: "nonexistent.md"},
|
||||
}
|
||||
resolveRuleEntries(entries, dir)
|
||||
|
||||
// Missing file should clear the rule.
|
||||
if entries[0].Rule != "" {
|
||||
t.Errorf("missing file should clear rule, got %q", entries[0].Rule)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRuleEntries_AbsolutePath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ruleFile := filepath.Join(dir, "my-rule.md")
|
||||
if err := os.WriteFile(ruleFile, []byte("absolute rule content"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Use an absolute path pointing to a file in a different directory.
|
||||
entries := []ProjectRuleEntry{
|
||||
{Path: "**/*.go", Rule: ruleFile},
|
||||
}
|
||||
resolveRuleEntries(entries, "/some/other/repo")
|
||||
|
||||
if entries[0].Rule != "absolute rule content" {
|
||||
t.Errorf("expected absolute file content, got %q", entries[0].Rule)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRuleEntries_TooLarge(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
big := make([]byte, 513*1024)
|
||||
for i := range big {
|
||||
big[i] = 'a'
|
||||
}
|
||||
bigFile := filepath.Join(dir, "big.md")
|
||||
if err := os.WriteFile(bigFile, big, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
entries := []ProjectRuleEntry{
|
||||
{Path: "**/*.go", Rule: "big.md"},
|
||||
}
|
||||
resolveRuleEntries(entries, dir)
|
||||
|
||||
if entries[0].Rule != "" {
|
||||
t.Errorf("oversized file should clear rule, got %q", entries[0].Rule)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRuleEntries_RelativePath(t *testing.T) {
|
||||
repoDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(repoDir, "shared.md"), []byte("repo-level"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
entries := []ProjectRuleEntry{
|
||||
{Path: "**/*.go", Rule: "shared.md"},
|
||||
}
|
||||
resolveRuleEntries(entries, repoDir)
|
||||
|
||||
if entries[0].Rule != "repo-level" {
|
||||
t.Errorf("repo-level should win, got %q", entries[0].Rule)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRuleEntries_EmptyRule(t *testing.T) {
|
||||
entries := []ProjectRuleEntry{
|
||||
{Path: "**/*.go", Rule: ""},
|
||||
{Path: "**/*.ts", Rule: " "},
|
||||
{Path: "**/*.java", Rule: "\t\n"},
|
||||
}
|
||||
resolveRuleEntries(entries, "/tmp")
|
||||
|
||||
if entries[0].Rule != "" {
|
||||
t.Errorf("empty rule should stay empty, got %q", entries[0].Rule)
|
||||
}
|
||||
if entries[1].Rule != " " {
|
||||
t.Errorf("whitespace-only rule should stay unchanged, got %q", entries[1].Rule)
|
||||
}
|
||||
if entries[2].Rule != "\t\n" {
|
||||
t.Errorf("whitespace+newline rule should stay unchanged, got %q", entries[2].Rule)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRuleEntries_SymlinkSafety(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sensitiveFile := filepath.Join(dir, "secret.json")
|
||||
if err := os.WriteFile(sensitiveFile, []byte("SECRET"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create a symlink with .md extension pointing to a .json file.
|
||||
// The extension check on the resolved path should reject .json.
|
||||
symlinkPath := filepath.Join(dir, "evil.md")
|
||||
if err := os.Symlink(sensitiveFile, symlinkPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
entries := []ProjectRuleEntry{
|
||||
{Path: "**/*.go", Rule: "evil.md"},
|
||||
}
|
||||
resolveRuleEntries(entries, dir)
|
||||
// The symlink target is .json, which is not in the whitelist.
|
||||
// The rule should be cleared.
|
||||
if entries[0].Rule != "" {
|
||||
t.Errorf("symlink to non-whitelisted file should clear rule, got %q", entries[0].Rule)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRuleEntries_TxtExtension(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "rules.txt"), []byte("rule from txt"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
entries := []ProjectRuleEntry{
|
||||
{Path: "**/*.go", Rule: "rules.txt"},
|
||||
}
|
||||
resolveRuleEntries(entries, dir)
|
||||
|
||||
if entries[0].Rule != "rule from txt" {
|
||||
t.Errorf(".txt should be accepted, got %q", entries[0].Rule)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRuleEntries_MarkdownExtension(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "rules.markdown"), []byte("rule from markdown"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
entries := []ProjectRuleEntry{
|
||||
{Path: "**/*.go", Rule: "rules.markdown"},
|
||||
}
|
||||
resolveRuleEntries(entries, dir)
|
||||
|
||||
if entries[0].Rule != "rule from markdown" {
|
||||
t.Errorf(".markdown should be accepted, got %q", entries[0].Rule)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRuleEntries_SubdirectoryPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
docsDir := filepath.Join(dir, "docs")
|
||||
if err := os.MkdirAll(docsDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(docsDir, "my-rule.md"), []byte("nested rule"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
entries := []ProjectRuleEntry{
|
||||
{Path: "**/*.go", Rule: "docs/my-rule.md"},
|
||||
}
|
||||
resolveRuleEntries(entries, dir)
|
||||
|
||||
if entries[0].Rule != "nested rule" {
|
||||
t.Errorf("subdirectory path should work, got %q", entries[0].Rule)
|
||||
}
|
||||
}
|
||||
|
||||
// ── looksLikeFilePath tests ──
|
||||
|
||||
func TestLooksLikeFilePath_InlineContent(t *testing.T) {
|
||||
tests := []string{
|
||||
"Check for null pointers",
|
||||
"Always validate input",
|
||||
"security",
|
||||
"xss",
|
||||
}
|
||||
for _, s := range tests {
|
||||
if looksLikeFilePath(s) {
|
||||
t.Errorf("looksLikeFilePath(%q) should be false", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLooksLikeFilePath_MultiLine(t *testing.T) {
|
||||
s := "line1\nline2\nline3"
|
||||
if looksLikeFilePath(s) {
|
||||
t.Errorf("multi-line should be false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLooksLikeFilePath_FileExtensions(t *testing.T) {
|
||||
tests := []string{
|
||||
"rules.md",
|
||||
"doc.txt",
|
||||
"doc.markdown",
|
||||
"DOC.MD",
|
||||
"path/to/file.md",
|
||||
}
|
||||
for _, s := range tests {
|
||||
if !looksLikeFilePath(s) {
|
||||
t.Errorf("looksLikeFilePath(%q) should be true", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLooksLikeFilePath_WithSpaces(t *testing.T) {
|
||||
// Values containing spaces are inline, not file paths.
|
||||
tests := []string{
|
||||
"Follow rules from team.md",
|
||||
"Ensure output is in .md",
|
||||
"use .txt format",
|
||||
}
|
||||
for _, s := range tests {
|
||||
if looksLikeFilePath(s) {
|
||||
t.Errorf("looksLikeFilePath(%q) should be false (contains space)", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLooksLikeFilePath_PathWithoutExtension(t *testing.T) {
|
||||
// Paths without .md/.txt/.markdown are NOT treated as file paths.
|
||||
tests := []string{
|
||||
"docs/security",
|
||||
"shared/rules/go",
|
||||
"Use HTTP/2 for all requests",
|
||||
}
|
||||
for _, s := range tests {
|
||||
if looksLikeFilePath(s) {
|
||||
t.Errorf("looksLikeFilePath(%q) should be false (no .md/.txt/.markdown)", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── readRuleFileSafe tests ──
|
||||
|
||||
func TestReadRuleFileSafe_NormalFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
f := filepath.Join(dir, "test.md")
|
||||
if err := os.WriteFile(f, []byte("hello world\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
content, err := readRuleFileSafe(f)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if content != "hello world" {
|
||||
t.Errorf("expected 'hello world', got %q", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadRuleFileSafe_UnsupportedExt(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
f := filepath.Join(dir, "test.json")
|
||||
if err := os.WriteFile(f, []byte("{}"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := readRuleFileSafe(f)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for .json")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadRuleFileSafe_TooLarge(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
f := filepath.Join(dir, "big.md")
|
||||
big := make([]byte, 513*1024)
|
||||
if err := os.WriteFile(f, big, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := readRuleFileSafe(f)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for oversized file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadRuleFileSafe_Missing(t *testing.T) {
|
||||
_, err := readRuleFileSafe("/nonexistent/path.md")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing file")
|
||||
}
|
||||
}
|
||||
|
||||
// ── path traversal tests ──
|
||||
|
||||
func TestResolveRuleEntries_PathTraversalBlocked(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// Create a file outside the repo dir to prove it is NOT read.
|
||||
outside := filepath.Join(t.TempDir(), "outside.md")
|
||||
if err := os.WriteFile(outside, []byte("should not be read\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
entries := []ProjectRuleEntry{
|
||||
{Path: "**/*.go", Rule: outside}, // absolute path to outside — allowed
|
||||
{Path: "**/*.ts", Rule: "../outside.md"}, // relative traversal — blocked
|
||||
}
|
||||
resolveRuleEntries(entries, dir)
|
||||
|
||||
// Absolute path to outside is allowed (explicit design choice).
|
||||
if entries[0].Rule != "should not be read" {
|
||||
t.Errorf("absolute path to outside should be allowed, got %q", entries[0].Rule)
|
||||
}
|
||||
// Relative traversal should be blocked and rule cleared.
|
||||
if entries[1].Rule != "" {
|
||||
t.Errorf("relative traversal should be blocked, got %q", entries[1].Rule)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRuleEntries_EmptyRepoDirRelative(t *testing.T) {
|
||||
// When repoDir is empty and rule is relative, it should be rejected.
|
||||
entries := []ProjectRuleEntry{
|
||||
{Path: "**/*.go", Rule: "rules.md"},
|
||||
}
|
||||
resolveRuleEntries(entries, "")
|
||||
|
||||
if entries[0].Rule != "" {
|
||||
t.Errorf("relative path with empty repoDir should be rejected, got %q", entries[0].Rule)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRuleEntries_EmptyRepoDirAbsolute(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
absFile := filepath.Join(dir, "abs.md")
|
||||
if err := os.WriteFile(absFile, []byte("absolute content\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
entries := []ProjectRuleEntry{
|
||||
{Path: "**/*.go", Rule: absFile},
|
||||
}
|
||||
resolveRuleEntries(entries, "")
|
||||
|
||||
if entries[0].Rule != "absolute content" {
|
||||
t.Errorf("absolute path with empty repoDir should work, got %q", entries[0].Rule)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRuleEntries_GlobalRuleFileResolution(t *testing.T) {
|
||||
// Simulate loadGlobalRule: repoDir = filepath.Dir(~/.opencodereview/rule.json)
|
||||
homeDir := t.TempDir()
|
||||
t.Setenv("HOME", homeDir)
|
||||
|
||||
globalRuleDir := filepath.Join(homeDir, ".opencodereview")
|
||||
if err := os.MkdirAll(globalRuleDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(globalRuleDir, "reusable.md"), []byte("global reusable rule\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
entries := []ProjectRuleEntry{
|
||||
{Path: "**/*.go", Rule: "reusable.md"},
|
||||
}
|
||||
// repoDir = ~/.opencodereview (where rule.json lives)
|
||||
resolveRuleEntries(entries, globalRuleDir)
|
||||
|
||||
if entries[0].Rule != "global reusable rule" {
|
||||
t.Errorf("global rule file should be resolved, got %q", entries[0].Rule)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -160,3 +160,170 @@ func TestApplyLanguage_DefaultEnglish(t *testing.T) {
|
|||
t.Errorf("MainTask system message does not end with %q", suffix)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_Template_Errors(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
tpl Template
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "zero MaxTokens",
|
||||
tpl: Template{MaxTokens: 0, MaxToolRequestTimes: 1, MainTask: LlmConversation{Messages: []ChatMessage{{Role: "system", Content: "x"}}}},
|
||||
wantErr: "max_tokens must be positive",
|
||||
},
|
||||
{
|
||||
name: "negative MaxTokens",
|
||||
tpl: Template{MaxTokens: -1, MaxToolRequestTimes: 1, MainTask: LlmConversation{Messages: []ChatMessage{{Role: "system", Content: "x"}}}},
|
||||
wantErr: "max_tokens must be positive",
|
||||
},
|
||||
{
|
||||
name: "zero MaxToolRequestTimes",
|
||||
tpl: Template{MaxTokens: 100, MaxToolRequestTimes: 0, MainTask: LlmConversation{Messages: []ChatMessage{{Role: "system", Content: "x"}}}},
|
||||
wantErr: "max_tool_request_times must be positive",
|
||||
},
|
||||
{
|
||||
name: "empty MainTask messages",
|
||||
tpl: Template{MaxTokens: 100, MaxToolRequestTimes: 1, MainTask: LlmConversation{Messages: nil}},
|
||||
wantErr: "main_task.messages must not be empty",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := tc.tpl.Validate()
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Errorf("error %q does not contain %q", err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_ScanTemplate(t *testing.T) {
|
||||
valid := ScanTemplate{
|
||||
MaxTokens: 100,
|
||||
MaxToolRequestTimes: 1,
|
||||
MainTask: LlmConversation{Messages: []ChatMessage{{Role: "system", Content: "x"}}},
|
||||
}
|
||||
if err := valid.Validate(); err != nil {
|
||||
t.Fatalf("valid ScanTemplate.Validate() error: %v", err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
tpl ScanTemplate
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "zero MaxTokens",
|
||||
tpl: ScanTemplate{MaxTokens: 0, MaxToolRequestTimes: 1, MainTask: LlmConversation{Messages: []ChatMessage{{Role: "system", Content: "x"}}}},
|
||||
wantErr: "scan: max_tokens must be positive",
|
||||
},
|
||||
{
|
||||
name: "zero MaxToolRequestTimes",
|
||||
tpl: ScanTemplate{MaxTokens: 100, MaxToolRequestTimes: 0, MainTask: LlmConversation{Messages: []ChatMessage{{Role: "system", Content: "x"}}}},
|
||||
wantErr: "scan: max_tool_request_times must be positive",
|
||||
},
|
||||
{
|
||||
name: "empty MainTask messages",
|
||||
tpl: ScanTemplate{MaxTokens: 100, MaxToolRequestTimes: 1, MainTask: LlmConversation{Messages: nil}},
|
||||
wantErr: "scan: main_task.messages must not be empty",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := tc.tpl.Validate()
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Errorf("error %q does not contain %q", err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadScanDefault_Validate(t *testing.T) {
|
||||
tpl, err := LoadScanDefault()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadScanDefault: %v", err)
|
||||
}
|
||||
if err := tpl.Validate(); err != nil {
|
||||
t.Errorf("loaded scan template should be valid: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyLanguage_ScanTemplate_AllOptionalTasks(t *testing.T) {
|
||||
tpl, err := LoadScanDefault()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadScanDefault: %v", err)
|
||||
}
|
||||
if tpl.DedupTask == nil {
|
||||
t.Fatal("DedupTask should be present in default scan template")
|
||||
}
|
||||
if tpl.ProjectSummaryTask == nil {
|
||||
t.Fatal("ProjectSummaryTask should be present in default scan template")
|
||||
}
|
||||
|
||||
tpl.ApplyLanguage("Japanese")
|
||||
suffix := "Always respond in Japanese."
|
||||
|
||||
check := func(name string, conv *LlmConversation) {
|
||||
t.Helper()
|
||||
if conv == nil {
|
||||
return
|
||||
}
|
||||
for _, m := range conv.Messages {
|
||||
if m.Role == "system" && !strings.Contains(m.Content, suffix) {
|
||||
t.Errorf("%s system message missing language directive", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
check("MainTask", &tpl.MainTask)
|
||||
check("PlanTask", tpl.PlanTask)
|
||||
check("DedupTask", tpl.DedupTask)
|
||||
check("ProjectSummaryTask", tpl.ProjectSummaryTask)
|
||||
check("MemoryCompressionTask", &tpl.MemoryCompressionTask)
|
||||
}
|
||||
|
||||
func TestApplyLanguage_ScanTemplate_NilOptionalTasks(t *testing.T) {
|
||||
tpl := &ScanTemplate{
|
||||
MainTask: LlmConversation{Messages: []ChatMessage{{Role: "system", Content: "base"}}},
|
||||
MemoryCompressionTask: LlmConversation{Messages: []ChatMessage{{Role: "system", Content: "compress"}}},
|
||||
}
|
||||
tpl.ApplyLanguage("Korean")
|
||||
suffix := "Always respond in Korean."
|
||||
if !strings.Contains(tpl.MainTask.Messages[0].Content, suffix) {
|
||||
t.Error("MainTask should contain language directive")
|
||||
}
|
||||
if !strings.Contains(tpl.MemoryCompressionTask.Messages[0].Content, suffix) {
|
||||
t.Error("MemoryCompressionTask should contain language directive")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyLanguage_SkipsNonSystemMessages(t *testing.T) {
|
||||
tpl := &Template{
|
||||
MainTask: LlmConversation{Messages: []ChatMessage{
|
||||
{Role: "system", Content: "sys"},
|
||||
{Role: "user", Content: "usr"},
|
||||
}},
|
||||
MemoryCompressionTask: LlmConversation{Messages: []ChatMessage{
|
||||
{Role: "system", Content: "sys"},
|
||||
}},
|
||||
}
|
||||
tpl.ApplyLanguage("French")
|
||||
if strings.Contains(tpl.MainTask.Messages[1].Content, "French") {
|
||||
t.Error("user-role message should not get language directive")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveLang(t *testing.T) {
|
||||
if got := resolveLang(""); got != "English" {
|
||||
t.Errorf("resolveLang(\"\") = %q, want \"English\"", got)
|
||||
}
|
||||
if got := resolveLang("German"); got != "German" {
|
||||
t.Errorf("resolveLang(\"German\") = %q, want \"German\"", got)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
92
internal/config/testconnection/testconnection_test.go
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
package testconnection
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadDefault(t *testing.T) {
|
||||
conv, err := LoadDefault()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadDefault: %v", err)
|
||||
}
|
||||
if conv == nil {
|
||||
t.Fatal("expected non-nil conversation")
|
||||
}
|
||||
if conv.Timeout <= 0 {
|
||||
t.Errorf("expected positive timeout, got %d", conv.Timeout)
|
||||
}
|
||||
if len(conv.Messages) == 0 {
|
||||
t.Fatal("expected at least one message")
|
||||
}
|
||||
hasSystem := false
|
||||
hasUser := false
|
||||
for _, m := range conv.Messages {
|
||||
switch m.Role {
|
||||
case "system":
|
||||
hasSystem = true
|
||||
case "user":
|
||||
hasUser = true
|
||||
}
|
||||
}
|
||||
if !hasSystem {
|
||||
t.Error("expected a system message")
|
||||
}
|
||||
if !hasUser {
|
||||
t.Error("expected a user message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveLang(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"", "English"},
|
||||
{"Chinese", "Chinese"},
|
||||
{"Japanese", "Japanese"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
got := resolveLang(tc.input)
|
||||
if got != tc.want {
|
||||
t.Errorf("resolveLang(%q) = %q, want %q", tc.input, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyLanguage(t *testing.T) {
|
||||
conv := &LlmConversation{
|
||||
Messages: []ChatMessage{
|
||||
{Role: "system", Content: "You are a bot."},
|
||||
{Role: "user", Content: "Hello"},
|
||||
},
|
||||
}
|
||||
|
||||
conv.ApplyLanguage("Chinese")
|
||||
|
||||
if conv.Messages[0].Content == "You are a bot." {
|
||||
t.Error("expected system message to be modified")
|
||||
}
|
||||
expected := "You are a bot.\n\nAlways respond in Chinese."
|
||||
if conv.Messages[0].Content != expected {
|
||||
t.Errorf("system content = %q, want %q", conv.Messages[0].Content, expected)
|
||||
}
|
||||
// User message should not be modified
|
||||
if conv.Messages[1].Content != "Hello" {
|
||||
t.Errorf("user content should not change, got %q", conv.Messages[1].Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyLanguage_EmptyLang(t *testing.T) {
|
||||
conv := &LlmConversation{
|
||||
Messages: []ChatMessage{
|
||||
{Role: "system", Content: "Base."},
|
||||
},
|
||||
}
|
||||
|
||||
conv.ApplyLanguage("")
|
||||
|
||||
expected := "Base.\n\nAlways respond in English."
|
||||
if conv.Messages[0].Content != expected {
|
||||
t.Errorf("content = %q, want %q", conv.Messages[0].Content, expected)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
106
internal/config/toolsconfig/toolsconfig_test.go
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
package toolsconfig
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoad_Default(t *testing.T) {
|
||||
tools, err := Load("")
|
||||
if err != nil {
|
||||
t.Fatalf("Load default tools: %v", err)
|
||||
}
|
||||
if len(tools) == 0 {
|
||||
t.Fatal("expected at least one tool from embedded config")
|
||||
}
|
||||
// Verify first tool has required fields
|
||||
first := tools[0]
|
||||
if first.Name == "" {
|
||||
t.Error("expected non-empty tool name")
|
||||
}
|
||||
if first.Definition == nil {
|
||||
t.Error("expected non-nil definition")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_CustomFile(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
path := filepath.Join(tmp, "tools.json")
|
||||
data := `[
|
||||
{"name": "test_tool", "plan_task": true, "main_task": false, "definition": {"name": "test_tool"}}
|
||||
]`
|
||||
if err := os.WriteFile(path, []byte(data), 0644); err != nil {
|
||||
t.Fatalf("write tools.json: %v", err)
|
||||
}
|
||||
|
||||
tools, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load custom file: %v", err)
|
||||
}
|
||||
if len(tools) != 1 {
|
||||
t.Fatalf("expected 1 tool, got %d", len(tools))
|
||||
}
|
||||
if tools[0].Name != "test_tool" {
|
||||
t.Errorf("expected name=test_tool, got %s", tools[0].Name)
|
||||
}
|
||||
if !tools[0].PlanTask {
|
||||
t.Error("expected PlanTask=true")
|
||||
}
|
||||
if tools[0].MainTask {
|
||||
t.Error("expected MainTask=false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_FileNotFound(t *testing.T) {
|
||||
_, err := Load("/nonexistent/tools.json")
|
||||
if err == nil {
|
||||
t.Error("expected error for nonexistent file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_InvalidJSON(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
path := filepath.Join(tmp, "tools.json")
|
||||
if err := os.WriteFile(path, []byte("not json"), 0644); err != nil {
|
||||
t.Fatalf("write tools.json: %v", err)
|
||||
}
|
||||
|
||||
_, err := Load(path)
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolDefsByPhase(t *testing.T) {
|
||||
def := json.RawMessage(`{"name": "test"}`)
|
||||
tests := []struct {
|
||||
name string
|
||||
entry ToolConfigEntry
|
||||
planOnly bool
|
||||
wantOk bool
|
||||
}{
|
||||
{"plan_task and planOnly=true", ToolConfigEntry{PlanTask: true, MainTask: false, Definition: def}, true, true},
|
||||
{"plan_task and planOnly=false", ToolConfigEntry{PlanTask: true, MainTask: false, Definition: def}, false, false},
|
||||
{"main_task and planOnly=false", ToolConfigEntry{PlanTask: false, MainTask: true, Definition: def}, false, true},
|
||||
{"main_task and planOnly=true", ToolConfigEntry{PlanTask: false, MainTask: true, Definition: def}, true, false},
|
||||
{"both and planOnly=true", ToolConfigEntry{PlanTask: true, MainTask: true, Definition: def}, true, true},
|
||||
{"both and planOnly=false", ToolConfigEntry{PlanTask: true, MainTask: true, Definition: def}, false, true},
|
||||
{"neither", ToolConfigEntry{PlanTask: false, MainTask: false, Definition: def}, true, false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, ok := tc.entry.ToolDefsByPhase(tc.planOnly)
|
||||
if ok != tc.wantOk {
|
||||
t.Errorf("ToolDefsByPhase(planOnly=%v) ok=%v, want %v", tc.planOnly, ok, tc.wantOk)
|
||||
}
|
||||
if tc.wantOk && got == nil {
|
||||
t.Error("expected non-nil definition when ok=true")
|
||||
}
|
||||
if !tc.wantOk && got != nil {
|
||||
t.Error("expected nil definition when ok=false")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
131
internal/diff/gitignore_test.go
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
package diff
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExcludedDirs(t *testing.T) {
|
||||
dirs := ExcludedDirs()
|
||||
if len(dirs) == 0 {
|
||||
t.Fatal("ExcludedDirs should return non-empty list")
|
||||
}
|
||||
found := false
|
||||
for _, d := range dirs {
|
||||
if d == ".git/" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("ExcludedDirs should include .git/")
|
||||
}
|
||||
|
||||
dirs2 := ExcludedDirs()
|
||||
dirs[0] = "MUTATED"
|
||||
if dirs2[0] == "MUTATED" {
|
||||
t.Error("ExcludedDirs should return a copy, not the original slice")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadGitignorePatterns(t *testing.T) {
|
||||
t.Run("valid gitignore", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
content := "*.log\n# comment\n\nnode_modules/\n*.tmp\n"
|
||||
if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
patterns := LoadGitignorePatterns(dir)
|
||||
want := []string{"*.log", "node_modules/", "*.tmp"}
|
||||
if len(patterns) != len(want) {
|
||||
t.Fatalf("got %d patterns %v, want %d %v", len(patterns), patterns, len(want), want)
|
||||
}
|
||||
for i := range want {
|
||||
if patterns[i] != want[i] {
|
||||
t.Errorf("patterns[%d] = %q, want %q", i, patterns[i], want[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing gitignore", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
patterns := LoadGitignorePatterns(dir)
|
||||
if patterns != nil {
|
||||
t.Errorf("expected nil for missing .gitignore, got %v", patterns)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestIsPathExcluded(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
relPath string
|
||||
patterns []string
|
||||
want bool
|
||||
}{
|
||||
{"hardcoded dir .git", ".git", nil, true},
|
||||
{"hardcoded dir prefix", ".git/config", nil, true},
|
||||
{"node_modules dir pattern", "node_modules/foo.js", []string{"node_modules/"}, true},
|
||||
{"gitignore pattern match", "debug.log", []string{"*.log"}, true},
|
||||
{"no match", "main.go", []string{"*.log"}, false},
|
||||
{"no patterns", "main.go", nil, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := IsPathExcluded(".", tt.relPath, tt.patterns)
|
||||
if got != tt.want {
|
||||
t.Errorf("IsPathExcluded(%q, %v) = %v, want %v", tt.relPath, tt.patterns, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchGitignorePattern(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
relPath string
|
||||
pattern string
|
||||
want bool
|
||||
}{
|
||||
{"basename glob match", "src/debug.log", "*.log", true},
|
||||
{"basename glob no match", "src/main.go", "*.log", false},
|
||||
{"directory pattern", "vendor/pkg/file.go", "vendor/", true},
|
||||
{"directory pattern nested", "a/vendor/b", "vendor/", true},
|
||||
{"directory pattern no match", "vendor_extra/file.go", "vendor/", false},
|
||||
{"full path glob", "docs/api.md", "docs/*.md", true},
|
||||
{"full path no match", "src/api.md", "docs/*.md", false},
|
||||
{"negation pattern", "important.log", "!important.log", false},
|
||||
{"path suffix match", "src/generated/api.go", "generated/api.go", true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := MatchGitignorePattern(tt.relPath, tt.pattern)
|
||||
if got != tt.want {
|
||||
t.Errorf("MatchGitignorePattern(%q, %q) = %v, want %v", tt.relPath, tt.pattern, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsRangeMode(t *testing.T) {
|
||||
p := &Provider{mode: ModeRange}
|
||||
if !p.IsRangeMode() {
|
||||
t.Error("expected IsRangeMode() = true for ModeRange")
|
||||
}
|
||||
p.mode = ModeCommit
|
||||
if p.IsRangeMode() {
|
||||
t.Error("expected IsRangeMode() = false for ModeCommit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsCommitMode(t *testing.T) {
|
||||
p := &Provider{mode: ModeCommit}
|
||||
if !p.IsCommitMode() {
|
||||
t.Error("expected IsCommitMode() = true for ModeCommit")
|
||||
}
|
||||
p.mode = ModeWorkspace
|
||||
if p.IsCommitMode() {
|
||||
t.Error("expected IsCommitMode() = false for ModeWorkspace")
|
||||
}
|
||||
}
|
||||
|
|
@ -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 == "" {
|
||||
|
|
|
|||
151
internal/gitcmd/runner_test.go
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
package gitcmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func initRepo(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
run := func(args ...string) {
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
cmd.Env = append(os.Environ(),
|
||||
"GIT_AUTHOR_NAME=test",
|
||||
"GIT_AUTHOR_EMAIL=test@test.com",
|
||||
"GIT_COMMITTER_NAME=test",
|
||||
"GIT_COMMITTER_EMAIL=test@test.com",
|
||||
)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %v: %v\n%s", args, err, out)
|
||||
}
|
||||
}
|
||||
run("init")
|
||||
run("config", "user.email", "test@test.com")
|
||||
run("config", "user.name", "test")
|
||||
if err := os.WriteFile(filepath.Join(dir, "hello.txt"), []byte("hello\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run("add", "hello.txt")
|
||||
run("commit", "-m", "init")
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestRunner_New(t *testing.T) {
|
||||
r := New(0)
|
||||
if r == nil {
|
||||
t.Fatal("New(0) returned nil")
|
||||
}
|
||||
if cap(r.sem) != defaultMaxConcurrent {
|
||||
t.Errorf("default capacity = %d, want %d", cap(r.sem), defaultMaxConcurrent)
|
||||
}
|
||||
|
||||
r2 := New(4)
|
||||
if cap(r2.sem) != 4 {
|
||||
t.Errorf("capacity = %d, want 4", cap(r2.sem))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunner_Run(t *testing.T) {
|
||||
dir := initRepo(t)
|
||||
r := New(2)
|
||||
|
||||
out, err := r.Run(context.Background(), dir, "log", "--oneline")
|
||||
if err != nil {
|
||||
t.Fatalf("Run error: %v", err)
|
||||
}
|
||||
if !strings.Contains(out, "init") {
|
||||
t.Errorf("expected 'init' in output: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunner_Run_InvalidCommand(t *testing.T) {
|
||||
dir := initRepo(t)
|
||||
r := New(2)
|
||||
|
||||
_, err := r.Run(context.Background(), dir, "nonexistent-subcommand")
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid git subcommand")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunner_Output(t *testing.T) {
|
||||
dir := initRepo(t)
|
||||
r := New(2)
|
||||
|
||||
out, err := r.Output(context.Background(), dir, "rev-parse", "HEAD")
|
||||
if err != nil {
|
||||
t.Fatalf("Output error: %v", err)
|
||||
}
|
||||
hash := strings.TrimSpace(string(out))
|
||||
if len(hash) != 40 {
|
||||
t.Errorf("expected 40-char hash, got %q", hash)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunner_RunSplit(t *testing.T) {
|
||||
dir := initRepo(t)
|
||||
r := New(2)
|
||||
|
||||
stdout, stderr, err := r.RunSplit(context.Background(), dir, "status", "--short")
|
||||
if err != nil {
|
||||
t.Fatalf("RunSplit error: %v", err)
|
||||
}
|
||||
_ = stderr
|
||||
if strings.Contains(stdout, "??") {
|
||||
t.Errorf("unexpected untracked files in clean repo: %q", stdout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunner_Stream(t *testing.T) {
|
||||
dir := initRepo(t)
|
||||
r := New(2)
|
||||
|
||||
var content string
|
||||
err := r.Stream(context.Background(), dir, func(stdout io.Reader) error {
|
||||
data, err := io.ReadAll(stdout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
content = string(data)
|
||||
return nil
|
||||
}, "show", "HEAD:hello.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("Stream error: %v", err)
|
||||
}
|
||||
if content != "hello\n" {
|
||||
t.Errorf("Stream content = %q, want %q", content, "hello\n")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunner_ContextCancelled(t *testing.T) {
|
||||
r := New(1)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
_, err := r.Run(ctx, ".", "status")
|
||||
if err == nil {
|
||||
t.Error("expected error for cancelled context")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunner_AcquireTimeout(t *testing.T) {
|
||||
r := New(1)
|
||||
r.sem <- struct{}{}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
_, err := r.Run(ctx, ".", "status")
|
||||
if err == nil {
|
||||
t.Error("expected timeout error when semaphore full")
|
||||
}
|
||||
}
|
||||
|
|
@ -198,6 +198,7 @@ func NewLLMClient(ep ResolvedEndpoint) LLMClient {
|
|||
APIKey: ep.Token,
|
||||
Model: ep.Model,
|
||||
AuthHeader: ep.AuthHeader,
|
||||
Timeout: ep.Timeout,
|
||||
ExtraBody: ep.ExtraBody,
|
||||
ExtraHeaders: ep.ExtraHeaders,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -482,3 +482,90 @@ func TestAnthropicClient_NoExtraHeadersWhenEmpty(t *testing.T) {
|
|||
|
||||
// Verify the SDK constant is accessible (compile-time check).
|
||||
var _ anthropic.CacheControlEphemeralParam = anthropic.NewCacheControlEphemeralParam()
|
||||
|
||||
func TestCountTokens(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
text string
|
||||
want int
|
||||
}{
|
||||
{"empty", "", 0},
|
||||
{"single word", "hello", 1},
|
||||
{"sentence", "The quick brown fox jumps over the lazy dog.", 10},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := CountTokens(tt.text)
|
||||
if tt.want == 0 && got != 0 {
|
||||
t.Errorf("CountTokens(%q) = %d, want 0", tt.text, got)
|
||||
}
|
||||
if tt.want > 0 && got == 0 {
|
||||
t.Errorf("CountTokens(%q) = 0, expected > 0", tt.text)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountTokensForModel(t *testing.T) {
|
||||
text := "Hello, world! This is a test."
|
||||
base := CountTokensForModel(text, "gpt-4")
|
||||
o1 := CountTokensForModel(text, "o1-mini")
|
||||
|
||||
if base == 0 {
|
||||
t.Error("cl100k_base should produce non-zero tokens")
|
||||
}
|
||||
if o1 == 0 {
|
||||
t.Error("o200k_base should produce non-zero tokens")
|
||||
}
|
||||
|
||||
if CountTokensForModel("", "gpt-4") != 0 {
|
||||
t.Error("empty text should return 0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodingForModel(t *testing.T) {
|
||||
tests := []struct {
|
||||
model string
|
||||
want string
|
||||
}{
|
||||
{"gpt-4", "cl100k_base"},
|
||||
{"claude-3-opus", "cl100k_base"},
|
||||
{"", "cl100k_base"},
|
||||
{"o1-preview", "o200k_base"},
|
||||
{"o3-mini", "o200k_base"},
|
||||
{"o4-mini", "o200k_base"},
|
||||
{"GPT-O1", "o200k_base"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.model, func(t *testing.T) {
|
||||
got := encodingForModel(tt.model)
|
||||
if got != tt.want {
|
||||
t.Errorf("encodingForModel(%q) = %q, want %q", tt.model, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripThinkTags(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"no tags", "hello world", "hello world"},
|
||||
{"open only", "<think>partial", "partial"},
|
||||
{"close only", "partial</think>", "partial"},
|
||||
{"both tags", "<think>reasoning here</think>answer", "reasoning hereanswer"},
|
||||
{"multiple tags", "<think>a</think>b<think>c</think>d", "abcd"},
|
||||
{"empty", "", ""},
|
||||
{"tags only", "<think></think>", ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := stripThinkTags(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("stripThinkTags(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
78
internal/llm/embedded_loader_test.go
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
package llm
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseBpeData_Valid(t *testing.T) {
|
||||
// "hello" base64 = "aGVsbG8="
|
||||
input := []byte("aGVsbG8= 42\nd29ybGQ= 7\n")
|
||||
ranks, err := parseBpeData(input)
|
||||
if err != nil {
|
||||
t.Fatalf("parseBpeData: %v", err)
|
||||
}
|
||||
if ranks["hello"] != 42 {
|
||||
t.Errorf("ranks[hello] = %d, want 42", ranks["hello"])
|
||||
}
|
||||
if ranks["world"] != 7 {
|
||||
t.Errorf("ranks[world] = %d, want 7", ranks["world"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBpeData_EmptyLines(t *testing.T) {
|
||||
input := []byte("\n \naGVsbG8= 1\n\n")
|
||||
ranks, err := parseBpeData(input)
|
||||
if err != nil {
|
||||
t.Fatalf("parseBpeData: %v", err)
|
||||
}
|
||||
if len(ranks) != 1 {
|
||||
t.Errorf("expected 1 entry, got %d", len(ranks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBpeData_InvalidLine(t *testing.T) {
|
||||
input := []byte("nospacehere\n")
|
||||
_, err := parseBpeData(input)
|
||||
if err == nil {
|
||||
t.Error("expected error for line without space")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBpeData_InvalidBase64(t *testing.T) {
|
||||
input := []byte("!!!invalid 1\n")
|
||||
_, err := parseBpeData(input)
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid base64")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBpeData_InvalidRank(t *testing.T) {
|
||||
input := []byte("aGVsbG8= notanumber\n")
|
||||
_, err := parseBpeData(input)
|
||||
if err == nil {
|
||||
t.Error("expected error for non-integer rank")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTiktokenBpe_KnownURL(t *testing.T) {
|
||||
loader := &embeddedBpeLoader{}
|
||||
ranks, err := loader.LoadTiktokenBpe("https://openaipublic.blob.core.windows.net/encodings/cl100k_base.tiktoken")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTiktokenBpe: %v", err)
|
||||
}
|
||||
if len(ranks) == 0 {
|
||||
t.Error("expected non-empty ranks for cl100k_base")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTiktokenBpe_UnknownURL(t *testing.T) {
|
||||
loader := &embeddedBpeLoader{}
|
||||
_, err := loader.LoadTiktokenBpe("https://example.com/unknown.tiktoken")
|
||||
if err == nil {
|
||||
t.Error("expected error for unknown URL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitEmbeddedLoader(t *testing.T) {
|
||||
InitEmbeddedLoader()
|
||||
}
|
||||
330
internal/llm/message_test.go
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
package llm
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewTextMessage(t *testing.T) {
|
||||
m := NewTextMessage("user", "hello")
|
||||
if m.Role != "user" {
|
||||
t.Errorf("Role = %q, want user", m.Role)
|
||||
}
|
||||
if m.Content != "hello" {
|
||||
t.Errorf("Content = %v, want hello", m.Content)
|
||||
}
|
||||
if m.ToolCallID != "" {
|
||||
t.Error("ToolCallID should be empty")
|
||||
}
|
||||
if len(m.ToolCalls) != 0 {
|
||||
t.Errorf("ToolCalls should be nil, got %v", m.ToolCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewToolCallMessage(t *testing.T) {
|
||||
calls := []ToolCall{
|
||||
{ID: "c1", Type: "function", Function: FunctionCall{Name: "tool_a", Arguments: `{}`}},
|
||||
{ID: "c2", Type: "function", Function: FunctionCall{Name: "tool_b", Arguments: `{"x":1}`}},
|
||||
}
|
||||
m := NewToolCallMessage("thinking", calls)
|
||||
if m.Role != "assistant" {
|
||||
t.Errorf("Role = %q, want assistant", m.Role)
|
||||
}
|
||||
if m.Content != "thinking" {
|
||||
t.Errorf("Content = %v, want thinking", m.Content)
|
||||
}
|
||||
if len(m.ToolCalls) != 2 {
|
||||
t.Fatalf("ToolCalls len = %d, want 2", len(m.ToolCalls))
|
||||
}
|
||||
if m.ToolCalls[0].ID != "c1" || m.ToolCalls[1].Function.Name != "tool_b" {
|
||||
t.Errorf("ToolCalls not copied correctly")
|
||||
}
|
||||
|
||||
// Mutation of original must not affect the message.
|
||||
calls[0].ID = "mutated"
|
||||
if m.ToolCalls[0].ID == "mutated" {
|
||||
t.Error("NewToolCallMessage must copy ToolCalls")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewToolCallMessage_NilCalls(t *testing.T) {
|
||||
m := NewToolCallMessage("text", nil)
|
||||
if m.ToolCalls != nil {
|
||||
t.Errorf("expected nil ToolCalls for nil input, got %v", m.ToolCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewToolResultMessage(t *testing.T) {
|
||||
m := NewToolResultMessage("call-123", "result text")
|
||||
if m.Role != "tool" {
|
||||
t.Errorf("Role = %q, want tool", m.Role)
|
||||
}
|
||||
if m.Content != "result text" {
|
||||
t.Errorf("Content = %v, want result text", m.Content)
|
||||
}
|
||||
if m.ToolCallID != "call-123" {
|
||||
t.Errorf("ToolCallID = %q, want call-123", m.ToolCallID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractText_String(t *testing.T) {
|
||||
m := Message{Role: "user", Content: "plain text"}
|
||||
if got := m.ExtractText(); got != "plain text" {
|
||||
t.Errorf("ExtractText() = %q, want plain text", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractText_ContentBlocks(t *testing.T) {
|
||||
m := Message{Role: "assistant", Content: []ContentBlock{
|
||||
{Type: "text", Text: "part1"},
|
||||
{Type: "text", Text: " part2"},
|
||||
}}
|
||||
if got := m.ExtractText(); got != "part1 part2" {
|
||||
t.Errorf("ExtractText() = %q, want 'part1 part2'", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractText_NestedContentBlocks(t *testing.T) {
|
||||
m := Message{Role: "tool", Content: []ContentBlock{
|
||||
{
|
||||
Type: "tool_result",
|
||||
Content: []ContentBlock{
|
||||
{Type: "text", Text: "inner1"},
|
||||
{Type: "text", Text: "inner2"},
|
||||
},
|
||||
},
|
||||
{Type: "text", Text: "outer"},
|
||||
}}
|
||||
got := m.ExtractText()
|
||||
if got != "inner1inner2outer" {
|
||||
t.Errorf("ExtractText() = %q, want inner1inner2outer", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractText_Default(t *testing.T) {
|
||||
m := Message{Role: "user", Content: 42}
|
||||
if got := m.ExtractText(); got != "" {
|
||||
t.Errorf("ExtractText() for non-string/non-block = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractText_NilContent(t *testing.T) {
|
||||
m := Message{Role: "user", Content: nil}
|
||||
if got := m.ExtractText(); got != "" {
|
||||
t.Errorf("ExtractText() for nil = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatResponse_Content(t *testing.T) {
|
||||
text := "hello world"
|
||||
resp := &ChatResponse{
|
||||
Choices: []Choice{{
|
||||
Message: ResponseMessage{Content: &text},
|
||||
}},
|
||||
}
|
||||
if got := resp.Content(); got != "hello world" {
|
||||
t.Errorf("Content() = %q, want hello world", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatResponse_Content_Empty(t *testing.T) {
|
||||
resp := &ChatResponse{}
|
||||
if got := resp.Content(); got != "" {
|
||||
t.Errorf("Content() with no choices = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatResponse_Content_FallbackToReasoning(t *testing.T) {
|
||||
empty := ""
|
||||
resp := &ChatResponse{
|
||||
Choices: []Choice{{
|
||||
Message: ResponseMessage{Content: &empty, ReasoningContent: "reasoning here"},
|
||||
}},
|
||||
}
|
||||
if got := resp.Content(); got != "reasoning here" {
|
||||
t.Errorf("Content() = %q, want reasoning here", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatResponse_Content_NilContent(t *testing.T) {
|
||||
resp := &ChatResponse{
|
||||
Choices: []Choice{{
|
||||
Message: ResponseMessage{Content: nil, ReasoningContent: "fallback"},
|
||||
}},
|
||||
}
|
||||
if got := resp.Content(); got != "fallback" {
|
||||
t.Errorf("Content() = %q, want fallback", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatResponse_Content_StripsThinkTags(t *testing.T) {
|
||||
text := "<think>internal</think>answer"
|
||||
resp := &ChatResponse{
|
||||
Choices: []Choice{{
|
||||
Message: ResponseMessage{Content: &text},
|
||||
}},
|
||||
}
|
||||
if got := resp.Content(); got != "internalanswer" {
|
||||
t.Errorf("Content() = %q, want internalanswer", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatResponse_ToolCalls(t *testing.T) {
|
||||
resp := &ChatResponse{
|
||||
Choices: []Choice{{
|
||||
Message: ResponseMessage{
|
||||
ToolCalls: []ToolCall{
|
||||
{ID: "c1", Type: "function", Function: FunctionCall{Name: "tool_a"}},
|
||||
{ID: "c2", Type: "function", Function: FunctionCall{Name: "tool_b"}},
|
||||
},
|
||||
},
|
||||
}},
|
||||
}
|
||||
calls := resp.ToolCalls()
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("ToolCalls() len = %d, want 2", len(calls))
|
||||
}
|
||||
if calls[0].Function.Name != "tool_a" || calls[1].Function.Name != "tool_b" {
|
||||
t.Error("ToolCalls returned unexpected values")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatResponse_ToolCalls_Empty(t *testing.T) {
|
||||
resp := &ChatResponse{}
|
||||
if got := resp.ToolCalls(); got != nil {
|
||||
t.Errorf("ToolCalls() with no choices = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseShellRC(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
rcPath := filepath.Join(tmp, ".zshrc")
|
||||
|
||||
content := `# some comment
|
||||
export PATH="/usr/bin:$PATH"
|
||||
export ANTHROPIC_BASE_URL="https://api.example.com"
|
||||
export ANTHROPIC_AUTH_TOKEN='sk-test-token'
|
||||
export ANTHROPIC_MODEL=claude-sonnet-4-20250514
|
||||
`
|
||||
if err := os.WriteFile(rcPath, []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ep, ok, err := parseShellRC(rcPath, "")
|
||||
if err != nil {
|
||||
t.Fatalf("parseShellRC: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected ok=true")
|
||||
}
|
||||
if ep.Token != "sk-test-token" {
|
||||
t.Errorf("Token = %q, want sk-test-token", ep.Token)
|
||||
}
|
||||
if ep.Model != "claude-sonnet-4-20250514" {
|
||||
t.Errorf("Model = %q", ep.Model)
|
||||
}
|
||||
if ep.Protocol != "anthropic" {
|
||||
t.Errorf("Protocol = %q, want anthropic", ep.Protocol)
|
||||
}
|
||||
if ep.AuthHeader != "authorization" {
|
||||
t.Errorf("AuthHeader = %q, want authorization", ep.AuthHeader)
|
||||
}
|
||||
if ep.Source != "Shell rc file" {
|
||||
t.Errorf("Source = %q", ep.Source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseShellRC_ModelOverride(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
rcPath := filepath.Join(tmp, ".bashrc")
|
||||
|
||||
content := `export ANTHROPIC_BASE_URL="https://api.example.com"
|
||||
export ANTHROPIC_AUTH_TOKEN="token"
|
||||
export ANTHROPIC_MODEL=claude-3-opus
|
||||
`
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected ok=true")
|
||||
}
|
||||
if ep.Model != "override-model" {
|
||||
t.Errorf("Model = %q, want override-model", ep.Model)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseShellRC_Incomplete(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
rcPath := filepath.Join(tmp, ".zshrc")
|
||||
|
||||
content := `export ANTHROPIC_BASE_URL="https://api.example.com"
|
||||
export ANTHROPIC_AUTH_TOKEN="token"
|
||||
# missing ANTHROPIC_MODEL
|
||||
`
|
||||
if err := os.WriteFile(rcPath, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("write rc: %v", err)
|
||||
}
|
||||
|
||||
_, ok, err := parseShellRC(rcPath, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ok {
|
||||
t.Error("expected ok=false when model is missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseShellRC_NonexistentFile(t *testing.T) {
|
||||
_, ok, err := parseShellRC("/nonexistent/path/.zshrc", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Error("expected ok=false for missing file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelListContains(t *testing.T) {
|
||||
models := []string{"gpt-4", " claude-3-opus ", "gemini-pro"}
|
||||
if !ModelListContains(models, "claude-3-opus") {
|
||||
t.Error("expected true for claude-3-opus")
|
||||
}
|
||||
if !ModelListContains(models, "gpt-4") {
|
||||
t.Error("expected true for gpt-4")
|
||||
}
|
||||
if ModelListContains(models, "gpt-3.5") {
|
||||
t.Error("expected false for gpt-3.5")
|
||||
}
|
||||
if ModelListContains(nil, "anything") {
|
||||
t.Error("expected false for nil list")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultAuthHeader(t *testing.T) {
|
||||
if got := defaultAuthHeader("anthropic"); got != "authorization" {
|
||||
t.Errorf("anthropic: got %q, want authorization", got)
|
||||
}
|
||||
if got := defaultAuthHeader("openai"); got != "" {
|
||||
t.Errorf("openai: got %q, want empty", got)
|
||||
}
|
||||
if got := defaultAuthHeader(""); got != "" {
|
||||
t.Errorf("empty: got %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserAgent(t *testing.T) {
|
||||
got := userAgent("anthropic")
|
||||
if got != "open-code-review/dev | anthropic" {
|
||||
t.Errorf("userAgent(anthropic) = %q", got)
|
||||
}
|
||||
got2 := userAgent("")
|
||||
if got2 != "open-code-review/dev" {
|
||||
t.Errorf("userAgent('') = %q", got2)
|
||||
}
|
||||
}
|
||||
|
|
@ -168,6 +168,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",
|
||||
|
|
|
|||
|
|
@ -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", "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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,10 +3,13 @@ package llm
|
|||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ResolvedEndpoint holds the resolved LLM endpoint configuration.
|
||||
|
|
@ -19,6 +22,11 @@ type ResolvedEndpoint struct {
|
|||
Source string // human-readable config source label
|
||||
ExtraBody map[string]any // vendor-specific request body fields
|
||||
ExtraHeaders map[string]string // extra HTTP headers for the LLM request
|
||||
// Timeout is the per-request HTTP timeout; 0 means use the client default (5 min).
|
||||
// Only config file (llm/provider sections) and OCR_LLM_TIMEOUT env var can set this.
|
||||
// tryCCEnv and tryShellRC always leave it at 0 since those sources have no timeout
|
||||
// knob; users can still override via OCR_LLM_TIMEOUT.
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// Environment variable names for OCR-specific configuration.
|
||||
|
|
@ -28,7 +36,12 @@ const (
|
|||
envOCRLLMModel = "OCR_LLM_MODEL"
|
||||
envOCRLLMAuthHeader = "OCR_LLM_AUTH_HEADER"
|
||||
envOCRLLMExtraHeaders = "OCR_LLM_EXTRA_HEADERS"
|
||||
envOCRUseAnthropic = "OCR_USE_ANTHROPIC"
|
||||
// envOCRLLMTimeout is a global override applied in ResolveEndpointWithModelOverride
|
||||
// after any strategy resolves, rather than inside tryOCREnv like other OCR_LLM_* vars.
|
||||
// This lets it override timeout for all resolution paths (OCR env, config file,
|
||||
// provider config, Claude Code env, shell RC).
|
||||
envOCRLLMTimeout = "OCR_LLM_TIMEOUT"
|
||||
envOCRUseAnthropic = "OCR_USE_ANTHROPIC"
|
||||
)
|
||||
|
||||
// Environment variable names from Claude Code configuration.
|
||||
|
|
@ -71,6 +84,16 @@ func ResolveEndpointWithModelOverride(configPath, modelOverride string) (Resolve
|
|||
ep.Source = s.name
|
||||
}
|
||||
ep.Model = stripModelSuffix(ep.Model)
|
||||
// OCR_LLM_TIMEOUT is a global override: applies regardless of
|
||||
// which strategy resolved the endpoint, and takes precedence
|
||||
// over config-file values when set.
|
||||
envTimeout, ok, err := parseTimeoutEnv()
|
||||
if err != nil {
|
||||
return ResolvedEndpoint{}, fmt.Errorf("resolve %s: %w", s.name, err)
|
||||
}
|
||||
if ok {
|
||||
ep.Timeout = envTimeout
|
||||
}
|
||||
return ep, nil
|
||||
}
|
||||
}
|
||||
|
|
@ -78,6 +101,43 @@ func ResolveEndpointWithModelOverride(configPath, modelOverride string) (Resolve
|
|||
return ResolvedEndpoint{}, fmt.Errorf("no valid LLM endpoint configured; one of OCR_LLM_URL/OCR_LLM_TOKEN/OCR_LLM_MODEL, ~/.opencodereview/config.json, or ANTHROPIC_BASE_URL/ANTHROPIC_AUTH_TOKEN/ANTHROPIC_MODEL must be set")
|
||||
}
|
||||
|
||||
// parseTimeoutEnv reads and validates the OCR_LLM_TIMEOUT environment variable.
|
||||
// Returns the parsed duration and true if set, or 0 and false if unset/empty.
|
||||
// Returns an error for invalid values (non-integer, negative, overflow) to give
|
||||
// the user clear feedback instead of silently falling back to the default.
|
||||
func parseTimeoutEnv() (time.Duration, bool, error) {
|
||||
raw := strings.TrimSpace(os.Getenv(envOCRLLMTimeout))
|
||||
if raw == "" {
|
||||
return 0, false, nil
|
||||
}
|
||||
sec, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
return 0, false, fmt.Errorf("OCR_LLM_TIMEOUT must be an integer (seconds): %w", err)
|
||||
}
|
||||
d, err := validateTimeoutSec(sec)
|
||||
if err != nil {
|
||||
return 0, false, fmt.Errorf("OCR_LLM_TIMEOUT: %w", err)
|
||||
}
|
||||
return d, true, nil
|
||||
}
|
||||
|
||||
// validateTimeoutSec converts a config-file timeout (in seconds) to time.Duration.
|
||||
// Returns 0 for zero input (use default). Rejects negative values and overflow.
|
||||
func validateTimeoutSec(sec int) (time.Duration, error) {
|
||||
if sec == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
if sec < 0 {
|
||||
return 0, fmt.Errorf("timeout_sec must be non-negative, got %d", sec)
|
||||
}
|
||||
// Guard against overflow: time.Duration is int64 nanoseconds.
|
||||
maxSec := int64(math.MaxInt64 / int64(time.Second))
|
||||
if int64(sec) > maxSec {
|
||||
return 0, fmt.Errorf("timeout_sec %d overflows time.Duration (max %d)", sec, maxSec)
|
||||
}
|
||||
return time.Duration(sec) * time.Second, nil
|
||||
}
|
||||
|
||||
// tryOCREnv reads OCR-specific environment variables.
|
||||
func tryOCREnv(modelOverride string) (ResolvedEndpoint, bool, error) {
|
||||
url := os.Getenv(envOCRLLMURL)
|
||||
|
|
@ -132,6 +192,7 @@ type llmFileConfig struct {
|
|||
AuthHeader string `json:"auth_header,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
UseAnthropic *bool `json:"use_anthropic,omitempty"` // pointer to distinguish unset from false
|
||||
TimeoutSec int `json:"timeout_sec,omitempty"` // per-request HTTP timeout in seconds
|
||||
ExtraBody map[string]any `json:"extra_body,omitempty"`
|
||||
ExtraHeaders map[string]string `json:"extra_headers,omitempty"`
|
||||
}
|
||||
|
|
@ -144,6 +205,7 @@ type providerEntryConfig struct {
|
|||
Model string `json:"model,omitempty"`
|
||||
Models []string `json:"models,omitempty"`
|
||||
AuthHeader string `json:"auth_header,omitempty"`
|
||||
TimeoutSec int `json:"timeout_sec,omitempty"` // per-request HTTP timeout in seconds
|
||||
ExtraBody map[string]any `json:"extra_body,omitempty"`
|
||||
ExtraHeaders map[string]string `json:"extra_headers,omitempty"`
|
||||
}
|
||||
|
|
@ -249,7 +311,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,
|
||||
|
|
@ -288,6 +350,11 @@ func tryProviderConfig(cfg configFile, modelOverride string) (ResolvedEndpoint,
|
|||
extraBody = entry.ExtraBody
|
||||
extraHeaders := entry.ExtraHeaders
|
||||
|
||||
timeout, err := validateTimeoutSec(entry.TimeoutSec)
|
||||
if err != nil {
|
||||
return ResolvedEndpoint{}, false, fmt.Errorf("provider %q: %w", cfg.Provider, err)
|
||||
}
|
||||
|
||||
if protocol == "anthropic" {
|
||||
url = ensureMessagesSuffix(url)
|
||||
}
|
||||
|
|
@ -301,6 +368,7 @@ func tryProviderConfig(cfg configFile, modelOverride string) (ResolvedEndpoint,
|
|||
Source: "provider:" + cfg.Provider,
|
||||
ExtraBody: extraBody,
|
||||
ExtraHeaders: extraHeaders,
|
||||
Timeout: timeout,
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
|
|
@ -336,7 +404,12 @@ func tryLegacyLlmConfig(cfg configFile, modelOverride string) (ResolvedEndpoint,
|
|||
}
|
||||
}
|
||||
|
||||
return ResolvedEndpoint{URL: cfg.Llm.URL, Token: cfg.Llm.AuthToken, Model: model, Protocol: protocol, AuthHeader: authHeader, Source: "OCR config file", ExtraBody: cfg.Llm.ExtraBody, ExtraHeaders: cfg.Llm.ExtraHeaders}, true, nil
|
||||
timeout, err := validateTimeoutSec(cfg.Llm.TimeoutSec)
|
||||
if err != nil {
|
||||
return ResolvedEndpoint{}, false, fmt.Errorf("OCR config file: %w", err)
|
||||
}
|
||||
|
||||
return ResolvedEndpoint{URL: cfg.Llm.URL, Token: cfg.Llm.AuthToken, Model: model, Protocol: protocol, AuthHeader: authHeader, Source: "OCR config file", ExtraBody: cfg.Llm.ExtraBody, ExtraHeaders: cfg.Llm.ExtraHeaders, Timeout: timeout}, true, nil
|
||||
}
|
||||
|
||||
// tryCCEnv reads Claude Code environment variables.
|
||||
|
|
@ -451,8 +524,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 {
|
||||
|
|
@ -491,7 +564,7 @@ var reservedHeaders = map[string]bool{
|
|||
|
||||
// ParseExtraHeaders parses a string of comma-separated key=value pairs into a dictionary.
|
||||
// Values may be double-quoted to include commas, e.g. X-Forwarded-For="1.2.3.4,5.6.7.8".
|
||||
// Reserved header names (authorization, x-api-key, content-type, user-agent) are rejected
|
||||
// Reserved header names (authorization, x-api-key, content-type, user-agent) are rejected
|
||||
// to prevent accidental override of auth or content-type set by the SDK.
|
||||
func ParseExtraHeaders(raw string) (map[string]string, error) {
|
||||
if raw == "" {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestStripModelSuffix(t *testing.T) {
|
||||
|
|
@ -103,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 {
|
||||
|
|
@ -136,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 {
|
||||
|
|
@ -167,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 {
|
||||
|
|
@ -198,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 {
|
||||
|
|
@ -270,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 {
|
||||
|
|
@ -304,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 {
|
||||
|
|
@ -333,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 {
|
||||
|
|
@ -355,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 {
|
||||
|
|
@ -382,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 {
|
||||
|
|
@ -408,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 {
|
||||
|
|
@ -430,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 {
|
||||
|
|
@ -447,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 {
|
||||
|
|
@ -471,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 {
|
||||
|
|
@ -507,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 {
|
||||
|
|
@ -530,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 {
|
||||
|
|
@ -554,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 {
|
||||
|
|
@ -577,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 {
|
||||
|
|
@ -602,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 {
|
||||
|
|
@ -628,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 {
|
||||
|
|
@ -653,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 {
|
||||
|
|
@ -675,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 {
|
||||
|
|
@ -705,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 {
|
||||
|
|
@ -732,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 {
|
||||
|
|
@ -759,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 {
|
||||
|
|
@ -784,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")
|
||||
|
|
@ -822,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")
|
||||
|
|
@ -1086,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 {
|
||||
|
|
@ -1113,7 +1168,9 @@ func TestResolveEndpoint_LegacyLlmExtraHeaders(t *testing.T) {
|
|||
}
|
||||
data, _ := json.Marshal(cfg)
|
||||
cfgPath := filepath.Join(t.TempDir(), "config.json")
|
||||
os.WriteFile(cfgPath, data, 0644)
|
||||
if err := os.WriteFile(cfgPath, data, 0644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
ep, err := ResolveEndpoint(cfgPath)
|
||||
if err != nil {
|
||||
|
|
@ -1123,3 +1180,341 @@ func TestResolveEndpoint_LegacyLlmExtraHeaders(t *testing.T) {
|
|||
t.Errorf("ExtraHeaders[\"X-Legacy\"] = %q, want %q", v, "yes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTimeoutEnv(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
want time.Duration
|
||||
wantOK bool
|
||||
wantErr bool
|
||||
}{
|
||||
{"empty", "", 0, false, false},
|
||||
{"valid 120", "120", 120 * time.Second, true, false},
|
||||
{"valid 60", "60", 60 * time.Second, true, false},
|
||||
{"zero", "0", 0, true, false},
|
||||
{"negative", "-5", 0, false, true},
|
||||
{"non-integer", "abc", 0, false, true},
|
||||
{"with spaces", " 90 ", 90 * time.Second, true, false},
|
||||
{"overflow", "99999999999999", 0, false, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Setenv("OCR_LLM_TIMEOUT", tt.value)
|
||||
got, ok, err := parseTimeoutEnv()
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("parseTimeoutEnv() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
if ok != tt.wantOK {
|
||||
t.Errorf("parseTimeoutEnv() ok = %v, want %v", ok, tt.wantOK)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("parseTimeoutEnv() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTimeoutSec(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input int
|
||||
want time.Duration
|
||||
wantErr bool
|
||||
}{
|
||||
{"zero", 0, 0, false},
|
||||
{"positive 60", 60, 60 * time.Second, false},
|
||||
{"positive 300", 300, 300 * time.Second, false},
|
||||
{"negative -1", -1, 0, true},
|
||||
{"negative -100", -100, 0, true},
|
||||
{"max safe", 9223372036, 9223372036 * time.Second, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := validateTimeoutSec(tt.input)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("validateTimeoutSec(%d) error = %v, wantErr %v", tt.input, err, tt.wantErr)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("validateTimeoutSec(%d) = %v, want %v", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveEndpoint_EnvTimeoutGlobalOverride(t *testing.T) {
|
||||
clearAllEnv(t)
|
||||
t.Setenv("OCR_LLM_URL", "https://api.example.com/v1")
|
||||
t.Setenv("OCR_LLM_TOKEN", "test-token")
|
||||
t.Setenv("OCR_LLM_MODEL", "mimo-v2.5-pro")
|
||||
t.Setenv("OCR_USE_ANTHROPIC", "false")
|
||||
t.Setenv("OCR_LLM_TIMEOUT", "90")
|
||||
|
||||
ep, err := ResolveEndpoint(filepath.Join(t.TempDir(), "nonexistent.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if ep.Timeout != 90*time.Second {
|
||||
t.Errorf("Timeout = %v, want %v", ep.Timeout, 90*time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveEndpoint_ConfigTimeoutSec(t *testing.T) {
|
||||
clearAllEnv(t)
|
||||
|
||||
cfg := configFile{
|
||||
Llm: llmFileConfig{
|
||||
URL: "https://api.example.com/v1/messages",
|
||||
AuthToken: "test-token",
|
||||
Model: "claude-opus-4-6",
|
||||
TimeoutSec: 120,
|
||||
},
|
||||
}
|
||||
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 ep.Timeout != 120*time.Second {
|
||||
t.Errorf("Timeout = %v, want %v", ep.Timeout, 120*time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveEndpoint_NegativeConfigTimeoutSec(t *testing.T) {
|
||||
clearAllEnv(t)
|
||||
|
||||
cfg := configFile{
|
||||
Llm: llmFileConfig{
|
||||
URL: "https://api.example.com/v1/messages",
|
||||
AuthToken: "test-token",
|
||||
Model: "claude-opus-4-6",
|
||||
TimeoutSec: -5,
|
||||
},
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
_, err := ResolveEndpoint(cfgPath)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for negative timeout_sec, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "non-negative") {
|
||||
t.Errorf("error %q should mention non-negative", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewLLMClient_TimeoutForwarded(t *testing.T) {
|
||||
ep := ResolvedEndpoint{
|
||||
URL: "https://api.example.com/v1",
|
||||
Token: "test-token",
|
||||
Model: "test-model",
|
||||
Timeout: 2 * time.Minute,
|
||||
}
|
||||
|
||||
client := NewLLMClient(ep)
|
||||
if client == nil {
|
||||
t.Fatal("NewLLMClient returned nil")
|
||||
}
|
||||
|
||||
// Verify the client was created (we can't easily inspect the internal timeout,
|
||||
// but we can verify the client is functional and was constructed without error).
|
||||
if oc, ok := client.(*OpenAIClient); ok {
|
||||
if oc.cfg.Timeout != 2*time.Minute {
|
||||
t.Errorf("OpenAIClient cfg.Timeout = %v, want %v", oc.cfg.Timeout, 2*time.Minute)
|
||||
}
|
||||
} else {
|
||||
t.Errorf("expected *OpenAIClient, got %T", client)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewLLMClient_DefaultTimeout(t *testing.T) {
|
||||
ep := ResolvedEndpoint{
|
||||
URL: "https://api.example.com/v1",
|
||||
Token: "test-token",
|
||||
Model: "test-model",
|
||||
// Timeout not set — should default to 5 minutes
|
||||
}
|
||||
|
||||
client := NewLLMClient(ep)
|
||||
if oc, ok := client.(*OpenAIClient); ok {
|
||||
if oc.cfg.Timeout != 5*time.Minute {
|
||||
t.Errorf("OpenAIClient cfg.Timeout = %v, want default %v", oc.cfg.Timeout, 5*time.Minute)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveEndpoint_ProviderConfigTimeoutSec(t *testing.T) {
|
||||
clearAllEnv(t)
|
||||
|
||||
cfg := configFile{
|
||||
Provider: "anthropic",
|
||||
Providers: map[string]providerEntryConfig{
|
||||
"anthropic": {
|
||||
APIKey: "sk-ant-test",
|
||||
Model: "claude-sonnet-4-6",
|
||||
TimeoutSec: 180,
|
||||
},
|
||||
},
|
||||
}
|
||||
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 ep.Timeout != 180*time.Second {
|
||||
t.Errorf("Timeout = %v, want %v", ep.Timeout, 180*time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveEndpoint_ProviderConfigNegativeTimeoutSec(t *testing.T) {
|
||||
clearAllEnv(t)
|
||||
|
||||
cfg := configFile{
|
||||
Provider: "anthropic",
|
||||
Providers: map[string]providerEntryConfig{
|
||||
"anthropic": {
|
||||
APIKey: "sk-ant-test",
|
||||
Model: "claude-sonnet-4-6",
|
||||
TimeoutSec: -10,
|
||||
},
|
||||
},
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
_, err := ResolveEndpoint(cfgPath)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for negative provider timeout_sec")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "non-negative") {
|
||||
t.Errorf("error %q should mention non-negative", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveEndpoint_EnvTimeoutOverridesConfigTimeout(t *testing.T) {
|
||||
clearAllEnv(t)
|
||||
t.Setenv("OCR_LLM_TIMEOUT", "60")
|
||||
|
||||
cfg := configFile{
|
||||
Llm: llmFileConfig{
|
||||
URL: "https://api.example.com/v1/messages",
|
||||
AuthToken: "test-token",
|
||||
Model: "claude-opus-4-6",
|
||||
TimeoutSec: 120,
|
||||
},
|
||||
}
|
||||
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)
|
||||
}
|
||||
// OCR_LLM_TIMEOUT (60s) should override config timeout_sec (120s)
|
||||
if ep.Timeout != 60*time.Second {
|
||||
t.Errorf("Timeout = %v, want %v (env should override config)", ep.Timeout, 60*time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveEndpoint_EnvTimeoutOverridesProviderTimeout(t *testing.T) {
|
||||
clearAllEnv(t)
|
||||
t.Setenv("OCR_LLM_TIMEOUT", "45")
|
||||
|
||||
cfg := configFile{
|
||||
Provider: "anthropic",
|
||||
Providers: map[string]providerEntryConfig{
|
||||
"anthropic": {
|
||||
APIKey: "sk-ant-test",
|
||||
Model: "claude-sonnet-4-6",
|
||||
TimeoutSec: 300,
|
||||
},
|
||||
},
|
||||
}
|
||||
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)
|
||||
}
|
||||
// OCR_LLM_TIMEOUT (45s) should override provider timeout_sec (300s)
|
||||
if ep.Timeout != 45*time.Second {
|
||||
t.Errorf("Timeout = %v, want %v (env should override provider config)", ep.Timeout, 45*time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveEndpoint_InvalidEnvTimeoutWithConfig(t *testing.T) {
|
||||
clearAllEnv(t)
|
||||
t.Setenv("OCR_LLM_TIMEOUT", "invalid")
|
||||
|
||||
cfg := configFile{
|
||||
Llm: llmFileConfig{
|
||||
URL: "https://api.example.com/v1/messages",
|
||||
AuthToken: "test-token",
|
||||
Model: "claude-opus-4-6",
|
||||
},
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
_, err := ResolveEndpoint(cfgPath)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid OCR_LLM_TIMEOUT")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "OCR_LLM_TIMEOUT") {
|
||||
t.Errorf("error %q should mention OCR_LLM_TIMEOUT", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveEndpoint_NegativeEnvTimeoutWithConfig(t *testing.T) {
|
||||
clearAllEnv(t)
|
||||
t.Setenv("OCR_LLM_TIMEOUT", "-30")
|
||||
|
||||
cfg := configFile{
|
||||
Llm: llmFileConfig{
|
||||
URL: "https://api.example.com/v1/messages",
|
||||
AuthToken: "test-token",
|
||||
Model: "claude-opus-4-6",
|
||||
},
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
_, err := ResolveEndpoint(cfgPath)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for negative OCR_LLM_TIMEOUT")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "OCR_LLM_TIMEOUT") {
|
||||
t.Errorf("error %q should mention OCR_LLM_TIMEOUT", err.Error())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
177
internal/llmloop/compression_test.go
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
package llmloop
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/open-code-review/open-code-review/internal/llm"
|
||||
)
|
||||
|
||||
func msg(role, text string) llm.Message {
|
||||
return llm.NewTextMessage(role, text)
|
||||
}
|
||||
|
||||
func TestCountMessagesTokens(t *testing.T) {
|
||||
msgs := []llm.Message{
|
||||
msg("user", "hello world"),
|
||||
msg("assistant", "hi there"),
|
||||
}
|
||||
got := CountMessagesTokens(msgs)
|
||||
if got <= 0 {
|
||||
t.Errorf("expected positive token count, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountMessagesTokens_Empty(t *testing.T) {
|
||||
got := CountMessagesTokens(nil)
|
||||
if got != 0 {
|
||||
t.Errorf("expected 0 for nil, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupIntoRounds(t *testing.T) {
|
||||
messages := []llm.Message{
|
||||
msg("system", "sys"),
|
||||
msg("user", "prompt"),
|
||||
msg("assistant", "resp1"),
|
||||
msg("tool", "result1"),
|
||||
msg("tool", "result2"),
|
||||
msg("assistant", "resp2"),
|
||||
msg("tool", "result3"),
|
||||
msg("assistant", "resp3"),
|
||||
}
|
||||
|
||||
rounds := groupIntoRounds(messages, 2)
|
||||
if len(rounds) != 3 {
|
||||
t.Fatalf("expected 3 rounds, got %d", len(rounds))
|
||||
}
|
||||
|
||||
if rounds[0].assistantIdx != 2 {
|
||||
t.Errorf("round[0].assistantIdx = %d, want 2", rounds[0].assistantIdx)
|
||||
}
|
||||
if len(rounds[0].toolIdxs) != 2 {
|
||||
t.Errorf("round[0] should have 2 tool messages, got %d", len(rounds[0].toolIdxs))
|
||||
}
|
||||
if rounds[1].assistantIdx != 5 {
|
||||
t.Errorf("round[1].assistantIdx = %d, want 5", rounds[1].assistantIdx)
|
||||
}
|
||||
if rounds[2].assistantIdx != 7 {
|
||||
t.Errorf("round[2].assistantIdx = %d, want 7", rounds[2].assistantIdx)
|
||||
}
|
||||
if len(rounds[2].toolIdxs) != 0 {
|
||||
t.Errorf("round[2] should have 0 tool messages")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupIntoRounds_NoAssistant(t *testing.T) {
|
||||
messages := []llm.Message{
|
||||
msg("system", "sys"),
|
||||
msg("user", "prompt"),
|
||||
msg("user", "another"),
|
||||
}
|
||||
rounds := groupIntoRounds(messages, 2)
|
||||
if len(rounds) != 0 {
|
||||
t.Errorf("expected 0 rounds, got %d", len(rounds))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPartitionMessages_ShortConversation(t *testing.T) {
|
||||
messages := []llm.Message{
|
||||
msg("system", "sys"),
|
||||
msg("user", "prompt"),
|
||||
}
|
||||
result := partitionMessages(messages, 100000, 0)
|
||||
if result.frozenEnd != 2 {
|
||||
t.Errorf("frozenEnd = %d, want 2", result.frozenEnd)
|
||||
}
|
||||
if result.compressEnd != 2 {
|
||||
t.Errorf("compressEnd = %d, want 2", result.compressEnd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPartitionMessages_EverythingFits(t *testing.T) {
|
||||
messages := []llm.Message{
|
||||
msg("system", "sys"),
|
||||
msg("user", "prompt"),
|
||||
msg("assistant", "short reply"),
|
||||
msg("tool", "ok"),
|
||||
}
|
||||
result := partitionMessages(messages, 100000, 0)
|
||||
if result.activeCount != 0 {
|
||||
t.Errorf("activeCount = %d, want 0 (everything fits)", result.activeCount)
|
||||
}
|
||||
if result.compressEnd != len(messages) {
|
||||
t.Errorf("compressEnd = %d, want %d", result.compressEnd, len(messages))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripMarkdownFences(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "no fences",
|
||||
input: `{"key": "value"}`,
|
||||
want: `{"key": "value"}`,
|
||||
},
|
||||
{
|
||||
name: "json fence",
|
||||
input: "```json\n{\"key\": \"value\"}\n```",
|
||||
want: `{"key": "value"}`,
|
||||
},
|
||||
{
|
||||
name: "plain fence",
|
||||
input: "```\ncontent\n```",
|
||||
want: "content",
|
||||
},
|
||||
{
|
||||
name: "fence with surrounding whitespace",
|
||||
input: " ```json\n{}\n``` ",
|
||||
want: "{}",
|
||||
},
|
||||
{
|
||||
name: "empty after strip",
|
||||
input: "```json\n```",
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := StripMarkdownFences(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("StripMarkdownFences(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMessageXML(t *testing.T) {
|
||||
messages := []llm.Message{
|
||||
msg("user", "hello"),
|
||||
msg("assistant", "world"),
|
||||
}
|
||||
got := buildMessageXML(messages)
|
||||
if !strings.Contains(got, `<message id="0" role="user">`) {
|
||||
t.Errorf("missing user message tag: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `<message id="1" role="assistant">`) {
|
||||
t.Errorf("missing assistant message tag: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "hello") || !strings.Contains(got, "world") {
|
||||
t.Errorf("missing content: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyMessages(t *testing.T) {
|
||||
orig := []llm.Message{msg("user", "a"), msg("assistant", "b")}
|
||||
cp := copyMessages(orig)
|
||||
if len(cp) != 2 {
|
||||
t.Fatalf("len = %d, want 2", len(cp))
|
||||
}
|
||||
cp[0] = msg("system", "mutated")
|
||||
if orig[0].Role == "system" {
|
||||
t.Error("copyMessages should return independent slice")
|
||||
}
|
||||
}
|
||||
5
internal/llmloop/init_test.go
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
package llmloop
|
||||
|
||||
import "github.com/open-code-review/open-code-review/internal/session"
|
||||
|
||||
func init() { session.UseTestSessions() }
|
||||
|
|
@ -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
|
||||
|
|
@ -260,8 +266,37 @@ func (r *Runner) RunPerFile(ctx context.Context, messages []llm.Message, newPath
|
|||
// resolution / re-location.
|
||||
func (r *Runner) executeToolCall(ctx context.Context, newPath string, call llm.ToolCall, rec *session.TaskRecord) tool.TaskCheckpoint {
|
||||
t := tool.OfName(call.Function.Name)
|
||||
|
||||
if !t.IsKnown() {
|
||||
return tool.Of(tool.NotAvailableMsg)
|
||||
p, ok := r.deps.Tools.Get(call.Function.Name)
|
||||
if !ok {
|
||||
return tool.Of(tool.NotAvailableMsg)
|
||||
}
|
||||
r.recordToolCall(call.Function.Name)
|
||||
var dynArgs map[string]any
|
||||
if err := json.Unmarshal([]byte(call.Function.Arguments), &dynArgs); err != nil {
|
||||
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 {
|
||||
rec.AddToolResult(call.Function.Name, call.Function.Arguments, result)
|
||||
}
|
||||
return tool.Of(result)
|
||||
}
|
||||
|
||||
if t == tool.TaskDone {
|
||||
|
|
@ -290,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)
|
||||
}
|
||||
|
||||
|
|
@ -337,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)
|
||||
|
|
@ -347,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 {
|
||||
|
|
@ -357,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 {
|
||||
|
|
|
|||