From b3b4f238a36033fa138591c4ea8efddfd974e2e2 Mon Sep 17 00:00:00 2001 From: kite Date: Fri, 26 Jun 2026 21:30:57 +0800 Subject: [PATCH 01/87] docs: add security assurance case and use signed tags Add ASSURANCE_CASE.md covering threat model, secure design principles (Saltzer & Schroeder), OWASP/CWE countermeasures, and automated verification. Switch tag command from annotated (-a) to signed (-s) to match the assurance case's integrity claims. --- .claude/commands/tag.md | 2 +- ASSURANCE_CASE.md | 109 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 ASSURANCE_CASE.md diff --git a/.claude/commands/tag.md b/.claude/commands/tag.md index 2a179e1..a1a1c48 100644 --- a/.claude/commands/tag.md +++ b/.claude/commands/tag.md @@ -25,7 +25,7 @@ Create an annotated git tag with an auto-generated summary of changes since the Run: ```bash -git tag -a -m "" +git tag -s -m "" ``` Report the created tag and its message to the user. diff --git a/ASSURANCE_CASE.md b/ASSURANCE_CASE.md new file mode 100644 index 0000000..ccd924f --- /dev/null +++ b/ASSURANCE_CASE.md @@ -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 | From e1a6a404ba875eb58e8e7e95084f95e96a1a30f8 Mon Sep 17 00:00:00 2001 From: kite Date: Fri, 26 Jun 2026 22:18:03 +0800 Subject: [PATCH 02/87] feat(ci): add Sigstore attestation for release artifacts Add build provenance attestation to the release workflow using actions/attest-build-provenance with OIDC keyless signing. Document release signature verification in SECURITY.md. --- .github/workflows/release.yml | 11 +++++++++++ SECURITY.md | 16 ++++++++++++++++ internal/llm/resolver.go | 2 +- 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d75b028..1536910 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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: @@ -145,6 +149,13 @@ 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 diff --git a/SECURITY.md b/SECURITY.md index 2fd3a65..5701307 100644 --- a/SECURITY.md +++ b/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). diff --git a/internal/llm/resolver.go b/internal/llm/resolver.go index ae53ddf..8ed011d 100644 --- a/internal/llm/resolver.go +++ b/internal/llm/resolver.go @@ -491,7 +491,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 == "" { From 98fe309f03801772361970a423a6d50cb4de3e5e Mon Sep 17 00:00:00 2001 From: kite Date: Fri, 26 Jun 2026 22:41:35 +0800 Subject: [PATCH 03/87] test: add unit tests for pure logic functions across 6 packages Add table-driven tests for pure computation functions with no external dependencies, improving overall coverage from 45.9% to 48.8%. Covered functions: - suggestdiff: ComputeLineDiff (LCS algorithm) - tool: CommentCollector, DiffMap, FileReadDiff, Registry, ParseReviewMode, scanLines - llmloop: CountMessagesTokens, groupIntoRounds, partitionMessages, StripMarkdownFences - agent: buildFilterCommentsJSON, parseFilterResponse, extFromPath, formatToolDefs, BuildToolDefs - viewer: truncateText, formatDuration, formatTime --- internal/agent/agent_test.go | 304 ++++++++++++++++++++++++ internal/llmloop/compression_test.go | 177 ++++++++++++++ internal/suggestdiff/diff_test.go | 127 ++++++++++ internal/tool/comment_collector_test.go | 152 ++++++++++++ internal/tool/definitions_test.go | 109 +++++++++ internal/tool/file_read_diff_test.go | 106 +++++++++ internal/tool/filereader_test.go | 139 +++++++++++ internal/viewer/server_test.go | 73 ++++++ 8 files changed, 1187 insertions(+) create mode 100644 internal/agent/agent_test.go create mode 100644 internal/llmloop/compression_test.go create mode 100644 internal/suggestdiff/diff_test.go create mode 100644 internal/tool/comment_collector_test.go create mode 100644 internal/tool/definitions_test.go create mode 100644 internal/tool/file_read_diff_test.go create mode 100644 internal/tool/filereader_test.go create mode 100644 internal/viewer/server_test.go diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go new file mode 100644 index 0000000..a3b65b9 --- /dev/null +++ b/internal/agent/agent_test.go @@ -0,0 +1,304 @@ +package agent + +import ( + "encoding/json" + "strings" + "testing" + + "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" +) + +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) + } + }) +} diff --git a/internal/llmloop/compression_test.go b/internal/llmloop/compression_test.go new file mode 100644 index 0000000..161da11 --- /dev/null +++ b/internal/llmloop/compression_test.go @@ -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, ``) { + t.Errorf("missing user message tag: %s", got) + } + if !strings.Contains(got, ``) { + 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") + } +} diff --git a/internal/suggestdiff/diff_test.go b/internal/suggestdiff/diff_test.go new file mode 100644 index 0000000..1cb0bc7 --- /dev/null +++ b/internal/suggestdiff/diff_test.go @@ -0,0 +1,127 @@ +package suggestdiff + +import ( + "testing" +) + +func TestComputeLineDiff(t *testing.T) { + tests := []struct { + name string + old []string + new []string + wantLen int + wantAdds int + wantDels int + }{ + { + name: "both empty", + old: nil, + new: nil, + wantLen: 0, + wantAdds: 0, + wantDels: 0, + }, + { + name: "identical single line", + old: []string{"hello"}, + new: []string{"hello"}, + wantLen: 1, + wantAdds: 0, + wantDels: 0, + }, + { + name: "add lines to empty", + old: []string{}, + new: []string{"a", "b"}, + wantLen: 2, + wantAdds: 2, + wantDels: 0, + }, + { + name: "delete all lines", + old: []string{"a", "b"}, + new: []string{}, + wantLen: 2, + wantAdds: 0, + wantDels: 2, + }, + { + name: "replace single line", + old: []string{"old"}, + new: []string{"new"}, + wantLen: 2, + wantAdds: 1, + wantDels: 1, + }, + { + name: "insert in middle", + old: []string{"a", "c"}, + new: []string{"a", "b", "c"}, + wantLen: 3, + wantAdds: 1, + wantDels: 0, + }, + { + name: "delete from middle", + old: []string{"a", "b", "c"}, + new: []string{"a", "c"}, + wantLen: 3, + wantAdds: 0, + wantDels: 1, + }, + { + name: "case insensitive match with whitespace", + old: []string{" Hello "}, + new: []string{"hello"}, + wantLen: 1, + wantAdds: 0, + wantDels: 0, + }, + { + name: "multi-line edit", + old: []string{"func main() {", " fmt.Println(\"old\")", "}"}, + new: []string{"func main() {", " fmt.Println(\"new\")", " return", "}"}, + wantLen: 5, + wantAdds: 2, + wantDels: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ComputeLineDiff(tt.old, tt.new) + if len(got) != tt.wantLen { + t.Errorf("len = %d, want %d; diff = %v", len(got), tt.wantLen, got) + } + var adds, dels int + for _, l := range got { + switch l.Type { + case DiffAdded: + adds++ + case DiffDeleted: + dels++ + } + } + if adds != tt.wantAdds { + t.Errorf("adds = %d, want %d", adds, tt.wantAdds) + } + if dels != tt.wantDels { + t.Errorf("dels = %d, want %d", dels, tt.wantDels) + } + }) + } +} + +func TestComputeLineDiff_ContextContent(t *testing.T) { + old := []string{"a", "b", "c"} + new := []string{"a", "x", "c"} + got := ComputeLineDiff(old, new) + + if got[0].Type != DiffContext || got[0].Content != "a" { + t.Errorf("first line should be context 'a', got %+v", got[0]) + } + last := got[len(got)-1] + if last.Type != DiffContext || last.Content != "c" { + t.Errorf("last line should be context 'c', got %+v", last) + } +} diff --git a/internal/tool/comment_collector_test.go b/internal/tool/comment_collector_test.go new file mode 100644 index 0000000..e2628e8 --- /dev/null +++ b/internal/tool/comment_collector_test.go @@ -0,0 +1,152 @@ +package tool + +import ( + "testing" + + "github.com/open-code-review/open-code-review/internal/model" +) + +func cm(path, content string) model.LlmComment { + return model.LlmComment{Path: path, Content: content} +} + +func TestCommentCollector_AddAndComments(t *testing.T) { + c := NewCommentCollector() + c.Add(cm("a.go", "issue 1")) + c.Add(cm("b.go", "issue 2")) + + got := c.Comments() + if len(got) != 2 { + t.Fatalf("expected 2 comments, got %d", len(got)) + } + if got[0].Path != "a.go" || got[1].Path != "b.go" { + t.Errorf("unexpected paths: %v", got) + } +} + +func TestCommentCollector_CommentsReturnsDefensiveCopy(t *testing.T) { + c := NewCommentCollector() + c.Add(cm("a.go", "x")) + got := c.Comments() + got[0].Content = "mutated" + if c.Comments()[0].Content == "mutated" { + t.Error("Comments() should return a copy") + } +} + +func TestCommentCollector_CommentsForPath(t *testing.T) { + c := NewCommentCollector() + c.Add(cm("a.go", "1")) + c.Add(cm("b.go", "2")) + c.Add(cm("a.go", "3")) + + got := c.CommentsForPath("a.go") + if len(got) != 2 { + t.Fatalf("expected 2 comments for a.go, got %d", len(got)) + } + if got[0].Content != "1" || got[1].Content != "3" { + t.Errorf("unexpected: %v", got) + } + + got = c.CommentsForPath("nonexist.go") + if len(got) != 0 { + t.Errorf("expected 0 for nonexistent path, got %d", len(got)) + } +} + +func TestCommentCollector_SnapshotAndSince(t *testing.T) { + c := NewCommentCollector() + c.Add(cm("a.go", "old")) + snap := c.Snapshot() + if snap != 1 { + t.Fatalf("snapshot = %d, want 1", snap) + } + + c.Add(cm("b.go", "new1")) + c.Add(cm("c.go", "new2")) + + since := c.Since(snap) + if len(since) != 2 { + t.Fatalf("Since(%d) len = %d, want 2", snap, len(since)) + } + if since[0].Path != "b.go" || since[1].Path != "c.go" { + t.Errorf("unexpected: %v", since) + } +} + +func TestCommentCollector_SinceEdgeCases(t *testing.T) { + c := NewCommentCollector() + c.Add(cm("a.go", "x")) + + if got := c.Since(-1); len(got) != 1 { + t.Errorf("Since(-1) should clamp to 0, got len %d", len(got)) + } + if got := c.Since(100); got != nil { + t.Errorf("Since(100) should return nil, got %v", got) + } +} + +func TestCommentCollector_ReplaceSince(t *testing.T) { + c := NewCommentCollector() + c.Add(cm("a.go", "keep")) + snap := c.Snapshot() + c.Add(cm("b.go", "raw1")) + c.Add(cm("c.go", "raw2")) + + c.ReplaceSince(snap, []model.LlmComment{cm("merged.go", "deduped")}) + + got := c.Comments() + if len(got) != 2 { + t.Fatalf("expected 2 comments after replace, got %d", len(got)) + } + if got[0].Path != "a.go" { + t.Errorf("first comment should be kept, got %v", got[0]) + } + if got[1].Path != "merged.go" { + t.Errorf("second comment should be replacement, got %v", got[1]) + } +} + +func TestCommentCollector_ReplaceSinceOutOfBounds(t *testing.T) { + c := NewCommentCollector() + c.Add(cm("a.go", "x")) + c.ReplaceSince(100, []model.LlmComment{cm("z.go", "nope")}) + if len(c.Comments()) != 1 { + t.Error("ReplaceSince beyond len should be no-op") + } +} + +func TestCommentCollector_RemoveByPathAndIndices(t *testing.T) { + c := NewCommentCollector() + c.Add(cm("a.go", "a0")) + c.Add(cm("b.go", "b0")) + c.Add(cm("a.go", "a1")) + c.Add(cm("a.go", "a2")) + c.Add(cm("b.go", "b1")) + + c.RemoveByPathAndIndices("a.go", map[int]struct{}{0: {}, 2: {}}) + + got := c.Comments() + if len(got) != 3 { + t.Fatalf("expected 3 comments, got %d: %v", len(got), got) + } + paths := make([]string, len(got)) + for i, g := range got { + paths[i] = g.Path + ":" + g.Content + } + want := []string{"b.go:b0", "a.go:a1", "b.go:b1"} + for i, w := range want { + if paths[i] != w { + t.Errorf("index %d: got %q, want %q", i, paths[i], w) + } + } +} + +func TestCommentCollector_RemoveByPathAndIndices_NoMatch(t *testing.T) { + c := NewCommentCollector() + c.Add(cm("a.go", "x")) + c.RemoveByPathAndIndices("b.go", map[int]struct{}{0: {}}) + if len(c.Comments()) != 1 { + t.Error("remove from non-matching path should be no-op") + } +} diff --git a/internal/tool/definitions_test.go b/internal/tool/definitions_test.go new file mode 100644 index 0000000..a1f1af3 --- /dev/null +++ b/internal/tool/definitions_test.go @@ -0,0 +1,109 @@ +package tool + +import ( + "context" + "testing" +) + +func TestOfName(t *testing.T) { + tests := []struct { + name string + want Tool + }{ + {"code_comment", CodeComment}, + {"file_read", FileRead}, + {"file_find", FileFind}, + {"file_read_diff", FileReadDiff}, + {"code_search", CodeSearch}, + {"task_done", TaskDone}, + {"nonexistent", Unknown}, + {"", Unknown}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := OfName(tt.name) + if got != tt.want { + t.Errorf("OfName(%q) = %v, want %v", tt.name, got, tt.want) + } + }) + } +} + +func TestTool_Name(t *testing.T) { + if CodeComment.Name() != "code_comment" { + t.Errorf("CodeComment.Name() = %q", CodeComment.Name()) + } +} + +func TestTool_IsKnown(t *testing.T) { + if !CodeComment.IsKnown() { + t.Error("CodeComment should be known") + } + if Unknown.IsKnown() { + t.Error("Unknown should not be known") + } +} + +func TestRegistry_RegisterAndGet(t *testing.T) { + reg := NewRegistry() + stub := NewStub(CodeComment) + reg.Register(stub) + + got, ok := reg.Get("code_comment") + if !ok { + t.Fatal("expected to find code_comment") + } + if got.Tool() != CodeComment { + t.Errorf("got tool %v, want CodeComment", got.Tool()) + } + + _, ok = reg.Get("nonexistent") + if ok { + t.Error("should not find nonexistent tool") + } +} + +func TestRegistry_Freeze_PanicsOnRegister(t *testing.T) { + reg := NewRegistry() + reg.Freeze() + + defer func() { + if r := recover(); r == nil { + t.Error("expected panic on Register after Freeze") + } + }() + reg.Register(NewStub(FileRead)) +} + +func TestRegistry_GetAfterFreeze(t *testing.T) { + reg := NewRegistry() + reg.Register(NewStub(FileRead)) + reg.Freeze() + + _, ok := reg.Get("file_read") + if !ok { + t.Error("should still find tools after Freeze") + } +} + +type dummyProvider struct{} + +func (d *dummyProvider) Tool() Tool { return CodeSearch } +func (d *dummyProvider) Execute(_ context.Context, _ map[string]any) (string, error) { + return "result", nil +} + +func TestRegistry_ProviderInterface(t *testing.T) { + reg := NewRegistry() + reg.Register(&dummyProvider{}) + reg.Freeze() + + p, ok := reg.Get("code_search") + if !ok { + t.Fatal("code_search not found") + } + result, err := p.Execute(context.Background(), nil) + if err != nil || result != "result" { + t.Errorf("Execute() = %q, %v", result, err) + } +} diff --git a/internal/tool/file_read_diff_test.go b/internal/tool/file_read_diff_test.go new file mode 100644 index 0000000..dbd37c2 --- /dev/null +++ b/internal/tool/file_read_diff_test.go @@ -0,0 +1,106 @@ +package tool + +import ( + "context" + "strings" + "testing" +) + +func TestNewDiffMap_DefensiveCopy(t *testing.T) { + orig := map[string]string{"a.go": "diff a"} + dm := NewDiffMap(orig) + orig["a.go"] = "mutated" + if v, _ := dm.Get("a.go"); v != "diff a" { + t.Error("NewDiffMap should make a defensive copy") + } +} + +func TestDiffMap_Get(t *testing.T) { + dm := NewDiffMap(map[string]string{"x.go": "content"}) + + v, ok := dm.Get("x.go") + if !ok || v != "content" { + t.Errorf("Get(x.go) = %q, %v; want 'content', true", v, ok) + } + + _, ok = dm.Get("missing.go") + if ok { + t.Error("Get(missing.go) should return false") + } +} + +func TestFileReadDiffProvider_Execute(t *testing.T) { + dm := NewDiffMap(map[string]string{ + "a.go": "@@ -1 +1 @@\n-old\n+new", + "b.go": "@@ -5 +5 @@\n-foo\n+bar", + }) + p := NewFileReadDiff(dm) + + tests := []struct { + name string + args map[string]any + wantSub string + wantErr string + }{ + { + name: "single existing path", + args: map[string]any{"path_array": []any{"a.go"}}, + wantSub: "==== FILE: a.go ====", + }, + { + name: "multiple paths", + args: map[string]any{"path_array": []any{"a.go", "b.go"}}, + wantSub: "==== FILE: b.go ====", + }, + { + name: "missing path", + args: map[string]any{"path_array": []any{"missing.go"}}, + wantErr: "Error: diff not found", + }, + { + name: "empty path_array", + args: map[string]any{"path_array": []any{}}, + wantErr: "Error: no files found", + }, + { + name: "nil path_array", + args: map[string]any{}, + wantErr: "Error: no files found", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := p.Execute(context.Background(), tt.args) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tt.wantErr != "" { + if !strings.Contains(got, tt.wantErr) { + t.Errorf("got %q, want containing %q", got, tt.wantErr) + } + return + } + if !strings.Contains(got, tt.wantSub) { + t.Errorf("got %q, want containing %q", got, tt.wantSub) + } + }) + } +} + +func TestFileReadDiffProvider_SetDiffMap(t *testing.T) { + p := NewFileReadDiff(NewDiffMap(map[string]string{"old.go": "v1"})) + p.SetDiffMap(NewDiffMap(map[string]string{"new.go": "v2"})) + + got, _ := p.Execute(context.Background(), map[string]any{"path_array": []any{"new.go"}}) + if !strings.Contains(got, "new.go") { + t.Errorf("SetDiffMap not applied: %q", got) + } +} + +func TestFileReadDiffProvider_Tool(t *testing.T) { + p := NewFileReadDiff(NewDiffMap(nil)) + if p.Tool() != FileReadDiff { + t.Errorf("Tool() = %v, want FileReadDiff", p.Tool()) + } +} diff --git a/internal/tool/filereader_test.go b/internal/tool/filereader_test.go new file mode 100644 index 0000000..86c2579 --- /dev/null +++ b/internal/tool/filereader_test.go @@ -0,0 +1,139 @@ +package tool + +import ( + "strings" + "testing" +) + +func TestParseReviewMode(t *testing.T) { + tests := []struct { + name string + from, to, comm string + want ReviewMode + }{ + {"workspace default", "", "", "", ModeWorkspace}, + {"range mode", "HEAD~1", "HEAD", "", ModeRange}, + {"commit mode", "", "", "abc123", ModeCommit}, + {"commit takes precedence", "a", "b", "c", ModeCommit}, + {"from only is workspace", "HEAD~1", "", "", ModeWorkspace}, + {"to only is workspace", "", "HEAD", "", ModeWorkspace}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ParseReviewMode(tt.from, tt.to, tt.comm) + if got != tt.want { + t.Errorf("ParseReviewMode(%q,%q,%q) = %v, want %v", tt.from, tt.to, tt.comm, got, tt.want) + } + }) + } +} + +func TestReviewMode_RefValue(t *testing.T) { + tests := []struct { + name string + mode ReviewMode + toRef string + commit string + wantRef string + wantOK bool + }{ + {"workspace returns empty", ModeWorkspace, "HEAD", "abc", "", false}, + {"range returns toRef", ModeRange, "HEAD", "", "HEAD", true}, + {"commit returns commit", ModeCommit, "HEAD", "abc123", "abc123", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ref, ok := tt.mode.RefValue(tt.toRef, tt.commit) + if ref != tt.wantRef || ok != tt.wantOK { + t.Errorf("RefValue(%q,%q) = (%q,%v), want (%q,%v)", tt.toRef, tt.commit, ref, ok, tt.wantRef, tt.wantOK) + } + }) + } +} + +func TestScanLines(t *testing.T) { + tests := []struct { + name string + input string + startLine int + maxLines int + wantLines []string + wantTotal int + }{ + { + name: "full file", + input: "line1\nline2\nline3\n", + startLine: 1, + maxLines: 100, + wantLines: []string{"line1", "line2", "line3", ""}, + wantTotal: 4, + }, + { + name: "no trailing newline", + input: "line1\nline2", + startLine: 1, + maxLines: 100, + wantLines: []string{"line1", "line2"}, + wantTotal: 2, + }, + { + name: "start from line 2", + input: "a\nb\nc\n", + startLine: 2, + maxLines: 100, + wantLines: []string{"b", "c", ""}, + wantTotal: 4, + }, + { + name: "limit lines", + input: "a\nb\nc\nd\n", + startLine: 1, + maxLines: 2, + wantLines: []string{"a", "b"}, + wantTotal: 5, + }, + { + name: "start beyond end", + input: "a\nb\n", + startLine: 10, + maxLines: 100, + wantLines: nil, + wantTotal: 3, + }, + { + name: "empty input", + input: "", + startLine: 1, + maxLines: 100, + wantLines: nil, + wantTotal: 0, + }, + { + name: "crlf line endings", + input: "line1\r\nline2\r\n", + startLine: 1, + maxLines: 100, + wantLines: []string{"line1", "line2", ""}, + wantTotal: 3, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + lines, total, err := scanLines(strings.NewReader(tt.input), tt.startLine, tt.maxLines) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if total != tt.wantTotal { + t.Errorf("total = %d, want %d", total, tt.wantTotal) + } + if len(lines) != len(tt.wantLines) { + t.Fatalf("lines count = %d, want %d; lines=%v", len(lines), len(tt.wantLines), lines) + } + for i, l := range lines { + if l != tt.wantLines[i] { + t.Errorf("line[%d] = %q, want %q", i, l, tt.wantLines[i]) + } + } + }) + } +} diff --git a/internal/viewer/server_test.go b/internal/viewer/server_test.go new file mode 100644 index 0000000..2c3a833 --- /dev/null +++ b/internal/viewer/server_test.go @@ -0,0 +1,73 @@ +package viewer + +import ( + "testing" + "time" +) + +func TestTruncateText(t *testing.T) { + tests := []struct { + name string + n int + s string + want string + }{ + {"short string unchanged", 10, "hello", "hello"}, + {"exact length unchanged", 5, "hello", "hello"}, + {"truncated with ellipsis", 3, "hello", "hel…"}, + {"empty string", 5, "", ""}, + {"n=0 always truncates non-empty", 0, "hi", "…"}, + {"unicode shorter than n bytes", 20, "你好世界", "你好世界"}, + {"unicode truncated at byte boundary", 6, "你好世界", "你好…"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := truncateText(tt.n, tt.s) + if got != tt.want { + t.Errorf("truncateText(%d, %q) = %q, want %q", tt.n, tt.s, got, tt.want) + } + }) + } +} + +func TestFormatDuration(t *testing.T) { + tests := []struct { + name string + seconds float64 + want string + }{ + {"zero", 0, "0.0s"}, + {"sub-second", 0.5, "0.5s"}, + {"seconds only", 45.3, "45.3s"}, + {"exactly one minute", 60, "1m0s"}, + {"minutes and seconds", 125, "2m5s"}, + {"large value", 3661, "61m1s"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := formatDuration(tt.seconds) + if got != tt.want { + t.Errorf("formatDuration(%v) = %q, want %q", tt.seconds, got, tt.want) + } + }) + } +} + +func TestFormatTime(t *testing.T) { + cst := time.FixedZone("CST", 8*60*60) + input := time.Date(2025, 3, 15, 14, 30, 0, 0, cst) + got := formatTime(input) + want := "2025-03-15 14:30" + if got != want { + t.Errorf("formatTime() = %q, want %q", got, want) + } +} + +func TestFormatTime_UTC(t *testing.T) { + input := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + got := formatTime(input) + want := "2025-01-01 08:00" + if got != want { + t.Errorf("formatTime(UTC midnight) = %q, want %q (should be +8h)", got, want) + } +} From 904ecd3ae1121fb5098b510e175efac1293e212e Mon Sep 17 00:00:00 2001 From: kite Date: Fri, 26 Jun 2026 23:09:11 +0800 Subject: [PATCH 04/87] test: expand unit test coverage for agent, llm, llmloop, and tool packages Add integration-style tests with fake LLM clients for agent dispatch and llmloop runner, plus new unit test files for gitcmd, session/history, tool/code_comment, tool/filereader_read, and viewer/store packages. --- internal/agent/agent_test.go | 265 ++++++++++++++++++++++++++ internal/gitcmd/runner_test.go | 151 +++++++++++++++ internal/llm/client_test.go | 87 +++++++++ internal/llmloop/loop_test.go | 227 ++++++++++++++++++++++ internal/llmloop/pool_test.go | 99 ++++++++++ internal/session/history_test.go | 214 +++++++++++++++++++++ internal/tool/code_comment_test.go | 190 ++++++++++++++++++ internal/tool/filereader_read_test.go | 130 +++++++++++++ internal/viewer/store_test.go | 241 +++++++++++++++++++++++ 9 files changed, 1604 insertions(+) create mode 100644 internal/gitcmd/runner_test.go create mode 100644 internal/llmloop/pool_test.go create mode 100644 internal/session/history_test.go create mode 100644 internal/tool/code_comment_test.go create mode 100644 internal/tool/filereader_read_test.go create mode 100644 internal/viewer/store_test.go diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index a3b65b9..99f8280 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -1,15 +1,88 @@ 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/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 @@ -302,3 +375,195 @@ func TestBuildToolDefs(t *testing.T) { } }) } + +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 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_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()) + } +} diff --git a/internal/gitcmd/runner_test.go b/internal/gitcmd/runner_test.go new file mode 100644 index 0000000..f803ebe --- /dev/null +++ b/internal/gitcmd/runner_test.go @@ -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") + } +} diff --git a/internal/llm/client_test.go b/internal/llm/client_test.go index fc23feb..50db73c 100644 --- a/internal/llm/client_test.go +++ b/internal/llm/client_test.go @@ -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", "partial", "partial"}, + {"close only", "partial", "partial"}, + {"both tags", "reasoning hereanswer", "reasoning hereanswer"}, + {"multiple tags", "abcd", "abcd"}, + {"empty", "", ""}, + {"tags only", "", ""}, + } + 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) + } + }) + } +} diff --git a/internal/llmloop/loop_test.go b/internal/llmloop/loop_test.go index 2688aa7..5b6d449 100644 --- a/internal/llmloop/loop_test.go +++ b/internal/llmloop/loop_test.go @@ -5,10 +5,237 @@ import ( "encoding/json" "testing" + "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/session" "github.com/open-code-review/open-code-review/internal/tool" ) +type fakeClient struct { + responses []*llm.ChatResponse + calls int +} + +func (f *fakeClient) 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 taskDoneResponse() *llm.ChatResponse { + content := "" + return &llm.ChatResponse{ + Choices: []llm.Choice{{ + Message: llm.ResponseMessage{ + Content: &content, + ToolCalls: []llm.ToolCall{{ + ID: "call_1", + Type: "function", + Function: llm.FunctionCall{ + Name: "task_done", + Arguments: `{}`, + }, + }}, + }, + }}, + Model: "fake", + Usage: &llm.UsageInfo{PromptTokens: 10, CompletionTokens: 5}, + } +} + +func fileReadToolCallResponse(callID, args string) *llm.ChatResponse { + content := "" + return &llm.ChatResponse{ + Choices: []llm.Choice{{ + Message: llm.ResponseMessage{ + Content: &content, + ToolCalls: []llm.ToolCall{{ + ID: callID, + Type: "function", + Function: llm.FunctionCall{ + Name: "file_read", + Arguments: args, + }, + }}, + }, + }}, + Model: "fake", + Usage: &llm.UsageInfo{PromptTokens: 20, CompletionTokens: 10}, + } +} + +type fakeFileReadProvider struct { + result string +} + +func (f *fakeFileReadProvider) Tool() tool.Tool { return tool.FileRead } +func (f *fakeFileReadProvider) Execute(_ context.Context, _ map[string]any) (string, error) { + return f.result, nil +} + +func newTestDeps(client llm.LLMClient) Deps { + reg := tool.NewRegistry() + reg.Register(&fakeFileReadProvider{result: "package main\n"}) + return Deps{ + LLMClient: client, + Model: "fake", + Template: template.Template{MaxTokens: 100000, MaxToolRequestTimes: 10}, + Tools: reg, + CommentCollector: tool.NewCommentCollector(), + Session: session.New("/tmp/test-repo", "main", "fake", session.SessionOptions{}), + } +} + +func TestRunPerFile_TaskDoneImmediately(t *testing.T) { + client := &fakeClient{responses: []*llm.ChatResponse{taskDoneResponse()}} + deps := newTestDeps(client) + runner := NewRunner(deps) + + msgs := []llm.Message{llm.NewTextMessage("user", "review this file")} + err := runner.RunPerFile(context.Background(), msgs, "main.go") + if err != nil { + t.Fatalf("RunPerFile: %v", err) + } + if client.calls != 1 { + t.Errorf("expected 1 LLM call, got %d", client.calls) + } + if runner.TotalInputTokens() != 10 { + t.Errorf("TotalInputTokens = %d, want 10", runner.TotalInputTokens()) + } + if runner.TotalOutputTokens() != 5 { + t.Errorf("TotalOutputTokens = %d, want 5", runner.TotalOutputTokens()) + } +} + +func TestRunPerFile_ToolCallThenDone(t *testing.T) { + client := &fakeClient{responses: []*llm.ChatResponse{ + fileReadToolCallResponse("call_1", `{"path":"main.go"}`), + taskDoneResponse(), + }} + deps := newTestDeps(client) + runner := NewRunner(deps) + + msgs := []llm.Message{llm.NewTextMessage("user", "review")} + err := runner.RunPerFile(context.Background(), msgs, "main.go") + if err != nil { + t.Fatalf("RunPerFile: %v", err) + } + if client.calls != 2 { + t.Errorf("expected 2 LLM calls, got %d", client.calls) + } + + toolCalls := runner.ToolCalls() + if toolCalls["file_read"] != 1 { + t.Errorf("file_read calls = %d, want 1", toolCalls["file_read"]) + } + if runner.TotalInputTokens() != 30 { + t.Errorf("TotalInputTokens = %d, want 30", runner.TotalInputTokens()) + } +} + +func TestRunPerFile_ContextCancelled(t *testing.T) { + client := &fakeClient{responses: []*llm.ChatResponse{taskDoneResponse()}} + deps := newTestDeps(client) + runner := NewRunner(deps) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + msgs := []llm.Message{llm.NewTextMessage("user", "review")} + err := runner.RunPerFile(ctx, msgs, "main.go") + if err == nil { + t.Error("expected error for cancelled context") + } +} + +func TestRunPerFile_UnknownTool(t *testing.T) { + content := "" + unknownToolResp := &llm.ChatResponse{ + Choices: []llm.Choice{{ + Message: llm.ResponseMessage{ + Content: &content, + ToolCalls: []llm.ToolCall{{ + ID: "call_x", + Type: "function", + Function: llm.FunctionCall{ + Name: "nonexistent_tool", + Arguments: `{}`, + }, + }}, + }, + }}, + Model: "fake", + Usage: &llm.UsageInfo{PromptTokens: 5, CompletionTokens: 5}, + } + client := &fakeClient{responses: []*llm.ChatResponse{unknownToolResp, taskDoneResponse()}} + deps := newTestDeps(client) + runner := NewRunner(deps) + + msgs := []llm.Message{llm.NewTextMessage("user", "review")} + err := runner.RunPerFile(context.Background(), msgs, "main.go") + if err != nil { + t.Fatalf("RunPerFile: %v", err) + } + if client.calls != 2 { + t.Errorf("expected 2 calls, got %d", client.calls) + } +} + +func TestRunner_RecordWarning(t *testing.T) { + deps := newTestDeps(&fakeClient{}) + runner := NewRunner(deps) + + runner.RecordWarning("token_limit", "a.go", "approaching token limit") + runner.RecordWarning("parse_error", "b.go", "invalid JSON") + + warnings := runner.Warnings() + if len(warnings) != 2 { + t.Fatalf("expected 2 warnings, got %d", len(warnings)) + } + if warnings[0].Type != "token_limit" { + t.Errorf("Type = %q", warnings[0].Type) + } + if warnings[1].File != "b.go" { + t.Errorf("File = %q", warnings[1].File) + } +} + +func TestRunner_RecordUsage(t *testing.T) { + deps := newTestDeps(&fakeClient{}) + runner := NewRunner(deps) + + runner.RecordUsage(&llm.UsageInfo{ + PromptTokens: 100, + CompletionTokens: 50, + CacheReadTokens: 20, + CacheWriteTokens: 10, + }) + runner.RecordUsage(nil) + + if runner.TotalInputTokens() != 100 { + t.Errorf("input = %d", runner.TotalInputTokens()) + } + if runner.TotalOutputTokens() != 50 { + t.Errorf("output = %d", runner.TotalOutputTokens()) + } + if runner.TotalCacheReadTokens() != 20 { + t.Errorf("cache read = %d", runner.TotalCacheReadTokens()) + } + if runner.TotalCacheWriteTokens() != 10 { + t.Errorf("cache write = %d", runner.TotalCacheWriteTokens()) + } + if runner.TotalTokensUsed() != 150 { + t.Errorf("total = %d", runner.TotalTokensUsed()) + } +} + func TestExecuteToolCall_CodeCommentOverridesHallucinatedPath(t *testing.T) { collector := tool.NewCommentCollector() reg := tool.NewRegistry() diff --git a/internal/llmloop/pool_test.go b/internal/llmloop/pool_test.go new file mode 100644 index 0000000..1db6bbe --- /dev/null +++ b/internal/llmloop/pool_test.go @@ -0,0 +1,99 @@ +package llmloop + +import ( + "errors" + "sync/atomic" + "testing" + + "github.com/open-code-review/open-code-review/internal/model" +) + +func TestNewCommentWorkerPool_Default(t *testing.T) { + p := NewCommentWorkerPool(0) + if cap(p.semaphore) != 8 { + t.Errorf("default capacity = %d, want 8", cap(p.semaphore)) + } +} + +func TestNewCommentWorkerPool_Custom(t *testing.T) { + p := NewCommentWorkerPool(4) + if cap(p.semaphore) != 4 { + t.Errorf("capacity = %d, want 4", cap(p.semaphore)) + } +} + +func TestCommentWorkerPool_SubmitAndAwait(t *testing.T) { + p := NewCommentWorkerPool(2) + + p.Submit(func() ([]model.LlmComment, error) { + return []model.LlmComment{{Path: "a.go", Content: "issue 1"}}, nil + }) + p.Submit(func() ([]model.LlmComment, error) { + return []model.LlmComment{{Path: "b.go", Content: "issue 2"}, {Path: "b.go", Content: "issue 3"}}, nil + }) + + results := p.Await() + if len(results) != 3 { + t.Fatalf("expected 3 results, got %d", len(results)) + } + + paths := map[string]bool{} + for _, r := range results { + paths[r.Path] = true + } + if !paths["a.go"] || !paths["b.go"] { + t.Errorf("unexpected paths: %v", results) + } +} + +func TestCommentWorkerPool_ErrorDoesNotBlock(t *testing.T) { + p := NewCommentWorkerPool(2) + + p.Submit(func() ([]model.LlmComment, error) { + return nil, errors.New("oops") + }) + p.Submit(func() ([]model.LlmComment, error) { + return []model.LlmComment{{Path: "ok.go", Content: "fine"}}, nil + }) + + results := p.Await() + if len(results) != 1 { + t.Fatalf("expected 1 result after error, got %d", len(results)) + } + if results[0].Path != "ok.go" { + t.Errorf("Path = %q", results[0].Path) + } +} + +func TestCommentWorkerPool_Concurrency(t *testing.T) { + p := NewCommentWorkerPool(3) + var running atomic.Int32 + var maxRunning atomic.Int32 + + for i := 0; i < 10; i++ { + p.Submit(func() ([]model.LlmComment, error) { + cur := running.Add(1) + for { + old := maxRunning.Load() + if cur <= old || maxRunning.CompareAndSwap(old, cur) { + break + } + } + running.Add(-1) + return nil, nil + }) + } + + p.Await() + if maxRunning.Load() > 3 { + t.Errorf("max concurrent = %d, expected <= 3", maxRunning.Load()) + } +} + +func TestCommentWorkerPool_AwaitEmpty(t *testing.T) { + p := NewCommentWorkerPool(2) + results := p.Await() + if results != nil { + t.Errorf("expected nil for no submissions, got %v", results) + } +} diff --git a/internal/session/history_test.go b/internal/session/history_test.go new file mode 100644 index 0000000..4daf05a --- /dev/null +++ b/internal/session/history_test.go @@ -0,0 +1,214 @@ +package session + +import ( + "errors" + "testing" + "time" + + "github.com/open-code-review/open-code-review/internal/llm" +) + +func TestNew(t *testing.T) { + sh := New("/tmp/repo", "main", "gpt-4", SessionOptions{ + ReviewMode: ReviewModeWorkspace, + DiffFrom: "a", + DiffTo: "b", + DiffCommit: "c", + }) + if sh == nil { + t.Fatal("New returned nil") + } + if sh.SessionID == "" { + t.Error("SessionID should not be empty") + } + if sh.RepoDir != "/tmp/repo" { + t.Errorf("RepoDir = %q", sh.RepoDir) + } + if sh.GitBranch != "main" { + t.Errorf("GitBranch = %q", sh.GitBranch) + } + if sh.Model != "gpt-4" { + t.Errorf("Model = %q", sh.Model) + } + if sh.ReviewMode != ReviewModeWorkspace { + t.Errorf("ReviewMode = %q", sh.ReviewMode) + } + if sh.DiffFrom != "a" || sh.DiffTo != "b" || sh.DiffCommit != "c" { + t.Errorf("Diff fields mismatch") + } + if sh.StartTime.IsZero() { + t.Error("StartTime should be set") + } + if sh.FileSessions == nil { + t.Error("FileSessions map should be initialized") + } +} + +func TestGetOrCreateFileSession(t *testing.T) { + sh := New("/tmp/repo", "main", "model", SessionOptions{}) + + fs1 := sh.GetOrCreateFileSession("main.go") + if fs1 == nil { + t.Fatal("nil FileSession") + } + if fs1.FilePath != "main.go" { + t.Errorf("FilePath = %q", fs1.FilePath) + } + + fs2 := sh.GetOrCreateFileSession("main.go") + if fs1 != fs2 { + t.Error("expected same FileSession instance on second call") + } + + fs3 := sh.GetOrCreateFileSession("other.go") + if fs3 == fs1 { + t.Error("different paths should yield different sessions") + } +} + +func TestAppendTaskRecord(t *testing.T) { + sh := New("/tmp/repo", "main", "model", SessionOptions{}) + fs := sh.GetOrCreateFileSession("file.go") + + msgs := []llm.Message{llm.NewTextMessage("user", "hello")} + rec := fs.AppendTaskRecord(MainTask, msgs) + if rec == nil { + t.Fatal("nil TaskRecord") + } + if rec.Type != MainTask { + t.Errorf("Type = %v", rec.Type) + } + if rec.RequestNo != 1 { + t.Errorf("RequestNo = %d, want 1", rec.RequestNo) + } + + rec2 := fs.AppendTaskRecord(MainTask, msgs) + if rec2.RequestNo != 2 { + t.Errorf("second RequestNo = %d, want 2", rec2.RequestNo) + } + + rec3 := fs.AppendTaskRecord(PlanTask, msgs) + if rec3.RequestNo != 1 { + t.Errorf("PlanTask RequestNo = %d, want 1 (separate counter)", rec3.RequestNo) + } +} + +func TestAppendTaskRecord_DefensiveCopy(t *testing.T) { + sh := New("/tmp/repo", "main", "model", SessionOptions{}) + fs := sh.GetOrCreateFileSession("file.go") + + msgs := []llm.Message{llm.NewTextMessage("user", "original")} + rec := fs.AppendTaskRecord(MainTask, msgs) + msgs[0] = llm.NewTextMessage("user", "mutated") + + if rec.RequestMessages[0].ExtractText() == "mutated" { + t.Error("AppendTaskRecord should store a copy of messages") + } +} + +func TestSetResponse(t *testing.T) { + sh := New("/tmp/repo", "main", "model", SessionOptions{}) + fs := sh.GetOrCreateFileSession("file.go") + rec := fs.AppendTaskRecord(MainTask, []llm.Message{llm.NewTextMessage("user", "hi")}) + + content := "response text" + resp := &llm.ChatResponse{ + Choices: []llm.Choice{{ + Message: llm.ResponseMessage{ + Content: &content, + }, + }}, + Model: "gpt-4", + Usage: &llm.UsageInfo{ + PromptTokens: 100, + CompletionTokens: 50, + }, + } + + rec.SetResponse(resp, 2*time.Second) + + if rec.Response == nil { + t.Fatal("Response should be set") + } + if rec.Response.Content != "response text" { + t.Errorf("Content = %q", rec.Response.Content) + } + if rec.Response.Model != "gpt-4" { + t.Errorf("Model = %q", rec.Response.Model) + } + if rec.Response.Usage.PromptTokens != 100 { + t.Errorf("PromptTokens = %d", rec.Response.Usage.PromptTokens) + } + if rec.Response.Usage.CompletionTokens != 50 { + t.Errorf("CompletionTokens = %d", rec.Response.Usage.CompletionTokens) + } + if rec.Duration != 2*time.Second { + t.Errorf("Duration = %v", rec.Duration) + } +} + +func TestSetResponse_EmptyResponse(t *testing.T) { + sh := New("/tmp/repo", "main", "model", SessionOptions{}) + fs := sh.GetOrCreateFileSession("file.go") + rec := fs.AppendTaskRecord(MainTask, []llm.Message{llm.NewTextMessage("user", "hi")}) + + rec.SetResponse(nil, time.Second) + if rec.Error == "" { + t.Error("expected error for nil response") + } +} + +func TestSetError(t *testing.T) { + sh := New("/tmp/repo", "main", "model", SessionOptions{}) + fs := sh.GetOrCreateFileSession("file.go") + rec := fs.AppendTaskRecord(MainTask, []llm.Message{llm.NewTextMessage("user", "hi")}) + + rec.SetError(errors.New("timeout"), 5*time.Second) + + if rec.Error != "timeout" { + t.Errorf("Error = %q, want %q", rec.Error, "timeout") + } + if rec.Duration != 5*time.Second { + t.Errorf("Duration = %v", rec.Duration) + } +} + +func TestLLMFailures(t *testing.T) { + sh := New("/tmp/repo", "main", "model", SessionOptions{}) + if sh.LLMFailures() != 0 { + t.Errorf("initial failures = %d", sh.LLMFailures()) + } + + fs := sh.GetOrCreateFileSession("a.go") + rec := fs.AppendTaskRecord(MainTask, nil) + rec.SetError(errors.New("fail1"), time.Second) + + rec2 := fs.AppendTaskRecord(MainTask, nil) + rec2.SetError(errors.New("fail2"), time.Second) + + if sh.LLMFailures() != 2 { + t.Errorf("failures = %d, want 2", sh.LLMFailures()) + } +} + +func TestAddToolResult(t *testing.T) { + sh := New("/tmp/repo", "main", "model", SessionOptions{}) + fs := sh.GetOrCreateFileSession("file.go") + rec := fs.AppendTaskRecord(MainTask, nil) + + rec.AddToolResult("file_read", `{"path":"main.go"}`, "package main") + + if len(rec.ToolResults) != 1 { + t.Fatalf("len = %d", len(rec.ToolResults)) + } + tr := rec.ToolResults[0] + if tr.ToolName != "file_read" { + t.Errorf("ToolName = %q", tr.ToolName) + } + if tr.Arguments != `{"path":"main.go"}` { + t.Errorf("Arguments = %q", tr.Arguments) + } + if tr.Result != "package main" { + t.Errorf("Result = %q", tr.Result) + } +} diff --git a/internal/tool/code_comment_test.go b/internal/tool/code_comment_test.go new file mode 100644 index 0000000..0c3877a --- /dev/null +++ b/internal/tool/code_comment_test.go @@ -0,0 +1,190 @@ +package tool + +import ( + "context" + "testing" +) + +func TestParseComments(t *testing.T) { + tests := []struct { + name string + args map[string]any + wantCount int + wantErr bool + }{ + { + name: "valid comments array", + args: map[string]any{ + "path": "main.go", + "comments": []any{ + map[string]any{"content": "issue 1", "existing_code": "old"}, + map[string]any{"content": "issue 2", "suggestion_code": "new"}, + }, + }, + wantCount: 2, + }, + { + name: "comments as JSON string", + args: map[string]any{ + "path": "main.go", + "comments": `[{"content":"from string"}]`, + }, + wantCount: 1, + }, + { + name: "missing path skips comment", + args: map[string]any{ + "comments": []any{ + map[string]any{"content": "no path"}, + }, + }, + wantCount: 0, + }, + { + name: "missing content skips comment", + args: map[string]any{ + "path": "file.go", + "comments": []any{ + map[string]any{"existing_code": "has no content"}, + }, + }, + wantCount: 0, + }, + { + name: "empty comments array returns error", + args: map[string]any{"path": "x.go", "comments": []any{}}, + wantErr: true, + }, + { + name: "no comments key returns error", + args: map[string]any{"path": "x.go"}, + wantErr: true, + }, + { + name: "invalid JSON string returns error", + args: map[string]any{"path": "x.go", "comments": "not json"}, + wantErr: true, + }, + { + name: "thinking field preserved", + args: map[string]any{ + "path": "a.go", + "comments": []any{ + map[string]any{"content": "c", "thinking": "my reasoning"}, + }, + }, + wantCount: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + comments, errMsg := ParseComments(tt.args) + if tt.wantErr { + if errMsg == "" { + t.Error("expected error message, got empty") + } + return + } + if errMsg != "" { + t.Fatalf("unexpected error: %s", errMsg) + } + if len(comments) != tt.wantCount { + t.Errorf("len(comments) = %d, want %d", len(comments), tt.wantCount) + } + }) + } +} + +func TestParseComments_Fields(t *testing.T) { + args := map[string]any{ + "path": "src/app.ts", + "comments": []any{ + map[string]any{ + "content": "fix null check", + "existing_code": "if (x == null)", + "suggestion_code": "if (x === null)", + "thinking": "strict equality is safer", + }, + }, + } + comments, errMsg := ParseComments(args) + if errMsg != "" { + t.Fatal(errMsg) + } + if len(comments) != 1 { + t.Fatal("expected 1 comment") + } + c := comments[0] + if c.Path != "src/app.ts" { + t.Errorf("Path = %q", c.Path) + } + if c.Content != "fix null check" { + t.Errorf("Content = %q", c.Content) + } + if c.ExistingCode != "if (x == null)" { + t.Errorf("ExistingCode = %q", c.ExistingCode) + } + if c.SuggestionCode != "if (x === null)" { + t.Errorf("SuggestionCode = %q", c.SuggestionCode) + } + if c.Thinking != "strict equality is safer" { + t.Errorf("Thinking = %q", c.Thinking) + } +} + +func TestCodeCommentProvider_Execute(t *testing.T) { + t.Run("adds comments to collector", func(t *testing.T) { + collector := NewCommentCollector() + p := &CodeCommentProvider{Collector: collector} + result, err := p.Execute(context.Background(), map[string]any{ + "path": "main.go", + "comments": []any{ + map[string]any{"content": "issue 1"}, + map[string]any{"content": "issue 2"}, + }, + }) + if err != nil { + t.Fatal(err) + } + if result != CommentSucceed { + t.Errorf("result = %q, want %q", result, CommentSucceed) + } + if len(collector.Comments()) != 2 { + t.Errorf("collector has %d comments, want 2", len(collector.Comments())) + } + }) + + t.Run("nil collector returns error message", func(t *testing.T) { + p := &CodeCommentProvider{Collector: nil} + result, err := p.Execute(context.Background(), map[string]any{ + "path": "main.go", + "comments": []any{map[string]any{"content": "x"}}, + }) + if err != nil { + t.Fatal(err) + } + if result == CommentSucceed { + t.Error("expected error message for nil collector") + } + }) + + t.Run("invalid args returns error message", func(t *testing.T) { + collector := NewCommentCollector() + p := &CodeCommentProvider{Collector: collector} + result, err := p.Execute(context.Background(), map[string]any{}) + if err != nil { + t.Fatal(err) + } + if result == CommentSucceed { + t.Error("expected error message for empty args") + } + }) + + t.Run("tool type is CodeComment", func(t *testing.T) { + p := &CodeCommentProvider{} + if p.Tool() != CodeComment { + t.Errorf("Tool() = %v, want CodeComment", p.Tool()) + } + }) +} diff --git a/internal/tool/filereader_read_test.go b/internal/tool/filereader_read_test.go new file mode 100644 index 0000000..74bffbc --- /dev/null +++ b/internal/tool/filereader_read_test.go @@ -0,0 +1,130 @@ +package tool + +import ( + "context" + "os" + "path/filepath" + "testing" +) + +func TestFileReader_Read_Workspace(t *testing.T) { + dir := t.TempDir() + content := "line1\nline2\nline3\n" + if err := os.WriteFile(filepath.Join(dir, "test.go"), []byte(content), 0644); err != nil { + t.Fatal(err) + } + + fr := &FileReader{RepoDir: dir, Mode: ModeWorkspace} + got, err := fr.Read(context.Background(), "test.go") + if err != nil { + t.Fatalf("Read() error: %v", err) + } + if got != content { + t.Errorf("Read() = %q, want %q", got, content) + } +} + +func TestFileReader_Read_WorkspaceNotFound(t *testing.T) { + dir := t.TempDir() + fr := &FileReader{RepoDir: dir, Mode: ModeWorkspace} + _, err := fr.Read(context.Background(), "missing.go") + if err == nil { + t.Error("expected error for missing file") + } +} + +func TestFileReader_Read_PathTraversal(t *testing.T) { + dir := t.TempDir() + fr := &FileReader{RepoDir: dir, Mode: ModeWorkspace} + + _, err := fr.Read(context.Background(), "../../../etc/passwd") + if err == nil { + t.Error("expected error for path traversal") + } +} + +func TestFileReader_Read_SymlinkOutsideRepo(t *testing.T) { + dir := t.TempDir() + outside := t.TempDir() + secretFile := filepath.Join(outside, "secret.txt") + if err := os.WriteFile(secretFile, []byte("sensitive"), 0644); err != nil { + t.Fatal(err) + } + + link := filepath.Join(dir, "link.txt") + if err := os.Symlink(secretFile, link); err != nil { + t.Skipf("symlinks not supported: %v", err) + } + + fr := &FileReader{RepoDir: dir, Mode: ModeWorkspace} + _, err := fr.Read(context.Background(), "link.txt") + if err == nil { + t.Error("expected error for symlink pointing outside repo") + } +} + +func TestFileReader_ReadLines_Workspace(t *testing.T) { + dir := t.TempDir() + content := "aaa\nbbb\nccc\nddd\n" + if err := os.WriteFile(filepath.Join(dir, "lines.txt"), []byte(content), 0644); err != nil { + t.Fatal(err) + } + + fr := &FileReader{RepoDir: dir, Mode: ModeWorkspace} + + t.Run("all lines", func(t *testing.T) { + lines, total, err := fr.ReadLines(context.Background(), "lines.txt", 1, 100) + if err != nil { + t.Fatal(err) + } + if total != 5 { + t.Errorf("total = %d, want 5", total) + } + if len(lines) != 5 { + t.Errorf("lines count = %d, want 5", len(lines)) + } + }) + + t.Run("start from line 2 with limit", func(t *testing.T) { + lines, total, err := fr.ReadLines(context.Background(), "lines.txt", 2, 2) + if err != nil { + t.Fatal(err) + } + if total != 5 { + t.Errorf("total = %d, want 5", total) + } + if len(lines) != 2 { + t.Fatalf("lines count = %d, want 2", len(lines)) + } + if lines[0] != "bbb" || lines[1] != "ccc" { + t.Errorf("lines = %v, want [bbb ccc]", lines) + } + }) + + t.Run("path traversal rejected", func(t *testing.T) { + _, _, err := fr.ReadLines(context.Background(), "../../etc/passwd", 1, 10) + if err == nil { + t.Error("expected error for path traversal") + } + }) +} + +func TestFileReader_Read_SubdirectoryFile(t *testing.T) { + dir := t.TempDir() + sub := filepath.Join(dir, "src", "pkg") + if err := os.MkdirAll(sub, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(sub, "main.go"), []byte("package main"), 0644); err != nil { + t.Fatal(err) + } + + fr := &FileReader{RepoDir: dir, Mode: ModeWorkspace} + got, err := fr.Read(context.Background(), "src/pkg/main.go") + if err != nil { + t.Fatalf("Read() error: %v", err) + } + if got != "package main" { + t.Errorf("Read() = %q, want %q", got, "package main") + } +} diff --git a/internal/viewer/store_test.go b/internal/viewer/store_test.go new file mode 100644 index 0000000..bb0d89b --- /dev/null +++ b/internal/viewer/store_test.go @@ -0,0 +1,241 @@ +package viewer + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func writeJSONL(t *testing.T, path string, lines ...string) { + t.Helper() + var content string + for _, l := range lines { + content += l + "\n" + } + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } +} + +func TestDiscoverRepos_Empty(t *testing.T) { + root := t.TempDir() + repos, err := DiscoverRepos(root) + if err != nil { + t.Fatal(err) + } + if len(repos) != 0 { + t.Errorf("expected 0 repos, got %d", len(repos)) + } +} + +func TestDiscoverRepos_NonExistentDir(t *testing.T) { + repos, err := DiscoverRepos("/nonexistent/path/abc123") + if err != nil { + t.Fatal(err) + } + if repos != nil { + t.Errorf("expected nil for non-existent dir, got %v", repos) + } +} + +func TestDiscoverRepos_SkipsFiles(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "stray.txt"), []byte("x"), 0644); err != nil { + t.Fatal(err) + } + repos, err := DiscoverRepos(root) + if err != nil { + t.Fatal(err) + } + if len(repos) != 0 { + t.Errorf("expected 0 repos, got %d", len(repos)) + } +} + +func TestDiscoverRepos_FindsRepos(t *testing.T) { + root := t.TempDir() + + repoA := filepath.Join(root, "repo-a") + repoB := filepath.Join(root, "repo-b") + if err := os.MkdirAll(repoA, 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(repoB, 0755); err != nil { + t.Fatal(err) + } + + writeJSONL(t, filepath.Join(repoA, "session1.jsonl"), + `{"type":"session_start","timestamp":"2025-01-01T10:00:00Z"}`) + writeJSONL(t, filepath.Join(repoA, "session2.jsonl"), + `{"type":"session_start","timestamp":"2025-01-02T10:00:00Z"}`) + writeJSONL(t, filepath.Join(repoB, "session3.jsonl"), + `{"type":"session_start","timestamp":"2025-01-03T10:00:00Z"}`) + + repos, err := DiscoverRepos(root) + if err != nil { + t.Fatal(err) + } + if len(repos) != 2 { + t.Fatalf("expected 2 repos, got %d", len(repos)) + } + if repos[0].EncodedPath != "repo-b" { + t.Errorf("expected most recent repo first, got %q", repos[0].EncodedPath) + } + if repos[1].SessionCount != 2 { + t.Errorf("repo-a session count = %d, want 2", repos[1].SessionCount) + } +} + +func TestDiscoverRepos_SkipsDirsWithNoJSONL(t *testing.T) { + root := t.TempDir() + emptyRepo := filepath.Join(root, "empty-repo") + if err := os.MkdirAll(emptyRepo, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(emptyRepo, "readme.txt"), []byte("hi"), 0644); err != nil { + t.Fatal(err) + } + + repos, err := DiscoverRepos(root) + if err != nil { + t.Fatal(err) + } + if len(repos) != 0 { + t.Errorf("expected 0 repos for dir with no .jsonl, got %d", len(repos)) + } +} + +func TestListSessions(t *testing.T) { + root := t.TempDir() + repoDir := filepath.Join(root, "myrepo") + if err := os.MkdirAll(repoDir, 0755); err != nil { + t.Fatal(err) + } + + writeJSONL(t, filepath.Join(repoDir, "aaa.jsonl"), + `{"type":"session_start","timestamp":"2025-03-01T09:00:00Z","cwd":"/home/user/proj","gitBranch":"main","model":"gpt-4","reviewMode":"workspace"}`, + `{"type":"session_end","duration_seconds":120.5,"files_reviewed":["a.go","b.go"],"llm_failures":1}`) + + writeJSONL(t, filepath.Join(repoDir, "bbb.jsonl"), + `{"type":"session_start","timestamp":"2025-03-02T10:00:00Z","cwd":"/home/user/proj","gitBranch":"feat","model":"claude","reviewMode":"commit","diffCommit":"abc123"}`, + `{"type":"session_end","duration_seconds":60.0,"files_reviewed":["c.go"],"llm_failures":0}`) + + // Non-jsonl file should be skipped + if err := os.WriteFile(filepath.Join(repoDir, "notes.txt"), []byte("ignored"), 0644); err != nil { + t.Fatal(err) + } + + sessions, err := ListSessions(root, "myrepo") + if err != nil { + t.Fatal(err) + } + if len(sessions) != 2 { + t.Fatalf("expected 2 sessions, got %d", len(sessions)) + } + + // Should be sorted newest first + if sessions[0].SessionID != "bbb" { + t.Errorf("expected newest session first, got %q", sessions[0].SessionID) + } + if sessions[0].Model != "claude" { + t.Errorf("Model = %q", sessions[0].Model) + } + if sessions[0].ReviewMode != "commit" { + t.Errorf("ReviewMode = %q", sessions[0].ReviewMode) + } + if sessions[0].DiffCommit != "abc123" { + t.Errorf("DiffCommit = %q", sessions[0].DiffCommit) + } + if sessions[0].DurationSec != 60.0 { + t.Errorf("DurationSec = %f", sessions[0].DurationSec) + } + if sessions[0].FileCount != 1 { + t.Errorf("FileCount = %d", sessions[0].FileCount) + } + + if sessions[1].SessionID != "aaa" { + t.Errorf("second session = %q", sessions[1].SessionID) + } + if sessions[1].LLMFailures != 1 { + t.Errorf("LLMFailures = %d", sessions[1].LLMFailures) + } +} + +func TestPeekSession(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "test.jsonl") + writeJSONL(t, path, + `{"type":"session_start","timestamp":"2025-06-15T14:30:00Z","cwd":"/repo","gitBranch":"dev","model":"gpt-4o","reviewMode":"range","diffFrom":"a1b2","diffTo":"c3d4"}`, + `{"type":"llm_request","filePath":"main.go","taskType":"main_task","request_no":1}`, + `{"type":"llm_response","filePath":"main.go","taskType":"main_task","content":"looks good"}`, + `{"type":"session_end","duration_seconds":45.2,"files_reviewed":["main.go","util.go"],"llm_failures":2}`) + + s, err := peekSession(path) + if err != nil { + t.Fatal(err) + } + + expected := time.Date(2025, 6, 15, 14, 30, 0, 0, time.UTC) + if !s.Timestamp.Equal(expected) { + t.Errorf("Timestamp = %v, want %v", s.Timestamp, expected) + } + if s.CWD != "/repo" { + t.Errorf("CWD = %q", s.CWD) + } + if s.GitBranch != "dev" { + t.Errorf("GitBranch = %q", s.GitBranch) + } + if s.Model != "gpt-4o" { + t.Errorf("Model = %q", s.Model) + } + if s.ReviewMode != "range" { + t.Errorf("ReviewMode = %q", s.ReviewMode) + } + if s.DiffFrom != "a1b2" { + t.Errorf("DiffFrom = %q", s.DiffFrom) + } + if s.DiffTo != "c3d4" { + t.Errorf("DiffTo = %q", s.DiffTo) + } + if s.DurationSec != 45.2 { + t.Errorf("DurationSec = %f", s.DurationSec) + } + if len(s.FilesReviewed) != 2 { + t.Errorf("FilesReviewed = %v", s.FilesReviewed) + } + if s.LLMFailures != 2 { + t.Errorf("LLMFailures = %d", s.LLMFailures) + } + if s.FileCount != 2 { + t.Errorf("FileCount = %d", s.FileCount) + } +} + +func TestPeekSession_MissingFile(t *testing.T) { + _, err := peekSession("/nonexistent/path/session.jsonl") + if err == nil { + t.Error("expected error for missing file") + } +} + +func TestPeekSession_NoSessionEnd(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "partial.jsonl") + writeJSONL(t, path, + `{"type":"session_start","timestamp":"2025-01-01T00:00:00Z","cwd":"/x","model":"m"}`) + + s, err := peekSession(path) + if err != nil { + t.Fatal(err) + } + if s.CWD != "/x" { + t.Errorf("CWD = %q", s.CWD) + } + if s.DurationSec != 0 { + t.Errorf("DurationSec should be 0 without session_end, got %f", s.DurationSec) + } + if s.FileCount != 0 { + t.Errorf("FileCount should be 0 without session_end, got %d", s.FileCount) + } +} From 5bd291739d2ab22a7b20d4e5c393d57a8ff67ac5 Mon Sep 17 00:00:00 2001 From: kite Date: Fri, 26 Jun 2026 23:19:22 +0800 Subject: [PATCH 05/87] chore: remove unused cmd/testdiff debug helper The testdiff CLI was an early-stage tool for manually testing the internal/diff package. It has no external references and its role is fully covered by the existing unit tests in internal/diff/. --- cmd/testdiff/main.go | 193 ------------------------------------------- 1 file changed, 193 deletions(-) delete mode 100644 cmd/testdiff/main.go diff --git a/cmd/testdiff/main.go b/cmd/testdiff/main.go deleted file mode 100644 index a1c3e77..0000000 --- a/cmd/testdiff/main.go +++ /dev/null @@ -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() - } - } -} From b3eb4b3491da62e5957353c7ad35b79452f86de1 Mon Sep 17 00:00:00 2001 From: kite Date: Fri, 26 Jun 2026 23:26:49 +0800 Subject: [PATCH 06/87] test: add unit tests for output_helpers, config, model, stdout, and telemetry packages --- cmd/opencodereview/output_helpers_test.go | 328 ++++++++++++++++++ .../testconnection/testconnection_test.go | 92 +++++ .../config/toolsconfig/toolsconfig_test.go | 102 ++++++ internal/model/model_test.go | 192 ++++++++++ internal/stdout/stdout_test.go | 30 ++ internal/telemetry/config_test.go | 265 ++++++++++++++ internal/telemetry/events_test.go | 50 +++ internal/telemetry/metrics_test.go | 30 ++ internal/telemetry/provider_test.go | 52 +++ internal/telemetry/shutdown_test.go | 71 ++++ internal/telemetry/span_test.go | 43 +++ 11 files changed, 1255 insertions(+) create mode 100644 cmd/opencodereview/output_helpers_test.go create mode 100644 internal/config/testconnection/testconnection_test.go create mode 100644 internal/config/toolsconfig/toolsconfig_test.go create mode 100644 internal/model/model_test.go create mode 100644 internal/stdout/stdout_test.go create mode 100644 internal/telemetry/config_test.go create mode 100644 internal/telemetry/events_test.go create mode 100644 internal/telemetry/metrics_test.go create mode 100644 internal/telemetry/provider_test.go create mode 100644 internal/telemetry/shutdown_test.go create mode 100644 internal/telemetry/span_test.go diff --git a/cmd/opencodereview/output_helpers_test.go b/cmd/opencodereview/output_helpers_test.go new file mode 100644 index 0000000..74d8106 --- /dev/null +++ b/cmd/opencodereview/output_helpers_test.go @@ -0,0 +1,328 @@ +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 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 + json.Unmarshal(buf.Bytes(), &out) + 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}) + + w.Close() + os.Stdout = old + + if err != nil { + t.Fatalf("error: %v", err) + } + + var buf bytes.Buffer + buf.ReadFrom(r) + + var out jsonOutput + json.Unmarshal(buf.Bytes(), &out) + 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) + } +} + +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) + + w.Close() + os.Stdout = old + + if err != nil { + t.Fatalf("error: %v", err) + } + + var buf bytes.Buffer + buf.ReadFrom(r) + + var out jsonOutput + json.Unmarshal(buf.Bytes(), &out) + 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() + + w.Close() + os.Stdout = old + + if err != nil { + t.Fatalf("error: %v", err) + } + + var buf bytes.Buffer + buf.ReadFrom(r) + + var out jsonOutput + json.Unmarshal(buf.Bytes(), &out) + if out.Status != "skipped" { + t.Errorf("status = %q, want skipped", out.Status) + } +} diff --git a/internal/config/testconnection/testconnection_test.go b/internal/config/testconnection/testconnection_test.go new file mode 100644 index 0000000..9af8567 --- /dev/null +++ b/internal/config/testconnection/testconnection_test.go @@ -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) + } +} diff --git a/internal/config/toolsconfig/toolsconfig_test.go b/internal/config/toolsconfig/toolsconfig_test.go new file mode 100644 index 0000000..bd25bb1 --- /dev/null +++ b/internal/config/toolsconfig/toolsconfig_test.go @@ -0,0 +1,102 @@ +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"}} + ]` + os.WriteFile(path, []byte(data), 0644) + + 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") + os.WriteFile(path, []byte("not json"), 0644) + + _, 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") + } + }) + } +} diff --git a/internal/model/model_test.go b/internal/model/model_test.go new file mode 100644 index 0000000..8da62b8 --- /dev/null +++ b/internal/model/model_test.go @@ -0,0 +1,192 @@ +package model + +import ( + "encoding/json" + "testing" +) + +func TestDiff_JSONRoundTrip(t *testing.T) { + d := Diff{ + OldPath: "a.go", + NewPath: "b.go", + Diff: "@@ -1 +1 @@\n-old\n+new", + IsBinary: false, + IsDeleted: false, + IsNew: true, + IsRenamed: true, + Insertions: 5, + Deletions: 3, + } + + data, err := json.Marshal(d) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + var got Diff + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if got != d { + t.Errorf("roundtrip mismatch:\n got %+v\n want %+v", got, d) + } +} + +func TestPreviewEntry_JSONRoundTrip(t *testing.T) { + e := PreviewEntry{ + Path: "main.go", + Status: "modified", + Insertions: 10, + Deletions: 2, + WillReview: true, + ExcludeReason: ExcludeNone, + } + + data, err := json.Marshal(e) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + var got PreviewEntry + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if got != e { + t.Errorf("roundtrip mismatch:\n got %+v\n want %+v", got, e) + } +} + +func TestPreviewEntry_ExcludeReasonOmitEmpty(t *testing.T) { + e := PreviewEntry{ + Path: "a.go", + ExcludeReason: ExcludeNone, + } + data, _ := json.Marshal(e) + var m map[string]any + json.Unmarshal(data, &m) + if _, ok := m["exclude_reason"]; ok { + t.Error("expected exclude_reason to be omitted when empty") + } + + e.ExcludeReason = ExcludeUserRule + data, _ = json.Marshal(e) + json.Unmarshal(data, &m) + if m["exclude_reason"] != "user_exclude" { + t.Errorf("expected exclude_reason=user_exclude, got %v", m["exclude_reason"]) + } +} + +func TestExcludeReasonConstants(t *testing.T) { + constants := map[ExcludeReason]string{ + ExcludeNone: "", + ExcludeUserRule: "user_exclude", + ExcludeExtension: "unsupported_ext", + ExcludeDefaultPath: "default_path", + ExcludeDeleted: "deleted", + ExcludeBinary: "binary", + } + for k, v := range constants { + if string(k) != v { + t.Errorf("ExcludeReason constant mismatch: got %q, want %q", string(k), v) + } + } +} + +func TestScanItem_AsDiff(t *testing.T) { + item := &ScanItem{ + Path: "file.go", + Content: "package main\n", + IsBinary: false, + LineCount: 1, + } + + d := item.AsDiff() + if d == nil { + t.Fatal("expected non-nil Diff") + } + if d.OldPath != "file.go" { + t.Errorf("OldPath = %q, want file.go", d.OldPath) + } + if d.NewPath != "file.go" { + t.Errorf("NewPath = %q, want file.go", d.NewPath) + } + if d.NewFileContent != "package main\n" { + t.Errorf("NewFileContent = %q", d.NewFileContent) + } + if d.IsBinary { + t.Error("expected IsBinary=false") + } + if d.Insertions != 1 { + t.Errorf("Insertions = %d, want 1", d.Insertions) + } +} + +func TestScanItem_AsDiff_Nil(t *testing.T) { + var item *ScanItem + d := item.AsDiff() + if d != nil { + t.Error("expected nil Diff for nil ScanItem") + } +} + +func TestScanItem_AsDiff_Binary(t *testing.T) { + item := &ScanItem{ + Path: "image.png", + IsBinary: true, + } + d := item.AsDiff() + if !d.IsBinary { + t.Error("expected IsBinary=true") + } +} + +func TestLlmComment_JSON(t *testing.T) { + c := LlmComment{ + Path: "main.go", + Content: "fix this", + SuggestionCode: "new code", + ExistingCode: "old code", + StartLine: 10, + EndLine: 15, + Thinking: "reasoning", + } + + data, err := json.Marshal(c) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + var got LlmComment + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if got != c { + t.Errorf("roundtrip mismatch:\n got %+v\n want %+v", got, c) + } +} + +func TestCodeReviewResult_JSON(t *testing.T) { + r := CodeReviewResult{ + RelevantFile: "api.go", + SuggestionContent: "suggestion", + ExistingCode: "old", + SuggestionCode: "new", + } + + data, err := json.Marshal(r) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + var got CodeReviewResult + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if got != r { + t.Errorf("roundtrip mismatch:\n got %+v\n want %+v", got, r) + } +} diff --git a/internal/stdout/stdout_test.go b/internal/stdout/stdout_test.go new file mode 100644 index 0000000..aed675b --- /dev/null +++ b/internal/stdout/stdout_test.go @@ -0,0 +1,30 @@ +package stdout + +import ( + "io" + "os" + "testing" +) + +func TestWriter_Default(t *testing.T) { + w := Writer() + if w != os.Stdout { + t.Error("expected default Writer to be os.Stdout") + } +} + +func TestQuiet(t *testing.T) { + restore := Quiet() + + w := Writer() + if w != io.Discard { + t.Error("expected Writer to be io.Discard after Quiet()") + } + + restore() + + w = Writer() + if w != os.Stdout { + t.Error("expected Writer to be os.Stdout after restore") + } +} diff --git a/internal/telemetry/config_test.go b/internal/telemetry/config_test.go new file mode 100644 index 0000000..4edb8ce --- /dev/null +++ b/internal/telemetry/config_test.go @@ -0,0 +1,265 @@ +package telemetry + +import ( + "os" + "path/filepath" + "testing" +) + +func TestDefaultConfig(t *testing.T) { + cfg := DefaultConfig() + if cfg.Enabled { + t.Error("expected Enabled=false by default") + } + if cfg.ServiceName != "open-code-review" { + t.Errorf("expected ServiceName=open-code-review, got %s", cfg.ServiceName) + } + if cfg.Exporter != "console" { + t.Errorf("expected Exporter=console, got %s", cfg.Exporter) + } + if cfg.OTLPEndpoint != "" { + t.Errorf("expected empty OTLPEndpoint, got %s", cfg.OTLPEndpoint) + } + if cfg.OTLPProtocol != "grpc" { + t.Errorf("expected OTLPProtocol=grpc, got %s", cfg.OTLPProtocol) + } + if cfg.ContentLog { + t.Error("expected ContentLog=false by default") + } +} + +func TestResolveEnv(t *testing.T) { + tests := []struct { + name string + envs map[string]string + check func(t *testing.T, cfg Config) + }{ + { + name: "enable telemetry", + envs: map[string]string{"OCR_ENABLE_TELEMETRY": "1"}, + check: func(t *testing.T, cfg Config) { + if !cfg.Enabled { + t.Error("expected Enabled=true") + } + }, + }, + { + name: "custom service name", + envs: map[string]string{"OTEL_SERVICE_NAME": "my-service"}, + check: func(t *testing.T, cfg Config) { + if cfg.ServiceName != "my-service" { + t.Errorf("expected ServiceName=my-service, got %s", cfg.ServiceName) + } + }, + }, + { + name: "otlp endpoint sets exporter to otlp", + envs: map[string]string{"OTEL_EXPORTER_OTLP_ENDPOINT": "localhost:4317"}, + check: func(t *testing.T, cfg Config) { + if cfg.Exporter != "otlp" { + t.Errorf("expected Exporter=otlp, got %s", cfg.Exporter) + } + if cfg.OTLPEndpoint != "localhost:4317" { + t.Errorf("expected OTLPEndpoint=localhost:4317, got %s", cfg.OTLPEndpoint) + } + }, + }, + { + name: "otlp protocol override", + envs: map[string]string{"OTEL_EXPORTER_OTLP_PROTOCOL": "http/json"}, + check: func(t *testing.T, cfg Config) { + if cfg.OTLPProtocol != "http/json" { + t.Errorf("expected OTLPProtocol=http/json, got %s", cfg.OTLPProtocol) + } + }, + }, + { + name: "content logging enabled", + envs: map[string]string{"OCR_CONTENT_LOGGING": "1"}, + check: func(t *testing.T, cfg Config) { + if !cfg.ContentLog { + t.Error("expected ContentLog=true") + } + }, + }, + { + name: "no env vars leaves defaults", + envs: map[string]string{}, + check: func(t *testing.T, cfg Config) { + if cfg.Enabled { + t.Error("expected Enabled=false") + } + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Clear relevant env vars + envKeys := []string{ + "OCR_ENABLE_TELEMETRY", "OTEL_SERVICE_NAME", + "OTEL_EXPORTER_OTLP_ENDPOINT", "OTEL_EXPORTER_OTLP_PROTOCOL", + "OCR_CONTENT_LOGGING", + } + for _, k := range envKeys { + t.Setenv(k, "") + os.Unsetenv(k) + } + for k, v := range tc.envs { + t.Setenv(k, v) + } + + cfg := DefaultConfig() + resolveEnv(&cfg) + tc.check(t, cfg) + }) + } +} + +func TestLoadFromJSON(t *testing.T) { + t.Run("nonexistent file returns nil error", func(t *testing.T) { + cfg := DefaultConfig() + err := LoadFromJSON(&cfg, "/nonexistent/path/config.json") + if err != nil { + t.Errorf("expected nil error for missing file, got %v", err) + } + }) + + t.Run("malformed json returns nil error", func(t *testing.T) { + tmp := t.TempDir() + path := filepath.Join(tmp, "config.json") + os.WriteFile(path, []byte("{invalid json"), 0644) + + cfg := DefaultConfig() + err := LoadFromJSON(&cfg, path) + if err != nil { + t.Errorf("expected nil error for malformed JSON, got %v", err) + } + }) + + t.Run("no telemetry section", func(t *testing.T) { + tmp := t.TempDir() + path := filepath.Join(tmp, "config.json") + os.WriteFile(path, []byte(`{"other": "value"}`), 0644) + + cfg := DefaultConfig() + err := LoadFromJSON(&cfg, path) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if cfg.Enabled { + t.Error("expected Enabled to remain false") + } + }) + + t.Run("all fields set", func(t *testing.T) { + tmp := t.TempDir() + path := filepath.Join(tmp, "config.json") + data := `{ + "telemetry": { + "enabled": true, + "exporter": "otlp", + "otlp_endpoint": "collector:4317", + "content_logging": true + } + }` + os.WriteFile(path, []byte(data), 0644) + + cfg := DefaultConfig() + err := LoadFromJSON(&cfg, path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !cfg.Enabled { + t.Error("expected Enabled=true") + } + if cfg.Exporter != "otlp" { + t.Errorf("expected Exporter=otlp, got %s", cfg.Exporter) + } + if cfg.OTLPEndpoint != "collector:4317" { + t.Errorf("expected OTLPEndpoint=collector:4317, got %s", cfg.OTLPEndpoint) + } + if !cfg.ContentLog { + t.Error("expected ContentLog=true") + } + }) + + t.Run("otlp_endpoint auto-sets exporter to otlp", func(t *testing.T) { + tmp := t.TempDir() + path := filepath.Join(tmp, "config.json") + data := `{"telemetry": {"otlp_endpoint": "localhost:4317"}}` + os.WriteFile(path, []byte(data), 0644) + + cfg := DefaultConfig() + err := LoadFromJSON(&cfg, path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.Exporter != "otlp" { + t.Errorf("expected Exporter=otlp, got %s", cfg.Exporter) + } + }) + + t.Run("exporter not overridden if already non-default", func(t *testing.T) { + tmp := t.TempDir() + path := filepath.Join(tmp, "config.json") + data := `{"telemetry": {"exporter": "otlp"}}` + os.WriteFile(path, []byte(data), 0644) + + cfg := DefaultConfig() + cfg.Exporter = "custom" + err := LoadFromJSON(&cfg, path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.Exporter != "custom" { + t.Errorf("expected Exporter to remain custom, got %s", cfg.Exporter) + } + }) +} + +func TestResolveConfig(t *testing.T) { + t.Run("empty path uses only env and defaults", func(t *testing.T) { + envKeys := []string{ + "OCR_ENABLE_TELEMETRY", "OTEL_SERVICE_NAME", + "OTEL_EXPORTER_OTLP_ENDPOINT", "OTEL_EXPORTER_OTLP_PROTOCOL", + "OCR_CONTENT_LOGGING", + } + for _, k := range envKeys { + t.Setenv(k, "") + os.Unsetenv(k) + } + + cfg := ResolveConfig("") + if cfg.Enabled { + t.Error("expected disabled with no env") + } + }) + + t.Run("env overrides json", func(t *testing.T) { + tmp := t.TempDir() + path := filepath.Join(tmp, "config.json") + data := `{"telemetry": {"enabled": false}}` + os.WriteFile(path, []byte(data), 0644) + + t.Setenv("OCR_ENABLE_TELEMETRY", "1") + + cfg := ResolveConfig(path) + if !cfg.Enabled { + t.Error("expected env to override json: Enabled should be true") + } + }) +} + +func TestHomeConfigPath(t *testing.T) { + path := HomeConfigPath() + if path == "" { + t.Skip("could not determine home dir") + } + if !filepath.IsAbs(path) { + t.Errorf("expected absolute path, got %s", path) + } + if filepath.Base(path) != "config.json" { + t.Errorf("expected config.json at end, got %s", filepath.Base(path)) + } +} diff --git a/internal/telemetry/events_test.go b/internal/telemetry/events_test.go new file mode 100644 index 0000000..88e8b02 --- /dev/null +++ b/internal/telemetry/events_test.go @@ -0,0 +1,50 @@ +package telemetry + +import ( + "testing" + "time" +) + +func TestFormatDuration(t *testing.T) { + tests := []struct { + dur time.Duration + want string + }{ + {0, "0s"}, + {1500 * time.Millisecond, "1.5s"}, + {60 * time.Second, "1m0s"}, + {123 * time.Millisecond, "123ms"}, + {2*time.Minute + 30*time.Second, "2m30s"}, + } + for _, tc := range tests { + got := FormatDuration(tc.dur) + if got != tc.want { + t.Errorf("FormatDuration(%v) = %q, want %q", tc.dur, got, tc.want) + } + } +} + +func TestSummarizeArgs(t *testing.T) { + tests := []struct { + name string + args map[string]any + want string + }{ + {"nil map", nil, ""}, + {"empty map", map[string]any{}, ""}, + {"path key returns quoted", map[string]any{"path": "foo/bar.go"}, `"foo/bar.go"`}, + {"search key returns quoted", map[string]any{"search": "hello"}, `"hello"`}, + {"query key returns quoted", map[string]any{"query": "world"}, `"world"`}, + {"pattern key returns quoted", map[string]any{"pattern": "*.go"}, `"*.go"`}, + {"generic short value", map[string]any{"foo": "bar"}, "foo=bar"}, + {"long value skipped", map[string]any{"data": string(make([]byte, 60))}, ""}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := summarizeArgs(tc.args) + if got != tc.want { + t.Errorf("summarizeArgs(%v) = %q, want %q", tc.args, got, tc.want) + } + }) + } +} diff --git a/internal/telemetry/metrics_test.go b/internal/telemetry/metrics_test.go new file mode 100644 index 0000000..c678964 --- /dev/null +++ b/internal/telemetry/metrics_test.go @@ -0,0 +1,30 @@ +package telemetry + +import ( + "context" + "fmt" + "testing" + "time" +) + +func TestRecordFunctions_DisabledTelemetry(t *testing.T) { + // Reset state so telemetry is disabled + initialized = false + shutdownFuncs = nil + + ctx := context.Background() + + // These should all be no-ops when telemetry is disabled + RecordReviewDuration(ctx, 5*time.Second) + RecordFilesReviewed(ctx, 10) + RecordCommentsGenerated(ctx, 3) + RecordLLMRequest(ctx, "gpt-4", 2*time.Second, 1000, "ok") + RecordToolCall(ctx, "file_read", 100*time.Millisecond, true) + RecordToolCall(ctx, "file_read", 100*time.Millisecond, false) +} + +func TestCheckMetricErr(t *testing.T) { + // Should not panic + checkMetricErr(nil) + checkMetricErr(fmt.Errorf("some error")) +} diff --git a/internal/telemetry/provider_test.go b/internal/telemetry/provider_test.go new file mode 100644 index 0000000..1454e54 --- /dev/null +++ b/internal/telemetry/provider_test.go @@ -0,0 +1,52 @@ +package telemetry + +import ( + "context" + "testing" +) + +func TestIsEnabled_NotInitialized(t *testing.T) { + // Reset global state + initialized = false + shutdownFuncs = nil + + if IsEnabled() { + t.Error("expected IsEnabled()=false when not initialized") + } +} + +func TestIsEnabled_InitializedButNoShutdowns(t *testing.T) { + initialized = true + shutdownFuncs = nil + defer func() { + initialized = false + }() + + if IsEnabled() { + t.Error("expected IsEnabled()=false when no shutdown funcs registered") + } +} + +func TestIsEnabled_WithShutdowns(t *testing.T) { + initialized = true + shutdownFuncs = []func(context.Context) error{ + func(ctx context.Context) error { return nil }, + } + defer func() { + initialized = false + shutdownFuncs = nil + }() + + if !IsEnabled() { + t.Error("expected IsEnabled()=true when shutdown funcs registered") + } +} + +func TestContentLogging_Disabled(t *testing.T) { + initialized = false + shutdownFuncs = nil + + if ContentLogging() { + t.Error("expected ContentLogging()=false when telemetry disabled") + } +} diff --git a/internal/telemetry/shutdown_test.go b/internal/telemetry/shutdown_test.go new file mode 100644 index 0000000..a56f999 --- /dev/null +++ b/internal/telemetry/shutdown_test.go @@ -0,0 +1,71 @@ +package telemetry + +import ( + "context" + "errors" + "testing" + "time" +) + +func TestShutdown_NoFuncs(t *testing.T) { + shutdownFuncs = nil + err := Shutdown(context.Background()) + if err != nil { + t.Errorf("expected nil error, got %v", err) + } +} + +func TestShutdown_Success(t *testing.T) { + called := false + shutdownFuncs = []func(context.Context) error{ + func(ctx context.Context) error { + called = true + return nil + }, + } + defer func() { shutdownFuncs = nil }() + + err := Shutdown(context.Background()) + if err != nil { + t.Errorf("expected nil error, got %v", err) + } + if !called { + t.Error("expected shutdown function to be called") + } + if shutdownFuncs != nil { + t.Error("expected shutdownFuncs to be cleared after shutdown") + } +} + +func TestShutdown_WithErrors(t *testing.T) { + shutdownFuncs = []func(context.Context) error{ + func(ctx context.Context) error { return nil }, + func(ctx context.Context) error { return errors.New("fail1") }, + func(ctx context.Context) error { return errors.New("fail2") }, + } + defer func() { shutdownFuncs = nil }() + + err := Shutdown(context.Background()) + if err == nil { + t.Fatal("expected error") + } + if shutdownFuncs != nil { + t.Error("expected shutdownFuncs to be cleared even on error") + } +} + +func TestShutdownWithTimeout(t *testing.T) { + called := false + shutdownFuncs = []func(context.Context) error{ + func(ctx context.Context) error { + called = true + return nil + }, + } + defer func() { shutdownFuncs = nil }() + + ShutdownWithTimeout(context.Background(), 5*time.Second) + if !called { + t.Error("expected shutdown function to be called via ShutdownWithTimeout") + } +} diff --git a/internal/telemetry/span_test.go b/internal/telemetry/span_test.go new file mode 100644 index 0000000..033561b --- /dev/null +++ b/internal/telemetry/span_test.go @@ -0,0 +1,43 @@ +package telemetry + +import ( + "fmt" + "testing" + + "go.opentelemetry.io/otel/attribute" +) + +func TestAnyToAttr(t *testing.T) { + tests := []struct { + name string + key string + val interface{} + want attribute.KeyValue + }{ + {"string", "k", "hello", attribute.String("k", "hello")}, + {"int", "k", 42, attribute.Int64("k", 42)}, + {"int64", "k", int64(100), attribute.Int64("k", 100)}, + {"bool", "k", true, attribute.Bool("k", true)}, + {"float64", "k", 3.14, attribute.Float64("k", 3.14)}, + {"default fallback", "k", []int{1, 2}, attribute.String("k", fmt.Sprintf("%v", []int{1, 2}))}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := AnyToAttr(tc.key, tc.val) + if got != tc.want { + t.Errorf("AnyToAttr(%q, %v) = %v, want %v", tc.key, tc.val, got, tc.want) + } + }) + } +} + +func TestSetAttr_NilSpan(t *testing.T) { + // Should not panic + SetAttr(nil, "key", "value") +} + +func TestRecordToolResult_NilSpan(t *testing.T) { + // Should not panic + RecordToolResult(nil, "tool", 100, nil) + RecordToolResult(nil, "tool", 100, fmt.Errorf("err")) +} From 110e5284fb4499e9e1479bb31804c92122cf9c96 Mon Sep 17 00:00:00 2001 From: kite Date: Fri, 26 Jun 2026 23:31:51 +0800 Subject: [PATCH 07/87] fix: stabilize TestDiscoverRepos_FindsRepos with explicit mtime ordering The test relied on filesystem ModTime for sorting repos, but files created in rapid succession can share the same mtime on CI, making the sort order non-deterministic. Use os.Chtimes to guarantee repo-b has a strictly later mtime than repo-a. --- internal/viewer/store_test.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/internal/viewer/store_test.go b/internal/viewer/store_test.go index bb0d89b..574f0a4 100644 --- a/internal/viewer/store_test.go +++ b/internal/viewer/store_test.go @@ -72,6 +72,12 @@ func TestDiscoverRepos_FindsRepos(t *testing.T) { writeJSONL(t, filepath.Join(repoB, "session3.jsonl"), `{"type":"session_start","timestamp":"2025-01-03T10:00:00Z"}`) + // Ensure repo-b's file has a strictly later mtime so sort-by-ModTime is deterministic. + future := time.Now().Add(time.Hour) + if err := os.Chtimes(filepath.Join(repoB, "session3.jsonl"), future, future); err != nil { + t.Fatal(err) + } + repos, err := DiscoverRepos(root) if err != nil { t.Fatal(err) From 453c4f9c7646152369a38f98737cc54edeaa5434 Mon Sep 17 00:00:00 2001 From: kite Date: Sat, 27 Jun 2026 00:15:28 +0800 Subject: [PATCH 08/87] test: add coverage tests for agent, llm, llmloop, and scan packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Raise statement coverage to 80%+ across four core packages to meet FLOSS best practice badge criteria. Key additions: - internal/scan (67% → 90%): getters, lookupDiff, filterScanItems, whyExcluded all branches, extFromPath, maybeRunPlan/ProjectSummary/ Dedup success paths, executeSubtask, Run pipeline, dispatchSubtasks. - internal/agent (60% → 83%): getters, filterDiffs, findDiff, resolveSystemRule, injectDiffMap, executeReviewFilter, executePlanPhase, executeSubtask, dispatchSubtasks. - internal/llmloop (59% → 80%): warnings, tool calls, usage recording, compression lifecycle (cancel/tryApply/run/trigger), partitionMessages. - internal/llm (67% → 80%): parseBpeData, embedded BPE loader, message constructors, ExtractText, ChatResponse helpers, parseShellRC. --- internal/agent/coverage_test.go | 633 ++++++++++++++++++++++++ internal/llm/embedded_loader_test.go | 78 +++ internal/llm/message_test.go | 326 +++++++++++++ internal/llmloop/runner_test.go | 471 ++++++++++++++++++ internal/scan/coverage_test.go | 703 +++++++++++++++++++++++++++ 5 files changed, 2211 insertions(+) create mode 100644 internal/agent/coverage_test.go create mode 100644 internal/llm/embedded_loader_test.go create mode 100644 internal/llm/message_test.go create mode 100644 internal/llmloop/runner_test.go create mode 100644 internal/scan/coverage_test.go diff --git a/internal/agent/coverage_test.go b/internal/agent/coverage_test.go new file mode 100644 index 0000000..f580942 --- /dev/null +++ b/internal/agent/coverage_test.go @@ -0,0 +1,633 @@ +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" + + 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 !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}} + + err := a.executeSubtask(context.Background(), a.diffs[0]) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + 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}} + + err := a.executeSubtask(context.Background(), a.diffs[0]) + if err != nil { + t.Fatalf("executeSubtask: %v", err) + } +} + +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() + + err := a.executeSubtask(ctx, model.Diff{NewPath: "a.go", Diff: "+x", Insertions: 1}) + if err == nil { + t.Fatal("expected error for cancelled context") + } +} + +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) + } +} diff --git a/internal/llm/embedded_loader_test.go b/internal/llm/embedded_loader_test.go new file mode 100644 index 0000000..4ac3dbf --- /dev/null +++ b/internal/llm/embedded_loader_test.go @@ -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() +} diff --git a/internal/llm/message_test.go b/internal/llm/message_test.go new file mode 100644 index 0000000..910233c --- /dev/null +++ b/internal/llm/message_test.go @@ -0,0 +1,326 @@ +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 := "internalanswer" + 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 +` + os.WriteFile(rcPath, []byte(content), 0644) + + 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 +` + os.WriteFile(rcPath, []byte(content), 0644) + + _, 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) + } +} diff --git a/internal/llmloop/runner_test.go b/internal/llmloop/runner_test.go new file mode 100644 index 0000000..6866cfd --- /dev/null +++ b/internal/llmloop/runner_test.go @@ -0,0 +1,471 @@ +package llmloop + +import ( + "context" + "strings" + "testing" + + "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" +) + +type fakeLLMClient struct { + response *llm.ChatResponse + err error +} + +func (f *fakeLLMClient) CompletionsWithCtx(_ context.Context, _ llm.ChatRequest) (*llm.ChatResponse, error) { + return f.response, f.err +} + +func newTestRunner(client llm.LLMClient, tpl template.Template) *Runner { + sess := session.New(t_tempDir, "main", "test-model", session.SessionOptions{ReviewMode: "diff"}) + collector := tool.NewCommentCollector() + return NewRunner(Deps{ + LLMClient: client, + Model: "test-model", + Template: tpl, + CommentCollector: collector, + Session: sess, + }) +} + +var t_tempDir string + +func TestRecordWarning(t *testing.T) { + t_tempDir = t.TempDir() + r := newTestRunner(&fakeLLMClient{}, template.Template{}) + r.RecordWarning("error", "main.go", "something went wrong") + r.RecordWarning("warn", "lib.go", "not great") + + warnings := r.Warnings() + if len(warnings) != 2 { + t.Fatalf("len = %d, want 2", len(warnings)) + } + if warnings[0].Type != "error" || warnings[0].File != "main.go" { + t.Errorf("warning[0] = %+v", warnings[0]) + } + if warnings[1].Message != "not great" { + t.Errorf("warning[1].Message = %q", warnings[1].Message) + } +} + +func TestRecordToolCall(t *testing.T) { + t_tempDir = t.TempDir() + r := newTestRunner(&fakeLLMClient{}, template.Template{}) + r.recordToolCall("file_read") + r.recordToolCall("file_read") + r.recordToolCall("code_comment") + + calls := r.ToolCalls() + if calls["file_read"] != 2 { + t.Errorf("file_read = %d, want 2", calls["file_read"]) + } + if calls["code_comment"] != 1 { + t.Errorf("code_comment = %d, want 1", calls["code_comment"]) + } +} + +func TestRecordUsage(t *testing.T) { + t_tempDir = t.TempDir() + r := newTestRunner(&fakeLLMClient{}, template.Template{}) + + r.RecordUsage(nil) + if r.TotalInputTokens() != 0 { + t.Error("nil usage should not change counters") + } + + r.RecordUsage(&llm.UsageInfo{ + PromptTokens: 100, + CompletionTokens: 50, + CacheReadTokens: 10, + CacheWriteTokens: 5, + }) + if r.TotalInputTokens() != 100 { + t.Errorf("TotalInputTokens = %d, want 100", r.TotalInputTokens()) + } + if r.TotalOutputTokens() != 50 { + t.Errorf("TotalOutputTokens = %d, want 50", r.TotalOutputTokens()) + } + if r.TotalCacheReadTokens() != 10 { + t.Errorf("TotalCacheReadTokens = %d, want 10", r.TotalCacheReadTokens()) + } + if r.TotalCacheWriteTokens() != 5 { + t.Errorf("TotalCacheWriteTokens = %d, want 5", r.TotalCacheWriteTokens()) + } + if r.TotalTokensUsed() != 150 { + t.Errorf("TotalTokensUsed = %d, want 150", r.TotalTokensUsed()) + } +} + +func TestCollectPendingComments_NilPool(t *testing.T) { + t_tempDir = t.TempDir() + collector := tool.NewCommentCollector() + collector.Add(model.LlmComment{Path: "a.go", Content: "fix"}) + + r := NewRunner(Deps{ + CommentCollector: collector, + CommentWorkerPool: nil, + }) + comments := r.CollectPendingComments() + if len(comments) != 1 { + t.Fatalf("expected 1 comment, got %d", len(comments)) + } + if comments[0].Path != "a.go" { + t.Errorf("comment.Path = %q", comments[0].Path) + } +} + +func TestCancelPendingCompression_NilJob(t *testing.T) { + t_tempDir = t.TempDir() + r := newTestRunner(&fakeLLMClient{}, template.Template{}) + r.cancelPendingCompression() +} + +func TestCancelPendingCompression_WithJob(t *testing.T) { + t_tempDir = t.TempDir() + r := newTestRunner(&fakeLLMClient{}, template.Template{}) + + cancelled := false + job := &compressionJob{ + done: make(chan struct{}), + cancel: func() { cancelled = true }, + } + r.pendingJob = job + r.cancelPendingCompression() + + if !cancelled { + t.Error("cancel was not called") + } + if r.pendingJob != nil { + t.Error("pendingJob should be nil after cancel") + } +} + +func TestTryApplyPendingCompression_NilJob(t *testing.T) { + t_tempDir = t.TempDir() + r := newTestRunner(&fakeLLMClient{}, template.Template{}) + msgs := []llm.Message{msg("user", "hi")} + if r.tryApplyPendingCompression(&msgs) { + t.Error("expected false for nil job") + } +} + +func TestTryApplyPendingCompression_NotDone(t *testing.T) { + t_tempDir = t.TempDir() + r := newTestRunner(&fakeLLMClient{}, template.Template{}) + + job := &compressionJob{ + done: make(chan struct{}), + cancel: func() {}, + } + r.pendingJob = job + + msgs := []llm.Message{msg("user", "hi")} + if r.tryApplyPendingCompression(&msgs) { + t.Error("expected false for non-completed job") + } +} + +func TestTryApplyPendingCompression_Applied(t *testing.T) { + t_tempDir = t.TempDir() + r := newTestRunner(&fakeLLMClient{}, template.Template{}) + + rebuilt := []llm.Message{msg("system", "sys"), msg("user", "compressed")} + job := &compressionJob{ + done: make(chan struct{}), + cancel: func() {}, + rebuilt: rebuilt, + snapshotLen: 3, + } + close(job.done) + r.pendingJob = job + + msgs := []llm.Message{ + msg("system", "sys"), + msg("user", "orig"), + msg("assistant", "resp"), + msg("tool", "appended after snapshot"), + } + applied := r.tryApplyPendingCompression(&msgs) + if !applied { + t.Fatal("expected applied=true") + } + if len(msgs) != 3 { + t.Fatalf("len(msgs) = %d, want 3", len(msgs)) + } + if msgs[1].ExtractText() != "compressed" { + t.Errorf("msgs[1] = %q, want compressed", msgs[1].ExtractText()) + } + if msgs[2].ExtractText() != "appended after snapshot" { + t.Errorf("msgs[2] = %q, want appended after snapshot", msgs[2].ExtractText()) + } + if r.pendingJob != nil { + t.Error("pendingJob should be nil after apply") + } +} + +func TestTryApplyPendingCompression_NilRebuilt(t *testing.T) { + t_tempDir = t.TempDir() + r := newTestRunner(&fakeLLMClient{}, template.Template{}) + + job := &compressionJob{ + done: make(chan struct{}), + cancel: func() {}, + rebuilt: nil, + snapshotLen: 3, + } + close(job.done) + r.pendingJob = job + + msgs := []llm.Message{msg("user", "hi")} + applied := r.tryApplyPendingCompression(&msgs) + if applied { + t.Error("expected false when rebuilt is nil (compression failed)") + } + if r.pendingJob != nil { + t.Error("pendingJob should be nil even on non-apply") + } +} + +func TestPartitionMessages_CompressionNeeded(t *testing.T) { + messages := []llm.Message{ + msg("system", "sys"), + msg("user", "prompt"), + } + for i := 0; i < 20; i++ { + messages = append(messages, msg("assistant", strings.Repeat("word ", 200))) + messages = append(messages, msg("tool", strings.Repeat("data ", 100))) + } + + result := partitionMessages(messages, 500, 0) + + if result.frozenEnd != 2 { + t.Errorf("frozenEnd = %d, want 2", result.frozenEnd) + } + if result.activeCount == 0 { + t.Error("activeCount should be > 0 for compression-needed case") + } + if result.compressEnd >= len(messages) { + t.Errorf("compressEnd = %d, should be < %d", result.compressEnd, len(messages)) + } + if result.compressEnd <= result.frozenEnd { + t.Errorf("compressEnd (%d) should be > frozenEnd (%d)", result.compressEnd, result.frozenEnd) + } +} + +func TestRunCompression_EmptyTemplate(t *testing.T) { + t_tempDir = t.TempDir() + r := newTestRunner(&fakeLLMClient{}, template.Template{ + MaxTokens: 1000, + }) + + msgs := []llm.Message{ + msg("system", "sys"), + msg("user", "prompt"), + msg("assistant", "resp"), + } + got, err := r.runCompression(context.Background(), msgs, "test.go") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != 2 { + t.Errorf("expected 2 (frozen only), got %d", len(got)) + } +} + +func TestRunCompression_ShortMessages(t *testing.T) { + t_tempDir = t.TempDir() + tpl := template.Template{ + MemoryCompressionTask: template.LlmConversation{ + Messages: []template.ChatMessage{{Role: "user", Content: "{{context}}"}}, + }, + MaxTokens: 1000, + } + r := newTestRunner(&fakeLLMClient{}, tpl) + + msgs := []llm.Message{msg("system", "sys"), msg("user", "prompt")} + got, err := r.runCompression(context.Background(), msgs, "test.go") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != 2 { + t.Errorf("expected 2, got %d", len(got)) + } +} + +func TestRunCompression_Success(t *testing.T) { + t_tempDir = t.TempDir() + summaryText := "compressed summary" + client := &fakeLLMClient{ + response: &llm.ChatResponse{ + Choices: []llm.Choice{{ + Message: llm.ResponseMessage{Content: &summaryText}, + }}, + Usage: &llm.UsageInfo{PromptTokens: 100, CompletionTokens: 20}, + }, + } + tpl := template.Template{ + MemoryCompressionTask: template.LlmConversation{ + Messages: []template.ChatMessage{{Role: "user", Content: "Summarize: {{context}}"}}, + }, + MaxTokens: 50, + } + r := newTestRunner(client, tpl) + + msgs := []llm.Message{ + msg("system", "sys"), + msg("user", "prompt"), + } + for i := 0; i < 10; i++ { + msgs = append(msgs, msg("assistant", strings.Repeat("word ", 100))) + msgs = append(msgs, msg("tool", strings.Repeat("data ", 50))) + } + + got, err := r.runCompression(context.Background(), msgs, "test.go") + if err != nil { + t.Fatalf("runCompression: %v", err) + } + if len(got) < 2 { + t.Fatalf("expected at least 2 messages, got %d", len(got)) + } + if !strings.Contains(got[1].ExtractText(), "previous_review_summary") { + t.Errorf("expected summary in rebuilt messages, got: %s", got[1].ExtractText()) + } + if r.TotalInputTokens() != 100 { + t.Errorf("TotalInputTokens = %d, want 100", r.TotalInputTokens()) + } +} + +func TestRunCompression_LLMError(t *testing.T) { + t_tempDir = t.TempDir() + client := &fakeLLMClient{ + err: context.DeadlineExceeded, + } + tpl := template.Template{ + MemoryCompressionTask: template.LlmConversation{ + Messages: []template.ChatMessage{{Role: "user", Content: "{{context}}"}}, + }, + MaxTokens: 50, + } + r := newTestRunner(client, tpl) + + msgs := []llm.Message{ + msg("system", "sys"), + msg("user", "prompt"), + } + for i := 0; i < 10; i++ { + msgs = append(msgs, msg("assistant", strings.Repeat("word ", 100))) + msgs = append(msgs, msg("tool", strings.Repeat("data ", 50))) + } + + got, err := r.runCompression(context.Background(), msgs, "test.go") + if err == nil { + t.Fatal("expected error") + } + if len(got) != len(msgs) { + t.Errorf("expected messages unchanged on error, got %d vs %d", len(got), len(msgs)) + } +} + +func TestRunCompression_EmptySummary(t *testing.T) { + t_tempDir = t.TempDir() + emptyStr := "" + client := &fakeLLMClient{ + response: &llm.ChatResponse{ + Choices: []llm.Choice{{ + Message: llm.ResponseMessage{Content: &emptyStr}, + }}, + }, + } + tpl := template.Template{ + MemoryCompressionTask: template.LlmConversation{ + Messages: []template.ChatMessage{{Role: "user", Content: "{{context}}"}}, + }, + MaxTokens: 50, + } + r := newTestRunner(client, tpl) + + msgs := []llm.Message{ + msg("system", "sys"), + msg("user", "prompt"), + } + for i := 0; i < 10; i++ { + msgs = append(msgs, msg("assistant", strings.Repeat("word ", 100))) + msgs = append(msgs, msg("tool", strings.Repeat("data ", 50))) + } + + got, err := r.runCompression(context.Background(), msgs, "test.go") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != len(msgs) { + t.Errorf("expected messages unchanged on empty summary, got %d vs %d", len(got), len(msgs)) + } +} + +func TestTriggerAsyncCompression(t *testing.T) { + t_tempDir = t.TempDir() + summaryText := "async summary" + client := &fakeLLMClient{ + response: &llm.ChatResponse{ + Choices: []llm.Choice{{ + Message: llm.ResponseMessage{Content: &summaryText}, + }}, + Usage: &llm.UsageInfo{PromptTokens: 50, CompletionTokens: 10}, + }, + } + tpl := template.Template{ + MemoryCompressionTask: template.LlmConversation{ + Messages: []template.ChatMessage{{Role: "user", Content: "{{context}}"}}, + }, + MaxTokens: 50, + } + r := newTestRunner(client, tpl) + + msgs := []llm.Message{ + msg("system", "sys"), + msg("user", "prompt"), + } + for i := 0; i < 10; i++ { + msgs = append(msgs, msg("assistant", strings.Repeat("word ", 100))) + msgs = append(msgs, msg("tool", strings.Repeat("data ", 50))) + } + + r.triggerAsyncCompression(context.Background(), msgs, "test.go") + + r.compressionMu.Lock() + job := r.pendingJob + r.compressionMu.Unlock() + + if job == nil { + t.Fatal("expected pendingJob to be set") + } + <-job.done + + if job.rebuilt == nil { + t.Fatal("expected rebuilt to be set after completion") + } +} + +func TestStripMarkdownFences_AdditionalCases(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {"markdown fence", "```markdown\ncontent\n```", "content"}, + {"xml fence", "```xml\n\n```", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := StripMarkdownFences(tt.input) + if got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} diff --git a/internal/scan/coverage_test.go b/internal/scan/coverage_test.go new file mode 100644 index 0000000..9c4a1e2 --- /dev/null +++ b/internal/scan/coverage_test.go @@ -0,0 +1,703 @@ +package scan + +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" +) + +// fakeScanClient is a minimal LLM client for scan tests that returns +// pre-configured responses in sequence. +type fakeScanClient struct { + responses []*llm.ChatResponse + idx int +} + +func (f *fakeScanClient) CompletionsWithCtx(_ context.Context, _ llm.ChatRequest) (*llm.ChatResponse, error) { + if f.idx >= len(f.responses) { + empty := "" + return &llm.ChatResponse{ + Choices: []llm.Choice{{Message: llm.ResponseMessage{Content: &empty}}}, + Usage: &llm.UsageInfo{}, + }, nil + } + resp := f.responses[f.idx] + f.idx++ + return resp, nil +} + +// errorScanClient always returns an error. +type errorScanClient struct { + err error +} + +func (e *errorScanClient) CompletionsWithCtx(_ context.Context, _ llm.ChatRequest) (*llm.ChatResponse, error) { + return nil, e.err +} + +func TestAgent_Getters(t *testing.T) { + tpl := makeTemplateWithFullScan() + a := newAgentForTest(t, tpl) + a.items = []model.ScanItem{ + {Path: "a.go", Content: "package a", LineCount: 1}, + {Path: "b.go", Content: "package b", LineCount: 1}, + } + + if a.ProjectSummary() != "" { + t.Errorf("ProjectSummary() should be empty, got %q", a.ProjectSummary()) + } + if a.Session() == nil { + t.Error("Session() should not be nil") + } + if a.FilesReviewed() != 2 { + t.Errorf("FilesReviewed() = %d, want 2", a.FilesReviewed()) + } + diffs := a.Diffs() + if len(diffs) != 2 { + t.Fatalf("Diffs() len = %d, want 2", len(diffs)) + } + if diffs[0].NewPath != "a.go" || diffs[1].NewPath != "b.go" { + t.Errorf("Diffs paths wrong: %q, %q", diffs[0].NewPath, diffs[1].NewPath) + } + if a.TotalTokensUsed() != 0 { + t.Errorf("TotalTokensUsed() = %d, want 0", a.TotalTokensUsed()) + } + if len(a.ToolCalls()) != 0 { + t.Errorf("ToolCalls() should be empty") + } +} + +func TestLookupDiff(t *testing.T) { + a := newAgentForTest(t, makeTemplateWithFullScan()) + a.items = []model.ScanItem{ + {Path: "main.go", Content: "package main\n", LineCount: 1}, + {Path: "lib.go", Content: "package lib\n", LineCount: 1}, + } + + d := a.lookupDiff("main.go") + if d == nil { + t.Fatal("expected non-nil for existing path") + } + if d.NewPath != "main.go" { + t.Errorf("NewPath = %q, want main.go", d.NewPath) + } + if d.NewFileContent != "package main\n" { + t.Errorf("NewFileContent = %q", d.NewFileContent) + } + + if d2 := a.lookupDiff("nonexist.go"); d2 != nil { + t.Errorf("expected nil for missing path, got %+v", d2) + } +} + +func TestFilterScanItems(t *testing.T) { + a := NewAgent(Args{ + Template: makeTemplateWithFullScan(), + FileFilter: &rules.FileFilter{ + Exclude: []string{"vendor/**"}, + }, + Session: session.New(t.TempDir(), "main", "test", session.SessionOptions{ + ReviewMode: session.ReviewModeFullScan, + }), + }) + + items := []model.ScanItem{ + {Path: "main.go", Content: "package main\n", LineCount: 1}, + {Path: "image.png", Content: "", IsBinary: true}, + {Path: "vendor/dep.go", Content: "package dep\n", LineCount: 1}, + {Path: "handler.go", Content: "package h\n", LineCount: 1}, + } + + kept := a.filterScanItems(items) + if len(kept) != 2 { + t.Fatalf("expected 2 kept, got %d", len(kept)) + } + for _, it := range kept { + if it.Path == "image.png" || it.Path == "vendor/dep.go" { + t.Errorf("should not keep %s", it.Path) + } + } +} + +func TestWhyExcluded_AllBranches(t *testing.T) { + tests := []struct { + name string + item model.ScanItem + filter *rules.FileFilter + want model.ExcludeReason + }{ + { + name: "binary", + item: model.ScanItem{Path: "img.png", IsBinary: true}, + want: model.ExcludeBinary, + }, + { + name: "user exclude", + item: model.ScanItem{Path: "vendor/dep.go", Content: "x"}, + filter: &rules.FileFilter{Exclude: []string{"vendor/**"}}, + want: model.ExcludeUserRule, + }, + { + name: "unsupported extension", + item: model.ScanItem{Path: "data.xyz123"}, + want: model.ExcludeExtension, + }, + { + name: "user include match passes", + item: model.ScanItem{Path: "src/main.go", Content: "x"}, + filter: &rules.FileFilter{Include: []string{"src/**"}}, + want: model.ExcludeNone, + }, + { + name: "default excluded path", + item: model.ScanItem{Path: "pkg/handler_test.go", Content: "x"}, + want: model.ExcludeDefaultPath, + }, + { + name: "allowed file passes", + item: model.ScanItem{Path: "main.go", Content: "x"}, + want: model.ExcludeNone, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a := NewAgent(Args{ + Template: makeTemplateWithFullScan(), + FileFilter: tt.filter, + Session: session.New(t.TempDir(), "main", "test", session.SessionOptions{ + ReviewMode: session.ReviewModeFullScan, + }), + }) + got := a.whyExcluded(tt.item) + if got != tt.want { + t.Errorf("whyExcluded(%q) = %q, want %q", tt.item.Path, got, tt.want) + } + }) + } +} + +func TestExtFromPath(t *testing.T) { + tests := []struct { + path string + want string + }{ + {"main.go", ".go"}, + {"src/lib/utils.ts", ".ts"}, + {"Makefile", ""}, + {".gitignore", ""}, + {"path/to/FILE.Go", ".go"}, + {"a/b/c.Test.JS", ".js"}, + } + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + got := extFromPath(tt.path) + if got != tt.want { + t.Errorf("extFromPath(%q) = %q, want %q", tt.path, got, tt.want) + } + }) + } +} + +func TestMaybeRunPlan_Success(t *testing.T) { + planJSON := `{"summary":"check error handling","checkpoints":[{"focus":"nil check","lines":"10-20","why":"potential NPE"}]}` + client := &fakeScanClient{ + responses: []*llm.ChatResponse{{ + Choices: []llm.Choice{{Message: llm.ResponseMessage{Content: &planJSON}}}, + Usage: &llm.UsageInfo{PromptTokens: 100, CompletionTokens: 50}, + }}, + } + + tpl := makeTemplateWithFullScan() + tpl.PlanTask = &template.LlmConversation{ + Messages: []template.ChatMessage{ + {Role: "user", Content: "Plan for {{current_file_path}}: {{file_content}}"}, + }, + } + + a := NewAgent(Args{ + Template: tpl, + LLMClient: client, + Model: "test", + CommentCollector: tool.NewCommentCollector(), + Tools: tool.NewRegistry(), + Session: session.New(t.TempDir(), "main", "test", session.SessionOptions{ + ReviewMode: session.ReviewModeFullScan, + }), + }) + a.currentDate = "2026-06-26 10:00" + + it := model.ScanItem{Path: "handler.go", Content: "package h\nfunc Handle() {}\n"} + guidance := a.maybeRunPlan(context.Background(), it, "rule-text") + + if !strings.Contains(guidance, "nil check") { + t.Errorf("guidance missing checkpoint, got: %q", guidance) + } + if !strings.Contains(guidance, "check error handling") { + t.Errorf("guidance missing summary, got: %q", guidance) + } + if a.TotalTokensUsed() != 150 { + t.Errorf("TotalTokensUsed() = %d, want 150", a.TotalTokensUsed()) + } +} + +func TestMaybeRunProjectSummary_Success(t *testing.T) { + summaryText := "Overall the code has good error handling but lacks input validation." + client := &fakeScanClient{ + responses: []*llm.ChatResponse{{ + Choices: []llm.Choice{{Message: llm.ResponseMessage{Content: &summaryText}}}, + Usage: &llm.UsageInfo{PromptTokens: 200, CompletionTokens: 80}, + }}, + } + + tpl := makeTemplateWithFullScan() + tpl.ProjectSummaryTask = &template.LlmConversation{ + Messages: []template.ChatMessage{ + {Role: "user", Content: "Summarize {{comment_count}} comments across {{file_count}} files:\n{{all_comments}}"}, + }, + } + + a := NewAgent(Args{ + Template: tpl, + LLMClient: client, + Model: "test", + CommentCollector: tool.NewCommentCollector(), + Tools: tool.NewRegistry(), + Session: session.New(t.TempDir(), "main", "test", session.SessionOptions{ + ReviewMode: session.ReviewModeFullScan, + }), + }) + + comments := []model.LlmComment{ + {Path: "a.go", Content: "missing error check"}, + {Path: "b.go", Content: "no input validation"}, + } + + a.maybeRunProjectSummary(context.Background(), comments) + + if a.ProjectSummary() != summaryText { + t.Errorf("ProjectSummary() = %q, want %q", a.ProjectSummary(), summaryText) + } +} + +func TestMaybeRunProjectSummary_SkipWhenDisabled(t *testing.T) { + a := newAgentForTest(t, makeTemplateWithFullScan()) + a.maybeRunProjectSummary(context.Background(), []model.LlmComment{{Path: "a.go", Content: "x"}}) + if a.ProjectSummary() != "" { + t.Error("summary should be empty when template has no ProjectSummaryTask") + } +} + +func TestMaybeRunProjectSummary_SkipWhenNoComments(t *testing.T) { + tpl := makeTemplateWithFullScan() + tpl.ProjectSummaryTask = &template.LlmConversation{ + Messages: []template.ChatMessage{{Role: "user", Content: "{{all_comments}}"}}, + } + a := NewAgent(Args{ + Template: tpl, + LLMClient: &fakeScanClient{}, + Model: "test", + CommentCollector: tool.NewCommentCollector(), + Tools: tool.NewRegistry(), + Session: session.New(t.TempDir(), "main", "test", session.SessionOptions{ + ReviewMode: session.ReviewModeFullScan, + }), + }) + + a.maybeRunProjectSummary(context.Background(), nil) + if a.ProjectSummary() != "" { + t.Error("summary should be empty when no comments") + } +} + +func TestMaybeRunDedup_Success(t *testing.T) { + dedupResp := `{"groups":[{"members":["c-0","c-1"],"merged_content":"combined finding"},{"members":["c-2"]}]}` + client := &fakeScanClient{ + responses: []*llm.ChatResponse{{ + Choices: []llm.Choice{{Message: llm.ResponseMessage{Content: &dedupResp}}}, + Usage: &llm.UsageInfo{PromptTokens: 80, CompletionTokens: 30}, + }}, + } + + tpl := makeTemplateWithFullScan() + tpl.DedupTask = &template.LlmConversation{ + Messages: []template.ChatMessage{ + {Role: "user", Content: "Dedup: {{batch_comments}}"}, + }, + } + + collector := tool.NewCommentCollector() + collector.Add(model.LlmComment{Path: "a.go", Content: "duplicate finding 1"}) + collector.Add(model.LlmComment{Path: "a.go", Content: "duplicate finding 2"}) + collector.Add(model.LlmComment{Path: "b.go", Content: "unique finding"}) + + a := NewAgent(Args{ + Template: tpl, + LLMClient: client, + Model: "test", + CommentCollector: collector, + Tools: tool.NewRegistry(), + Session: session.New(t.TempDir(), "main", "test", session.SessionOptions{ + ReviewMode: session.ReviewModeFullScan, + }), + }) + + batchStart := 0 + a.maybeRunDedup(context.Background(), 0, batchStart) + + comments := collector.Comments() + if len(comments) != 2 { + t.Fatalf("expected 2 deduped comments, got %d", len(comments)) + } + if comments[0].Content != "combined finding" { + t.Errorf("merged comment content = %q, want 'combined finding'", comments[0].Content) + } + if comments[1].Content != "unique finding" { + t.Errorf("second comment = %q, want 'unique finding'", comments[1].Content) + } +} + +func TestMaybeRunDedup_SkipWhenDisabled(t *testing.T) { + collector := tool.NewCommentCollector() + collector.Add(model.LlmComment{Path: "a.go", Content: "c1"}) + collector.Add(model.LlmComment{Path: "a.go", Content: "c2"}) + collector.Add(model.LlmComment{Path: "a.go", Content: "c3"}) + + a := newAgentForTest(t, makeTemplateWithFullScan()) + a.args.CommentCollector = collector + a.maybeRunDedup(context.Background(), 0, 0) + + if len(collector.Comments()) != 3 { + t.Errorf("comments should be unchanged when dedup is disabled") + } +} + +func TestMaybeRunDedup_SkipWhenTooFewComments(t *testing.T) { + tpl := makeTemplateWithFullScan() + tpl.DedupTask = &template.LlmConversation{ + Messages: []template.ChatMessage{{Role: "user", Content: "{{batch_comments}}"}}, + } + tpl.DedupMinComments = 5 + + collector := tool.NewCommentCollector() + collector.Add(model.LlmComment{Path: "a.go", Content: "only one"}) + + a := NewAgent(Args{ + Template: tpl, + LLMClient: &fakeScanClient{}, + Model: "test", + CommentCollector: collector, + Tools: tool.NewRegistry(), + Session: session.New(t.TempDir(), "main", "test", session.SessionOptions{ + ReviewMode: session.ReviewModeFullScan, + }), + }) + + a.maybeRunDedup(context.Background(), 0, 0) + if len(collector.Comments()) != 1 { + t.Error("comments should be unchanged when below min threshold") + } +} + +func TestExecuteSubtask_Success(t *testing.T) { + doneContent := "" + client := &fakeScanClient{ + responses: []*llm.ChatResponse{{ + 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: 50, CompletionTokens: 20}, + }}, + } + + tpl := makeTemplateWithFullScan() + tpl.MaxTokens = 100000 + + a := NewAgent(Args{ + Template: tpl, + LLMClient: client, + Model: "test", + CommentCollector: tool.NewCommentCollector(), + Tools: tool.NewRegistry(), + SkipPlan: true, + Session: session.New(t.TempDir(), "main", "test", session.SessionOptions{ + ReviewMode: session.ReviewModeFullScan, + }), + }) + a.currentDate = "2026-06-26 10:00" + + it := model.ScanItem{Path: "main.go", Content: "package main\n", LineCount: 1} + err := a.executeSubtask(context.Background(), it) + if err != nil { + t.Fatalf("executeSubtask: %v", err) + } + if a.TotalTokensUsed() != 70 { + t.Errorf("TotalTokensUsed() = %d, want 70", a.TotalTokensUsed()) + } +} + +func TestExecuteSubtask_WithPlan(t *testing.T) { + planJSON := `{"summary":"focus on error paths","checkpoints":[]}` + doneContent := "" + client := &fakeScanClient{ + responses: []*llm.ChatResponse{ + { + Choices: []llm.Choice{{Message: llm.ResponseMessage{Content: &planJSON}}}, + Usage: &llm.UsageInfo{PromptTokens: 30, CompletionTokens: 20}, + }, + { + 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: 60, CompletionTokens: 30}, + }, + }, + } + + tpl := makeTemplateWithFullScan() + tpl.MaxTokens = 100000 + tpl.PlanTask = &template.LlmConversation{ + Messages: []template.ChatMessage{ + {Role: "user", Content: "Plan {{current_file_path}}: {{file_content}}"}, + }, + } + + a := NewAgent(Args{ + Template: tpl, + LLMClient: client, + Model: "test", + CommentCollector: tool.NewCommentCollector(), + Tools: tool.NewRegistry(), + Session: session.New(t.TempDir(), "main", "test", session.SessionOptions{ + ReviewMode: session.ReviewModeFullScan, + }), + }) + a.currentDate = "2026-06-26 10:00" + + it := model.ScanItem{Path: "handler.go", Content: "package h\nfunc Handle() error { return nil }\n", LineCount: 2} + err := a.executeSubtask(context.Background(), it) + if err != nil { + t.Fatalf("executeSubtask: %v", err) + } +} + +func TestExecuteSubtask_ContextCancelled(t *testing.T) { + a := newAgentForTest(t, makeTemplateWithFullScan()) + a.currentDate = "2026-06-26" + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err := a.executeSubtask(ctx, model.ScanItem{Path: "a.go", Content: "x", LineCount: 1}) + if err == nil { + t.Fatal("expected error for cancelled context") + } +} + +func TestRun_EmptyTemplate(t *testing.T) { + a := NewAgent(Args{ + Template: template.ScanTemplate{}, + Session: session.New(t.TempDir(), "main", "test", session.SessionOptions{ + ReviewMode: session.ReviewModeFullScan, + }), + }) + _, err := a.Run(context.Background()) + if err == nil || !strings.Contains(err.Error(), "MAIN_TASK is missing") { + t.Errorf("expected MAIN_TASK error, got: %v", err) + } +} + +func TestRun_NoReviewableFiles(t *testing.T) { + repo := initTestRepo(t) + writeFile(t, repo, "img.png", []byte{0x89, 0x50, 0x4e, 0x47}) + gitCommit(t, repo, "binary") + + a := NewAgent(Args{ + RepoDir: repo, + Template: makeTemplateWithFullScan(), + LLMClient: &fakeScanClient{}, + CommentCollector: tool.NewCommentCollector(), + Tools: tool.NewRegistry(), + SkipPlan: true, + SkipDedup: true, + SkipSummary: true, + Session: session.New(t.TempDir(), "main", "test", session.SessionOptions{ + ReviewMode: session.ReviewModeFullScan, + }), + }) + + comments, err := a.Run(context.Background()) + if err != nil { + t.Fatalf("Run: %v", err) + } + if len(comments) != 0 { + t.Errorf("expected 0 comments for binary-only repo, got %d", len(comments)) + } +} + +func TestRun_FullPipeline(t *testing.T) { + repo := initTestRepo(t) + writeFile(t, repo, "main.go", []byte("package main\nfunc main() {}\n")) + gitCommit(t, repo, "init") + + doneContent := "" + client := &fakeScanClient{ + responses: []*llm.ChatResponse{{ + 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: 100, CompletionTokens: 50}, + }}, + } + + tpl := makeTemplateWithFullScan() + tpl.MaxTokens = 100000 + + a := NewAgent(Args{ + RepoDir: repo, + Template: tpl, + LLMClient: client, + Model: "test", + CommentCollector: tool.NewCommentCollector(), + Tools: tool.NewRegistry(), + MaxConcurrency: 1, + SkipPlan: true, + SkipDedup: true, + SkipSummary: true, + Session: session.New(t.TempDir(), "main", "test", session.SessionOptions{ + ReviewMode: session.ReviewModeFullScan, + }), + }) + + comments, err := a.Run(context.Background()) + if err != nil { + t.Fatalf("Run: %v", err) + } + _ = comments + + if a.FilesReviewed() != 1 { + t.Errorf("FilesReviewed = %d, want 1", a.FilesReviewed()) + } + if a.TotalTokensUsed() == 0 { + t.Error("expected non-zero tokens after run") + } +} + +func TestDispatchSubtasks_AllFailed(t *testing.T) { + client := &errorScanClient{err: context.DeadlineExceeded} + + tpl := makeTemplateWithFullScan() + tpl.MaxTokens = 100000 + + a := NewAgent(Args{ + Template: tpl, + LLMClient: client, + Model: "test", + CommentCollector: tool.NewCommentCollector(), + Tools: tool.NewRegistry(), + MaxConcurrency: 1, + SkipPlan: true, + SkipDedup: true, + SkipSummary: true, + Session: session.New(t.TempDir(), "main", "test", session.SessionOptions{ + ReviewMode: session.ReviewModeFullScan, + }), + }) + a.items = []model.ScanItem{{Path: "a.go", Content: "x", LineCount: 1}} + a.currentDate = "2026-06-26" + a.args.Tools.Freeze() + + _, err := a.dispatchSubtasks(context.Background()) + if err == nil || !strings.Contains(err.Error(), "failed") { + t.Errorf("expected all-failed error, got: %v", err) + } +} + +func TestPhaseEnabled(t *testing.T) { + tpl := makeTemplateWithFullScan() + a := newAgentForTest(t, tpl) + + if a.planEnabled() { + t.Error("planEnabled should be false without PlanTask") + } + if a.dedupEnabled() { + t.Error("dedupEnabled should be false without DedupTask") + } + if a.summaryEnabled() { + t.Error("summaryEnabled should be false without ProjectSummaryTask") + } + + tpl.PlanTask = &template.LlmConversation{ + Messages: []template.ChatMessage{{Role: "user", Content: "plan"}}, + } + tpl.DedupTask = &template.LlmConversation{ + Messages: []template.ChatMessage{{Role: "user", Content: "dedup"}}, + } + tpl.ProjectSummaryTask = &template.LlmConversation{ + Messages: []template.ChatMessage{{Role: "user", Content: "summary"}}, + } + + a2 := NewAgent(Args{ + Template: tpl, + CommentCollector: tool.NewCommentCollector(), + Tools: tool.NewRegistry(), + Session: session.New(t.TempDir(), "main", "test", session.SessionOptions{ + ReviewMode: session.ReviewModeFullScan, + }), + }) + if !a2.planEnabled() { + t.Error("planEnabled should be true with PlanTask") + } + if !a2.dedupEnabled() { + t.Error("dedupEnabled should be true with DedupTask") + } + if !a2.summaryEnabled() { + t.Error("summaryEnabled should be true with ProjectSummaryTask") + } + + a3 := NewAgent(Args{ + Template: tpl, + CommentCollector: tool.NewCommentCollector(), + Tools: tool.NewRegistry(), + SkipPlan: true, + SkipDedup: true, + SkipSummary: true, + Session: session.New(t.TempDir(), "main", "test", session.SessionOptions{ + ReviewMode: session.ReviewModeFullScan, + }), + }) + if a3.planEnabled() { + t.Error("planEnabled should be false with SkipPlan") + } + if a3.dedupEnabled() { + t.Error("dedupEnabled should be false with SkipDedup") + } + if a3.summaryEnabled() { + t.Error("summaryEnabled should be false with SkipSummary") + } +} From 9f71753f6801bd365da0d57758d991b88dee0bf4 Mon Sep 17 00:00:00 2001 From: kite Date: Sat, 27 Jun 2026 00:22:30 +0800 Subject: [PATCH 09/87] test: improve coverage for template and pathutil packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit template: 71.2% → 84.9% — add Validate error-branch tests for both Template and ScanTemplate, ApplyLanguage coverage for optional scan tasks (DedupTask, ProjectSummaryTask), nil-optional-task path, and non-system message skip verification. pathutil: add tests for non-existent path error, relative path resolution, nested symlink traversal, and additional WithinBase edge cases. --- internal/config/template/template_test.go | 167 ++++++++++++++++++++++ internal/pathutil/path_test.go | 82 +++++++++++ 2 files changed, 249 insertions(+) diff --git a/internal/config/template/template_test.go b/internal/config/template/template_test.go index 0de23ec..f2f8727 100644 --- a/internal/config/template/template_test.go +++ b/internal/config/template/template_test.go @@ -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) + } +} diff --git a/internal/pathutil/path_test.go b/internal/pathutil/path_test.go index 1af2b9d..3664e75 100644 --- a/internal/pathutil/path_test.go +++ b/internal/pathutil/path_test.go @@ -53,3 +53,85 @@ func TestCanonicalPathResolvesSymlink(t *testing.T) { t.Fatalf("CanonicalPath(%q) = %q, want %q", linkPath, got, want) } } + +func TestCanonicalPath_NonExistentPath(t *testing.T) { + _, err := CanonicalPath(filepath.Join(t.TempDir(), "does", "not", "exist")) + if err == nil { + t.Fatal("expected error for non-existent path, got nil") + } +} + +func TestCanonicalPath_RelativePath(t *testing.T) { + dir := t.TempDir() + file := filepath.Join(dir, "test.txt") + if err := os.WriteFile(file, []byte("x"), 0o644); err != nil { + t.Fatalf("create file: %v", err) + } + + oldWd, err := os.Getwd() + if err != nil { + t.Fatalf("Getwd: %v", err) + } + t.Cleanup(func() { os.Chdir(oldWd) }) + if err := os.Chdir(dir); err != nil { + t.Fatalf("Chdir: %v", err) + } + + got, err := CanonicalPath("test.txt") + if err != nil { + t.Fatalf("CanonicalPath: %v", err) + } + if !filepath.IsAbs(got) { + t.Errorf("result should be absolute, got %q", got) + } +} + +func TestCanonicalPath_NestedSymlink(t *testing.T) { + realDir := t.TempDir() + realFile := filepath.Join(realDir, "file.txt") + if err := os.WriteFile(realFile, []byte("hello"), 0o644); err != nil { + t.Fatalf("write file: %v", err) + } + + linkDir := t.TempDir() + link1 := filepath.Join(linkDir, "link1") + if err := os.Symlink(realDir, link1); err != nil { + t.Skipf("symlink not supported: %v", err) + } + link2 := filepath.Join(linkDir, "link2") + if err := os.Symlink(link1, link2); err != nil { + t.Skipf("nested symlink not supported: %v", err) + } + + got, err := CanonicalPath(filepath.Join(link2, "file.txt")) + if err != nil { + t.Fatalf("CanonicalPath: %v", err) + } + want, _ := filepath.EvalSymlinks(realFile) + if got != want { + t.Errorf("CanonicalPath through nested symlinks = %q, want %q", got, want) + } +} + +func TestWithinBase_AdditionalCases(t *testing.T) { + cases := []struct { + name string + base string + target string + want bool + }{ + {name: "same path", base: "/a/b", target: "/a/b", want: true}, + {name: "deep child", base: "/a/b", target: "/a/b/c/d/e/f", want: true}, + {name: "double dotdot escape", base: "/a/b/c", target: "/a/b/c/../../x", want: false}, + {name: "dotdot only", base: "/a/b", target: "/a", want: false}, + {name: "root base with child", base: "/", target: "/anything", want: true}, + {name: "empty relative after clean", base: "/a/b", target: "/a/b/./c", want: true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := WithinBase(tc.base, tc.target); got != tc.want { + t.Errorf("WithinBase(%q, %q) = %v, want %v", tc.base, tc.target, got, tc.want) + } + }) + } +} From be4b0b0aa92747787396889a737a20d860e4a3c3 Mon Sep 17 00:00:00 2001 From: kite Date: Sat, 27 Jun 2026 00:34:12 +0800 Subject: [PATCH 10/87] test: improve coverage for viewer package from 35% to 92% Add comprehensive tests for handler, server, and store modules including LoadSession full parsing, template rendering, and error/edge-case paths. --- internal/viewer/handler_test.go | 202 +++++++++++ internal/viewer/server_extra_test.go | 238 +++++++++++++ internal/viewer/store_load_test.go | 487 +++++++++++++++++++++++++++ 3 files changed, 927 insertions(+) create mode 100644 internal/viewer/handler_test.go create mode 100644 internal/viewer/server_extra_test.go create mode 100644 internal/viewer/store_load_test.go diff --git a/internal/viewer/handler_test.go b/internal/viewer/handler_test.go new file mode 100644 index 0000000..3f01296 --- /dev/null +++ b/internal/viewer/handler_test.go @@ -0,0 +1,202 @@ +package viewer + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestHandleRepos_Success(t *testing.T) { + root := t.TempDir() + repoDir := filepath.Join(root, "test-repo") + if err := os.MkdirAll(repoDir, 0755); err != nil { + t.Fatal(err) + } + writeJSONL(t, filepath.Join(repoDir, "s1.jsonl"), + `{"type":"session_start","timestamp":"2025-01-01T10:00:00Z"}`) + + req := httptest.NewRequest("GET", "/", nil) + rr := httptest.NewRecorder() + handleRepos(rr, req, root) + + if rr.Code != http.StatusOK { + t.Errorf("status = %d, want 200", rr.Code) + } + if !strings.Contains(rr.Body.String(), "test-repo") { + t.Errorf("response does not contain repo name") + } +} + +func TestHandleRepos_EmptyRoot(t *testing.T) { + root := t.TempDir() + req := httptest.NewRequest("GET", "/", nil) + rr := httptest.NewRecorder() + handleRepos(rr, req, root) + + if rr.Code != http.StatusOK { + t.Errorf("status = %d, want 200", rr.Code) + } + if !strings.Contains(rr.Body.String(), "No session data found") { + t.Errorf("expected empty-state message in body") + } +} + +func TestHandleRepos_NotFoundForNonRootPath(t *testing.T) { + root := t.TempDir() + req := httptest.NewRequest("GET", "/other", nil) + rr := httptest.NewRecorder() + handleRepos(rr, req, root) + + if rr.Code != http.StatusNotFound { + t.Errorf("status = %d, want 404", rr.Code) + } +} + +func TestHandleRepos_UnreadableRoot(t *testing.T) { + req := httptest.NewRequest("GET", "/", nil) + rr := httptest.NewRecorder() + handleRepos(rr, req, "/nonexistent/definitely/missing/root") + + // DiscoverRepos returns nil for non-existent dirs (treated as empty) + if rr.Code != http.StatusOK { + t.Errorf("status = %d, want 200 (empty)", rr.Code) + } +} + +func TestHandleRepos_PermissionDenied(t *testing.T) { + root := t.TempDir() + // Create a directory that exists but cannot be read + badDir := filepath.Join(root, "unreadable") + if err := os.MkdirAll(badDir, 0755); err != nil { + t.Fatal(err) + } + // Create a .jsonl inside so it's a valid sessions dir + writeJSONL(t, filepath.Join(badDir, "s.jsonl"), `{"type":"session_start"}`) + // Remove read permission on root + if err := os.Chmod(root, 0000); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chmod(root, 0755) }) + + req := httptest.NewRequest("GET", "/", nil) + rr := httptest.NewRecorder() + handleRepos(rr, req, root) + + if rr.Code != http.StatusInternalServerError { + t.Errorf("status = %d, want 500", rr.Code) + } +} + +func TestHandleSessions_Success(t *testing.T) { + root := t.TempDir() + repoDir := filepath.Join(root, "myrepo") + if err := os.MkdirAll(repoDir, 0755); err != nil { + t.Fatal(err) + } + writeJSONL(t, filepath.Join(repoDir, "sess1.jsonl"), + `{"type":"session_start","timestamp":"2025-03-01T09:00:00Z","cwd":"/home/user/project","model":"gpt-4"}`, + `{"type":"session_end","duration_seconds":60,"files_reviewed":["a.go"]}`) + + req := httptest.NewRequest("GET", "/r/myrepo", nil) + rr := httptest.NewRecorder() + handleSessions(rr, req, root, "myrepo") + + if rr.Code != http.StatusOK { + t.Errorf("status = %d, want 200", rr.Code) + } + body := rr.Body.String() + if !strings.Contains(body, "project") { + t.Errorf("response does not contain repo display name derived from CWD") + } +} + +func TestHandleSessions_NoCWD(t *testing.T) { + root := t.TempDir() + repoDir := filepath.Join(root, "repo2") + if err := os.MkdirAll(repoDir, 0755); err != nil { + t.Fatal(err) + } + writeJSONL(t, filepath.Join(repoDir, "s.jsonl"), + `{"type":"session_start","timestamp":"2025-01-01T00:00:00Z","model":"m"}`) + + req := httptest.NewRequest("GET", "/r/repo2", nil) + rr := httptest.NewRecorder() + handleSessions(rr, req, root, "repo2") + + if rr.Code != http.StatusOK { + t.Errorf("status = %d, want 200", rr.Code) + } +} + +func TestHandleSessions_ErrorOnBadDir(t *testing.T) { + root := t.TempDir() + req := httptest.NewRequest("GET", "/r/missing", nil) + rr := httptest.NewRecorder() + handleSessions(rr, req, root, "missing") + + if rr.Code != http.StatusInternalServerError { + t.Errorf("status = %d, want 500", rr.Code) + } +} + +func TestHandleSession_Success(t *testing.T) { + root := t.TempDir() + repoDir := filepath.Join(root, "repo") + if err := os.MkdirAll(repoDir, 0755); err != nil { + t.Fatal(err) + } + writeJSONL(t, filepath.Join(repoDir, "abc123.jsonl"), + `{"type":"session_start","timestamp":"2025-06-01T10:00:00Z","cwd":"/my/proj","model":"claude"}`, + `{"type":"llm_request","filePath":"main.go","taskType":"main_task","request_no":1,"messages":[]}`, + `{"type":"llm_response","filePath":"main.go","taskType":"main_task","content":"LGTM"}`, + `{"type":"session_end","duration_seconds":30,"files_reviewed":["main.go"]}`) + + req := httptest.NewRequest("GET", "/r/repo/abc123", nil) + rr := httptest.NewRecorder() + handleSession(rr, req, root, "repo", "abc123") + + if rr.Code != http.StatusOK { + t.Errorf("status = %d, want 200", rr.Code) + } + if !strings.Contains(rr.Body.String(), "proj") { + t.Errorf("response does not contain derived display name") + } +} + +func TestHandleSession_NotFound(t *testing.T) { + root := t.TempDir() + repoDir := filepath.Join(root, "repo") + if err := os.MkdirAll(repoDir, 0755); err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest("GET", "/r/repo/nonexistent", nil) + rr := httptest.NewRecorder() + handleSession(rr, req, root, "repo", "nonexistent") + + if rr.Code != http.StatusNotFound { + t.Errorf("status = %d, want 404", rr.Code) + } +} + +func TestHandleSession_EmptyCWD(t *testing.T) { + root := t.TempDir() + repoDir := filepath.Join(root, "repo") + if err := os.MkdirAll(repoDir, 0755); err != nil { + t.Fatal(err) + } + writeJSONL(t, filepath.Join(repoDir, "s.jsonl"), + `{"type":"session_start","timestamp":"2025-01-01T00:00:00Z","model":"m"}`, + `{"type":"session_end","duration_seconds":1,"files_reviewed":[]}`) + + req := httptest.NewRequest("GET", "/r/repo/s", nil) + rr := httptest.NewRecorder() + handleSession(rr, req, root, "repo", "s") + + if rr.Code != http.StatusOK { + t.Errorf("status = %d, want 200", rr.Code) + } +} diff --git a/internal/viewer/server_extra_test.go b/internal/viewer/server_extra_test.go new file mode 100644 index 0000000..c4e8b63 --- /dev/null +++ b/internal/viewer/server_extra_test.go @@ -0,0 +1,238 @@ +package viewer + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestParseTemplate_ReposHTML(t *testing.T) { + tmpl, err := parseTemplate("repos.html") + if err != nil { + t.Fatalf("parseTemplate(repos.html) error: %v", err) + } + if tmpl == nil { + t.Fatal("parseTemplate returned nil template") + } +} + +func TestParseTemplate_SessionsHTML(t *testing.T) { + tmpl, err := parseTemplate("sessions.html") + if err != nil { + t.Fatalf("parseTemplate(sessions.html) error: %v", err) + } + if tmpl == nil { + t.Fatal("parseTemplate returned nil template") + } +} + +func TestParseTemplate_SessionHTML(t *testing.T) { + tmpl, err := parseTemplate("session.html") + if err != nil { + t.Fatalf("parseTemplate(session.html) error: %v", err) + } + if tmpl == nil { + t.Fatal("parseTemplate returned nil template") + } +} + +func TestParseTemplate_NonExistent(t *testing.T) { + _, err := parseTemplate("nonexistent.html") + if err == nil { + t.Error("expected error for non-existent template") + } +} + +func TestRenderTemplate_Success(t *testing.T) { + rr := httptest.NewRecorder() + renderTemplate(rr, "repos.html", map[string]any{ + "Repos": []RepoInfo{}, + }) + + if rr.Code != http.StatusOK { + t.Errorf("status = %d, want 200", rr.Code) + } + ct := rr.Header().Get("Content-Type") + if ct != "text/html; charset=utf-8" { + t.Errorf("Content-Type = %q", ct) + } + if !strings.Contains(rr.Body.String(), "No session data found") { + t.Errorf("expected empty repos message in rendered output") + } +} + +func TestRenderTemplate_WithRepos(t *testing.T) { + rr := httptest.NewRecorder() + renderTemplate(rr, "repos.html", map[string]any{ + "Repos": []RepoInfo{ + {EncodedPath: "my-project", SessionCount: 3}, + }, + }) + + if rr.Code != http.StatusOK { + t.Errorf("status = %d, want 200", rr.Code) + } + if !strings.Contains(rr.Body.String(), "my-project") { + t.Errorf("expected repo name in rendered output") + } +} + +func TestRenderTemplate_BadTemplate(t *testing.T) { + rr := httptest.NewRecorder() + renderTemplate(rr, "nonexistent.html", nil) + + if rr.Code != http.StatusInternalServerError { + t.Errorf("status = %d, want 500", rr.Code) + } + if !strings.Contains(rr.Body.String(), "template error") { + t.Errorf("expected template error message") + } +} + +func TestRenderTemplate_Sessions(t *testing.T) { + rr := httptest.NewRecorder() + renderTemplate(rr, "sessions.html", sessionsData{ + EncodedRepo: "test-repo", + RepoName: "MyProject", + Sessions: []SessionSummary{}, + }) + + if rr.Code != http.StatusOK { + t.Errorf("status = %d, want 200", rr.Code) + } + if !strings.Contains(rr.Body.String(), "MyProject") { + t.Errorf("expected repo name in sessions template") + } +} + +func TestRenderTemplate_SessionPage(t *testing.T) { + rr := httptest.NewRecorder() + vs := &ViewSession{ + Summary: SessionSummary{ + SessionID: "abc", + Model: "gpt-4", + CWD: "/test", + }, + Files: []*FileGroup{ + { + FilePath: "main.go", + Tasks: map[TaskType][]*TaskCard{ + MainTask: { + { + RequestNo: 1, + ResponseContent: "looks good", + Model: "gpt-4", + PromptTokens: 100, + CompletionTokens: 50, + }, + }, + }, + }, + }, + } + renderTemplate(rr, "session.html", sessionPageData{ + EncodedRepo: "repo", + RepoName: "MyRepo", + Session: vs, + }) + + if rr.Code != http.StatusOK { + t.Errorf("status = %d, want 200", rr.Code) + } +} + +func TestRenderTemplate_ExecutionError(t *testing.T) { + rr := httptest.NewRecorder() + // Pass wrong data type to trigger template execution error + // repos.html expects .Repos to be rangeable; passing a string causes execution error + renderTemplate(rr, "repos.html", map[string]any{ + "Repos": "not-a-slice", + }) + // Template execution may partially write before failing, so we just check it didn't panic + // and that something was written (the header was set before Execute) + ct := rr.Header().Get("Content-Type") + if ct != "text/html; charset=utf-8" { + t.Errorf("Content-Type = %q", ct) + } +} + +func TestStaticFS(t *testing.T) { + sfs := staticFS() + if sfs == nil { + t.Fatal("staticFS() returned nil") + } + // Should be able to open style.css + f, err := sfs.Open("style.css") + if err != nil { + t.Fatalf("failed to open style.css from staticFS: %v", err) + } + f.Close() +} + +func TestResolveAllowedHostsFromEnv(t *testing.T) { + t.Setenv(EnvAllowedHosts, "custom.host,other.host") + allowed := resolveAllowedHostsFromEnv("192.168.1.5:5483") + + if _, ok := allowed["localhost"]; !ok { + t.Error("missing localhost") + } + if _, ok := allowed["192.168.1.5"]; !ok { + t.Error("missing bind host") + } + if _, ok := allowed["custom.host"]; !ok { + t.Error("missing custom.host from env") + } + if _, ok := allowed["other.host"]; !ok { + t.Error("missing other.host from env") + } +} + +func TestBuildAllowedHosts_BracketedIPv6(t *testing.T) { + a := buildAllowedHosts("[fe80::1]", "") + if _, ok := a["fe80::1"]; !ok { + t.Errorf("bracketed IPv6 bind host not stripped: %v", a) + } +} + +func TestResolveAllowedHostsFromEnv_NoEnv(t *testing.T) { + t.Setenv(EnvAllowedHosts, "") + allowed := resolveAllowedHostsFromEnv(":5483") + + if len(allowed) != 3 { + t.Errorf("expected 3 default hosts, got %d: %v", len(allowed), allowed) + } +} + +func TestTemplateFuncTaskTypeClass(t *testing.T) { + tmpl, err := parseTemplate("session.html") + if err != nil { + t.Fatal(err) + } + + // Verify we can execute with task data that exercises taskTypeClass + rr := httptest.NewRecorder() + vs := &ViewSession{ + Summary: SessionSummary{SessionID: "x", CWD: "/p"}, + Files: []*FileGroup{ + { + FilePath: "f.go", + Tasks: map[TaskType][]*TaskCard{ + PlanTask: {{RequestNo: 1, ResponseContent: "plan"}}, + MainTask: {{RequestNo: 2, ResponseContent: "main"}}, + MemoryCompressionTask: {{RequestNo: 3, ResponseContent: "mem"}}, + ReLocationTask: {{RequestNo: 4, ResponseContent: "reloc"}}, + TaskType("custom"): {{RequestNo: 5, ResponseContent: "custom"}}, + }, + }, + }, + } + err = tmpl.Execute(rr, sessionPageData{ + EncodedRepo: "r", + RepoName: "R", + Session: vs, + }) + if err != nil { + t.Errorf("template execution with all task types: %v", err) + } +} diff --git a/internal/viewer/store_load_test.go b/internal/viewer/store_load_test.go new file mode 100644 index 0000000..45b3ef7 --- /dev/null +++ b/internal/viewer/store_load_test.go @@ -0,0 +1,487 @@ +package viewer + +import ( + "os" + "path/filepath" + "testing" +) + +func TestSessionsRoot(t *testing.T) { + root, err := SessionsRoot() + if err != nil { + t.Fatal(err) + } + home, _ := os.UserHomeDir() + expected := filepath.Join(home, ".opencodereview", "sessions") + if root != expected { + t.Errorf("SessionsRoot() = %q, want %q", root, expected) + } +} + +func TestLoadSession_FullParse(t *testing.T) { + root := t.TempDir() + repoDir := filepath.Join(root, "repo") + if err := os.MkdirAll(repoDir, 0755); err != nil { + t.Fatal(err) + } + + writeJSONL(t, filepath.Join(repoDir, "sess1.jsonl"), + `{"type":"session_start","timestamp":"2025-06-10T08:00:00Z","cwd":"/home/dev/proj","gitBranch":"feat","model":"claude-3","reviewMode":"commit","diffFrom":"aaa","diffTo":"bbb","diffCommit":"ccc"}`, + `{"type":"llm_request","filePath":"main.go","taskType":"main_task","request_no":1,"messages":[{"role":"user","content":"review this"}]}`, + `{"type":"llm_response","filePath":"main.go","taskType":"main_task","content":"Code looks good","duration_ms":1500,"model":"claude-3","usage":{"prompt_tokens":100,"completion_tokens":50,"cache_read_tokens":10,"cache_write_tokens":5},"tool_calls":[{"name":"search","arguments":"query"}]}`, + `{"type":"tool_call","filePath":"main.go","taskType":"main_task","result":"found 3 results","ok":true,"duration_ms":20}`, + `{"type":"llm_request","filePath":"util.go","taskType":"plan_task","request_no":1,"messages":[]}`, + `{"type":"llm_response","filePath":"util.go","taskType":"plan_task","content":"planning","duration_ms":800,"model":"claude-3","usage":{"prompt_tokens":200,"completion_tokens":80,"cache_read_tokens":0,"cache_write_tokens":0}}`, + `{"type":"session_end","duration_seconds":120.5,"files_reviewed":["main.go","util.go"],"llm_failures":1}`, + ) + + vs, err := LoadSession(root, "repo", "sess1") + if err != nil { + t.Fatal(err) + } + + // Check summary + if vs.Summary.SessionID != "sess1" { + t.Errorf("SessionID = %q", vs.Summary.SessionID) + } + if vs.Summary.CWD != "/home/dev/proj" { + t.Errorf("CWD = %q", vs.Summary.CWD) + } + if vs.Summary.GitBranch != "feat" { + t.Errorf("GitBranch = %q", vs.Summary.GitBranch) + } + if vs.Summary.Model != "claude-3" { + t.Errorf("Model = %q", vs.Summary.Model) + } + if vs.Summary.ReviewMode != "commit" { + t.Errorf("ReviewMode = %q", vs.Summary.ReviewMode) + } + if vs.Summary.DiffFrom != "aaa" { + t.Errorf("DiffFrom = %q", vs.Summary.DiffFrom) + } + if vs.Summary.DiffTo != "bbb" { + t.Errorf("DiffTo = %q", vs.Summary.DiffTo) + } + if vs.Summary.DiffCommit != "ccc" { + t.Errorf("DiffCommit = %q", vs.Summary.DiffCommit) + } + if vs.Summary.DurationSec != 120.5 { + t.Errorf("DurationSec = %f", vs.Summary.DurationSec) + } + if vs.Summary.FileCount != 2 { + t.Errorf("FileCount = %d", vs.Summary.FileCount) + } + if vs.Summary.LLMFailures != 1 { + t.Errorf("LLMFailures = %d", vs.Summary.LLMFailures) + } + + // Check files are sorted + if len(vs.Files) != 2 { + t.Fatalf("Files count = %d, want 2", len(vs.Files)) + } + if vs.Files[0].FilePath != "main.go" { + t.Errorf("Files[0] = %q, want main.go", vs.Files[0].FilePath) + } + if vs.Files[1].FilePath != "util.go" { + t.Errorf("Files[1] = %q, want util.go", vs.Files[1].FilePath) + } + + // Check main.go task card + mainCards := vs.Files[0].Tasks[MainTask] + if len(mainCards) != 1 { + t.Fatalf("main.go main_task cards = %d", len(mainCards)) + } + card := mainCards[0] + if card.RequestNo != 1 { + t.Errorf("RequestNo = %d", card.RequestNo) + } + if card.ResponseContent != "Code looks good" { + t.Errorf("ResponseContent = %q", card.ResponseContent) + } + if card.DurationMs != 1500 { + t.Errorf("DurationMs = %d", card.DurationMs) + } + if card.Model != "claude-3" { + t.Errorf("Model = %q", card.Model) + } + if card.PromptTokens != 100 { + t.Errorf("PromptTokens = %d", card.PromptTokens) + } + if card.CompletionTokens != 50 { + t.Errorf("CompletionTokens = %d", card.CompletionTokens) + } + if card.CacheReadTokens != 10 { + t.Errorf("CacheReadTokens = %d", card.CacheReadTokens) + } + if card.CacheWriteTokens != 5 { + t.Errorf("CacheWriteTokens = %d", card.CacheWriteTokens) + } + + // Check tool calls + if len(card.ToolCalls) != 1 { + t.Fatalf("ToolCalls = %d", len(card.ToolCalls)) + } + tc := card.ToolCalls[0] + if tc.Name != "search" { + t.Errorf("ToolCall.Name = %q", tc.Name) + } + if !tc.Ok { + t.Error("ToolCall.Ok = false, want true") + } + if tc.Result != "found 3 results" { + t.Errorf("ToolCall.Result = %q", tc.Result) + } + if tc.DurationMs != 20 { + t.Errorf("ToolCall.DurationMs = %d", tc.DurationMs) + } + + // Check token usage + if vs.TokenUsage.TotalPromptTokens != 300 { + t.Errorf("TotalPromptTokens = %d, want 300", vs.TokenUsage.TotalPromptTokens) + } + if vs.TokenUsage.TotalCompletionTokens != 130 { + t.Errorf("TotalCompletionTokens = %d, want 130", vs.TokenUsage.TotalCompletionTokens) + } + if vs.TokenUsage.TotalCacheReadTokens != 10 { + t.Errorf("TotalCacheReadTokens = %d", vs.TokenUsage.TotalCacheReadTokens) + } + if vs.TokenUsage.TotalCacheWriteTokens != 5 { + t.Errorf("TotalCacheWriteTokens = %d", vs.TokenUsage.TotalCacheWriteTokens) + } + if vs.TokenUsage.RequestCount != 2 { + t.Errorf("RequestCount = %d, want 2", vs.TokenUsage.RequestCount) + } + + // Check file token breakdown + if len(vs.TokenUsage.FileTokenBreakdown) != 2 { + t.Fatalf("FileTokenBreakdown count = %d", len(vs.TokenUsage.FileTokenBreakdown)) + } + // Sorted by total tokens (descending), util.go (200+80=280) > main.go (100+50=150) + if vs.TokenUsage.FileTokenBreakdown[0].FilePath != "util.go" { + t.Errorf("top token file = %q, want util.go", vs.TokenUsage.FileTokenBreakdown[0].FilePath) + } +} + +func TestLoadSession_MissingFile(t *testing.T) { + root := t.TempDir() + repoDir := filepath.Join(root, "repo") + if err := os.MkdirAll(repoDir, 0755); err != nil { + t.Fatal(err) + } + + _, err := LoadSession(root, "repo", "nonexistent") + if err == nil { + t.Error("expected error for missing session file") + } +} + +func TestLoadSession_MalformedLines(t *testing.T) { + root := t.TempDir() + repoDir := filepath.Join(root, "repo") + if err := os.MkdirAll(repoDir, 0755); err != nil { + t.Fatal(err) + } + + writeJSONL(t, filepath.Join(repoDir, "bad.jsonl"), + `not json at all`, + `{"type":"session_start","timestamp":"2025-01-01T00:00:00Z","cwd":"/x","model":"m"}`, + `{broken json`, + `{"type":"session_end","duration_seconds":10,"files_reviewed":[],"llm_failures":0}`, + ) + + vs, err := LoadSession(root, "repo", "bad") + if err != nil { + t.Fatal(err) + } + if vs.Summary.CWD != "/x" { + t.Errorf("CWD = %q, want /x (should skip malformed lines)", vs.Summary.CWD) + } +} + +func TestLoadSession_LLMError(t *testing.T) { + root := t.TempDir() + repoDir := filepath.Join(root, "repo") + if err := os.MkdirAll(repoDir, 0755); err != nil { + t.Fatal(err) + } + + writeJSONL(t, filepath.Join(repoDir, "errs.jsonl"), + `{"type":"session_start","timestamp":"2025-01-01T00:00:00Z","cwd":"/x","model":"m"}`, + `{"type":"llm_request","filePath":"a.go","taskType":"main_task","request_no":1,"messages":[]}`, + `{"type":"llm_error","filePath":"a.go","taskType":"main_task","error":"rate limit exceeded","duration_ms":500}`, + `{"type":"session_end","duration_seconds":5,"files_reviewed":["a.go"],"llm_failures":1}`, + ) + + vs, err := LoadSession(root, "repo", "errs") + if err != nil { + t.Fatal(err) + } + + cards := vs.Files[0].Tasks[MainTask] + if len(cards) != 1 { + t.Fatalf("cards count = %d", len(cards)) + } + if cards[0].Error != "rate limit exceeded" { + t.Errorf("Error = %q", cards[0].Error) + } + if cards[0].DurationMs != 500 { + t.Errorf("DurationMs = %d", cards[0].DurationMs) + } +} + +func TestLoadSession_ToolCallWithOkFalse(t *testing.T) { + root := t.TempDir() + repoDir := filepath.Join(root, "repo") + if err := os.MkdirAll(repoDir, 0755); err != nil { + t.Fatal(err) + } + + writeJSONL(t, filepath.Join(repoDir, "tc.jsonl"), + `{"type":"session_start","timestamp":"2025-01-01T00:00:00Z","cwd":"/x","model":"m"}`, + `{"type":"llm_request","filePath":"a.go","taskType":"main_task","request_no":1,"messages":[]}`, + `{"type":"llm_response","filePath":"a.go","taskType":"main_task","content":"result","duration_ms":100,"model":"m","usage":{"prompt_tokens":10,"completion_tokens":5},"tool_calls":[{"name":"search","arguments":"query"}]}`, + `{"type":"tool_call","filePath":"a.go","taskType":"main_task","result":"error: not found","ok":false,"duration_ms":50}`, + `{"type":"session_end","duration_seconds":1,"files_reviewed":["a.go"]}`, + ) + + vs, err := LoadSession(root, "repo", "tc") + if err != nil { + t.Fatal(err) + } + + cards := vs.Files[0].Tasks[MainTask] + if len(cards) != 1 { + t.Fatalf("cards = %d", len(cards)) + } + if len(cards[0].ToolCalls) != 1 { + t.Fatalf("tool calls = %d", len(cards[0].ToolCalls)) + } + tc := cards[0].ToolCalls[0] + if tc.Ok { + t.Error("expected Ok=false") + } + if tc.Result != "error: not found" { + t.Errorf("Result = %q", tc.Result) + } + if tc.DurationMs != 50 { + t.Errorf("DurationMs = %d", tc.DurationMs) + } +} + +func TestLoadSession_MultipleRequests(t *testing.T) { + root := t.TempDir() + repoDir := filepath.Join(root, "repo") + if err := os.MkdirAll(repoDir, 0755); err != nil { + t.Fatal(err) + } + + writeJSONL(t, filepath.Join(repoDir, "multi.jsonl"), + `{"type":"session_start","timestamp":"2025-01-01T00:00:00Z","cwd":"/x","model":"m"}`, + `{"type":"llm_request","filePath":"a.go","taskType":"main_task","request_no":1,"messages":[]}`, + `{"type":"llm_response","filePath":"a.go","taskType":"main_task","content":"first pass","duration_ms":100,"model":"m","usage":{"prompt_tokens":50,"completion_tokens":20}}`, + `{"type":"llm_request","filePath":"a.go","taskType":"main_task","request_no":2,"messages":[]}`, + `{"type":"llm_response","filePath":"a.go","taskType":"main_task","content":"second pass","duration_ms":200,"model":"m","usage":{"prompt_tokens":60,"completion_tokens":30}}`, + `{"type":"session_end","duration_seconds":10,"files_reviewed":["a.go"]}`, + ) + + vs, err := LoadSession(root, "repo", "multi") + if err != nil { + t.Fatal(err) + } + + cards := vs.Files[0].Tasks[MainTask] + if len(cards) != 2 { + t.Fatalf("cards = %d, want 2", len(cards)) + } + if cards[0].ResponseContent != "first pass" { + t.Errorf("cards[0] content = %q", cards[0].ResponseContent) + } + if cards[1].ResponseContent != "second pass" { + t.Errorf("cards[1] content = %q", cards[1].ResponseContent) + } + if vs.TokenUsage.RequestCount != 2 { + t.Errorf("RequestCount = %d", vs.TokenUsage.RequestCount) + } +} + +func TestLoadSession_EmptyFile(t *testing.T) { + root := t.TempDir() + repoDir := filepath.Join(root, "repo") + if err := os.MkdirAll(repoDir, 0755); err != nil { + t.Fatal(err) + } + writeJSONL(t, filepath.Join(repoDir, "empty.jsonl")) + + vs, err := LoadSession(root, "repo", "empty") + if err != nil { + t.Fatal(err) + } + if len(vs.Files) != 0 { + t.Errorf("Files = %d, want 0", len(vs.Files)) + } +} + +func TestLoadSession_ResponseWithoutRequest(t *testing.T) { + root := t.TempDir() + repoDir := filepath.Join(root, "repo") + if err := os.MkdirAll(repoDir, 0755); err != nil { + t.Fatal(err) + } + + // llm_response for a file that has no prior request - should not panic + writeJSONL(t, filepath.Join(repoDir, "orphan.jsonl"), + `{"type":"session_start","timestamp":"2025-01-01T00:00:00Z","cwd":"/x","model":"m"}`, + `{"type":"llm_response","filePath":"unknown.go","taskType":"main_task","content":"orphan","duration_ms":100,"model":"m"}`, + `{"type":"session_end","duration_seconds":1,"files_reviewed":[]}`, + ) + + vs, err := LoadSession(root, "repo", "orphan") + if err != nil { + t.Fatal(err) + } + // No file groups should be created for orphan responses (no request created the fileIndex entry) + if len(vs.Files) != 0 { + t.Errorf("Files = %d, want 0 (orphan response has no request)", len(vs.Files)) + } +} + +func TestLoadSession_LLMErrorWithoutRequest(t *testing.T) { + root := t.TempDir() + repoDir := filepath.Join(root, "repo") + if err := os.MkdirAll(repoDir, 0755); err != nil { + t.Fatal(err) + } + + writeJSONL(t, filepath.Join(repoDir, "orphanerr.jsonl"), + `{"type":"session_start","timestamp":"2025-01-01T00:00:00Z","cwd":"/x","model":"m"}`, + `{"type":"llm_error","filePath":"unknown.go","taskType":"main_task","error":"fail","duration_ms":10}`, + `{"type":"session_end","duration_seconds":1,"files_reviewed":[]}`, + ) + + // Should not panic + vs, err := LoadSession(root, "repo", "orphanerr") + if err != nil { + t.Fatal(err) + } + if len(vs.Files) != 0 { + t.Errorf("Files = %d", len(vs.Files)) + } +} + +func TestLoadSession_ToolCallWithoutRequest(t *testing.T) { + root := t.TempDir() + repoDir := filepath.Join(root, "repo") + if err := os.MkdirAll(repoDir, 0755); err != nil { + t.Fatal(err) + } + + writeJSONL(t, filepath.Join(repoDir, "orphantc.jsonl"), + `{"type":"session_start","timestamp":"2025-01-01T00:00:00Z","cwd":"/x","model":"m"}`, + `{"type":"tool_call","filePath":"unknown.go","taskType":"main_task","result":"x","ok":true}`, + `{"type":"session_end","duration_seconds":1,"files_reviewed":[]}`, + ) + + // Should not panic + vs, err := LoadSession(root, "repo", "orphantc") + if err != nil { + t.Fatal(err) + } + if len(vs.Files) != 0 { + t.Errorf("Files = %d", len(vs.Files)) + } +} + +func TestDiscoverRepos_SkipsUnreadableSubdir(t *testing.T) { + root := t.TempDir() + badRepo := filepath.Join(root, "unreadable-repo") + if err := os.MkdirAll(badRepo, 0755); err != nil { + t.Fatal(err) + } + writeJSONL(t, filepath.Join(badRepo, "s.jsonl"), `{"type":"session_start"}`) + // Remove read permission so os.ReadDir(repoDir) fails + if err := os.Chmod(badRepo, 0000); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chmod(badRepo, 0755) }) + + repos, err := DiscoverRepos(root) + if err != nil { + t.Fatal(err) + } + // Unreadable repo is skipped (continue) + if len(repos) != 0 { + t.Errorf("repos = %d, want 0 (unreadable dir skipped)", len(repos)) + } +} + +func TestListSessions_SkipsUnreadableFiles(t *testing.T) { + root := t.TempDir() + repoDir := filepath.Join(root, "repo") + if err := os.MkdirAll(repoDir, 0755); err != nil { + t.Fatal(err) + } + + // Create a valid session file + writeJSONL(t, filepath.Join(repoDir, "good.jsonl"), + `{"type":"session_start","timestamp":"2025-01-01T00:00:00Z","cwd":"/x","model":"m"}`) + + // Create an unreadable jsonl file + badPath := filepath.Join(repoDir, "bad.jsonl") + if err := os.WriteFile(badPath, []byte(`{"type":"session_start"}`), 0644); err != nil { + t.Fatal(err) + } + if err := os.Chmod(badPath, 0000); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chmod(badPath, 0644) }) + + sessions, err := ListSessions(root, "repo") + if err != nil { + t.Fatal(err) + } + // Should get 1 session (the good one), bad one is skipped + if len(sessions) != 1 { + t.Errorf("sessions = %d, want 1 (bad file skipped)", len(sessions)) + } +} + +func TestLoadSession_MultipleTaskTypes(t *testing.T) { + root := t.TempDir() + repoDir := filepath.Join(root, "repo") + if err := os.MkdirAll(repoDir, 0755); err != nil { + t.Fatal(err) + } + + writeJSONL(t, filepath.Join(repoDir, "tasks.jsonl"), + `{"type":"session_start","timestamp":"2025-01-01T00:00:00Z","cwd":"/x","model":"m"}`, + `{"type":"llm_request","filePath":"a.go","taskType":"plan_task","request_no":1,"messages":[]}`, + `{"type":"llm_response","filePath":"a.go","taskType":"plan_task","content":"planning","duration_ms":100,"model":"m","usage":{"prompt_tokens":10,"completion_tokens":5}}`, + `{"type":"llm_request","filePath":"a.go","taskType":"main_task","request_no":1,"messages":[]}`, + `{"type":"llm_response","filePath":"a.go","taskType":"main_task","content":"reviewing","duration_ms":200,"model":"m","usage":{"prompt_tokens":20,"completion_tokens":10}}`, + `{"type":"llm_request","filePath":"a.go","taskType":"memory_compression_task","request_no":1,"messages":[]}`, + `{"type":"llm_response","filePath":"a.go","taskType":"memory_compression_task","content":"compressed","duration_ms":50,"model":"m","usage":{"prompt_tokens":5,"completion_tokens":3}}`, + `{"type":"session_end","duration_seconds":5,"files_reviewed":["a.go"]}`, + ) + + vs, err := LoadSession(root, "repo", "tasks") + if err != nil { + t.Fatal(err) + } + + if len(vs.Files) != 1 { + t.Fatalf("Files = %d", len(vs.Files)) + } + fg := vs.Files[0] + if len(fg.Tasks) != 3 { + t.Errorf("task types = %d, want 3", len(fg.Tasks)) + } + if len(fg.Tasks[PlanTask]) != 1 { + t.Errorf("plan_task cards = %d", len(fg.Tasks[PlanTask])) + } + if len(fg.Tasks[MainTask]) != 1 { + t.Errorf("main_task cards = %d", len(fg.Tasks[MainTask])) + } + if len(fg.Tasks[MemoryCompressionTask]) != 1 { + t.Errorf("memory_compression_task cards = %d", len(fg.Tasks[MemoryCompressionTask])) + } +} From 38fc691498d1786e83a26834510a1d02b4faf79f Mon Sep 17 00:00:00 2001 From: kite Date: Sat, 27 Jun 2026 00:42:41 +0800 Subject: [PATCH 11/87] fix: skip permission-based tests when running as root in CI --- internal/viewer/handler_test.go | 3 +++ internal/viewer/store_load_test.go | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/internal/viewer/handler_test.go b/internal/viewer/handler_test.go index 3f01296..c7e9179 100644 --- a/internal/viewer/handler_test.go +++ b/internal/viewer/handler_test.go @@ -67,6 +67,9 @@ func TestHandleRepos_UnreadableRoot(t *testing.T) { } func TestHandleRepos_PermissionDenied(t *testing.T) { + if os.Getuid() == 0 { + t.Skip("permission checks are bypassed for root") + } root := t.TempDir() // Create a directory that exists but cannot be read badDir := filepath.Join(root, "unreadable") diff --git a/internal/viewer/store_load_test.go b/internal/viewer/store_load_test.go index 45b3ef7..3ed4a92 100644 --- a/internal/viewer/store_load_test.go +++ b/internal/viewer/store_load_test.go @@ -392,6 +392,9 @@ func TestLoadSession_ToolCallWithoutRequest(t *testing.T) { } func TestDiscoverRepos_SkipsUnreadableSubdir(t *testing.T) { + if os.Getuid() == 0 { + t.Skip("permission checks are bypassed for root") + } root := t.TempDir() badRepo := filepath.Join(root, "unreadable-repo") if err := os.MkdirAll(badRepo, 0755); err != nil { @@ -415,6 +418,9 @@ func TestDiscoverRepos_SkipsUnreadableSubdir(t *testing.T) { } func TestListSessions_SkipsUnreadableFiles(t *testing.T) { + if os.Getuid() == 0 { + t.Skip("permission checks are bypassed for root") + } root := t.TempDir() repoDir := filepath.Join(root, "repo") if err := os.MkdirAll(repoDir, 0755); err != nil { From 9a11fc1302a3b1ef48db4ab95e65bc9c04df565f Mon Sep 17 00:00:00 2001 From: kite Date: Sat, 27 Jun 2026 01:47:48 +0800 Subject: [PATCH 12/87] test: improve coverage for cmd/opencodereview package from 42% to 70% Add comprehensive unit tests covering config dispatch, emit run result, git helpers, provider commands, provider TUI pure functions and View rendering, shared utilities, flags parsing, scan command flags, and small file entry points (version, viewer, llm, rules commands). --- cmd/opencodereview/config_cmd_test.go | 374 ++++++ cmd/opencodereview/config_dispatch_test.go | 30 + cmd/opencodereview/emit_run_result_test.go | 177 +++ cmd/opencodereview/flags_test.go | 176 ++- cmd/opencodereview/git_test.go | 153 +++ cmd/opencodereview/output_helpers_test.go | 234 ++++ cmd/opencodereview/provider_cmd_test.go | 205 +++ cmd/opencodereview/provider_tui_funcs_test.go | 1160 +++++++++++++++++ cmd/opencodereview/scan_cmd_test.go | 107 ++ cmd/opencodereview/shared_test.go | 127 ++ cmd/opencodereview/smallfiles_test.go | 248 ++++ 11 files changed, 2990 insertions(+), 1 deletion(-) create mode 100644 cmd/opencodereview/config_dispatch_test.go create mode 100644 cmd/opencodereview/emit_run_result_test.go create mode 100644 cmd/opencodereview/git_test.go create mode 100644 cmd/opencodereview/provider_cmd_test.go create mode 100644 cmd/opencodereview/provider_tui_funcs_test.go create mode 100644 cmd/opencodereview/shared_test.go create mode 100644 cmd/opencodereview/smallfiles_test.go diff --git a/cmd/opencodereview/config_cmd_test.go b/cmd/opencodereview/config_cmd_test.go index 88f1f79..af1e43a 100644 --- a/cmd/opencodereview/config_cmd_test.go +++ b/cmd/opencodereview/config_cmd_test.go @@ -1,6 +1,7 @@ package main import ( + "os" "testing" ) @@ -417,6 +418,379 @@ 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 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"} diff --git a/cmd/opencodereview/config_dispatch_test.go b/cmd/opencodereview/config_dispatch_test.go new file mode 100644 index 0000000..87f02b7 --- /dev/null +++ b/cmd/opencodereview/config_dispatch_test.go @@ -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") + } +} diff --git a/cmd/opencodereview/emit_run_result_test.go b/cmd/opencodereview/emit_run_result_test.go new file mode 100644 index 0000000..4082ca2 --- /dev/null +++ b/cmd/opencodereview/emit_run_result_test.go @@ -0,0 +1,177 @@ +package main + +import ( + "context" + "encoding/json" + "strings" + "testing" + "time" + + "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 +} + +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 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_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_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 +} diff --git a/cmd/opencodereview/flags_test.go b/cmd/opencodereview/flags_test.go index 617dfa4..15701ca 100644 --- a/cmd/opencodereview/flags_test.go +++ b/cmd/opencodereview/flags_test.go @@ -1,6 +1,9 @@ package main -import "testing" +import ( + "testing" + "time" +) func TestParseReviewFlagsModelOverride(t *testing.T) { opts, err := parseReviewFlags([]string{"--model", "claude-opus-4-6"}) @@ -18,3 +21,174 @@ func TestParseReviewFlagsModelOverride(t *testing.T) { t.Errorf("audience = %q, want %q", opts.audience, "human") } } + +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]) + } + } + }) + } +} diff --git a/cmd/opencodereview/git_test.go b/cmd/opencodereview/git_test.go new file mode 100644 index 0000000..0630a4c --- /dev/null +++ b/cmd/opencodereview/git_test.go @@ -0,0 +1,153 @@ +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") + os.WriteFile(f, []byte("hello"), 0o644) + cmd := exec.Command("git", "-C", dir, "add", ".") + cmd.CombinedOutput() + cmd = exec.Command("git", "-C", dir, "commit", "-m", "initial commit") + cmd.CombinedOutput() + 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, _ := os.Getwd() + defer os.Chdir(origDir) + os.Chdir(dir) + + 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") + } +} diff --git a/cmd/opencodereview/output_helpers_test.go b/cmd/opencodereview/output_helpers_test.go index 74d8106..d0d25b2 100644 --- a/cmd/opencodereview/output_helpers_test.go +++ b/cmd/opencodereview/output_helpers_test.go @@ -162,6 +162,34 @@ func TestBuildDiffLines(t *testing.T) { }) } +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) + + w.Close() + os.Stdout = old + + if err != nil { + t.Fatalf("error: %v", err) + } + + var buf bytes.Buffer + buf.ReadFrom(r) + + var out jsonOutput + json.Unmarshal(buf.Bytes(), &out) + if out.Status != "completed_with_errors" { + t.Errorf("status = %q, want completed_with_errors", out.Status) + } + if !strings.Contains(out.Message, "errors") { + t.Errorf("message = %q, expected to mention errors", out.Message) + } +} + func TestStatusBadge(t *testing.T) { tests := []struct { status string @@ -326,3 +354,209 @@ func TestOutputJSONNoFiles(t *testing.T) { t.Errorf("status = %q, want skipped", out.Status) } } + +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) + } +} diff --git a/cmd/opencodereview/provider_cmd_test.go b/cmd/opencodereview/provider_cmd_test.go new file mode 100644 index 0000000..54dc906 --- /dev/null +++ b/cmd/opencodereview/provider_cmd_test.go @@ -0,0 +1,205 @@ +package main + +import ( + "encoding/json" + "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") + } +} diff --git a/cmd/opencodereview/provider_tui_funcs_test.go b/cmd/opencodereview/provider_tui_funcs_test.go new file mode 100644 index 0000000..a5f0001 --- /dev/null +++ b/cmd/opencodereview/provider_tui_funcs_test.go @@ -0,0 +1,1160 @@ +package main + +import ( + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + + "github.com/open-code-review/open-code-review/internal/llm" +) + +func TestCustomProviderActiveModel_NilCfg(t *testing.T) { + m := providerTUIModel{existingCfg: nil} + cp := customProviderListItem{name: "test", entry: ProviderEntry{Model: "m1"}} + got := m.customProviderActiveModel(cp) + if got != "" { + t.Errorf("expected empty string for nil cfg, got %q", got) + } +} + +func TestCustomProviderActiveModel_DifferentProvider(t *testing.T) { + cfg := &Config{Provider: "other-provider"} + m := newProviderTUI(cfg, "") + cp := customProviderListItem{name: "test", entry: ProviderEntry{Model: "m1"}} + got := m.customProviderActiveModel(cp) + if got != "" { + t.Errorf("expected empty string for different provider, got %q", got) + } +} + +func TestCustomProviderActiveModel_MatchingProvider(t *testing.T) { + cfg := &Config{ + Provider: "my-custom", + Model: "gpt-4", + CustomProviders: map[string]ProviderEntry{ + "my-custom": {URL: "http://localhost", Model: "gpt-4"}, + }, + } + m := newProviderTUI(cfg, "") + cp := customProviderListItem{name: "my-custom", entry: ProviderEntry{URL: "http://localhost"}} + got := m.customProviderActiveModel(cp) + if got != "gpt-4" { + t.Errorf("expected gpt-4, got %q", got) + } +} + +func TestModelProviderName_OfficialTab(t *testing.T) { + m := newProviderTUI(&Config{}, "") + name := m.modelProviderName() + if name == "" { + t.Error("expected non-empty provider name") + } + providers := llm.ListProviders() + if len(providers) == 0 { + t.Skip("no providers registered") + } +} + +func TestModelProviderName_CustomTab(t *testing.T) { + cfg := &Config{ + CustomProviders: map[string]ProviderEntry{ + "my-llm": {URL: "http://localhost", Model: "m"}, + }, + } + m := newProviderTUI(cfg, "") + m.activeTab = tabCustom + m.customIdx = 0 + name := m.modelProviderName() + if !strings.Contains(name, "(custom)") { + t.Errorf("expected '(custom)' in name, got %q", name) + } +} + +func TestModelProviderName_CustomTab_NoSelection(t *testing.T) { + m := newProviderTUI(&Config{}, "") + m.activeTab = tabCustom + m.customIdx = 999 + name := m.modelProviderName() + if name != "" { + t.Errorf("expected empty fallback for out-of-bounds custom, got %q", name) + } +} + +func TestModelCount(t *testing.T) { + m := newProviderTUI(&Config{}, "") + count := m.modelCount() + models := m.models() + if count != len(models)+1 { + t.Errorf("modelCount() = %d, want %d", count, len(models)+1) + } +} + +func TestInit_ReturnsNil(t *testing.T) { + m := newProviderTUI(&Config{}, "") + cmd := m.Init() + if cmd != nil { + t.Error("Init() should return nil") + } +} + +func TestListCursorPrefix_Active(t *testing.T) { + got := listCursorPrefix(true) + if !strings.Contains(got, tuiCursor) { + t.Errorf("expected cursor in prefix, got %q", got) + } +} + +func TestListCursorPrefix_Inactive(t *testing.T) { + got := listCursorPrefix(false) + if strings.Contains(got, tuiCursor) { + t.Errorf("expected no cursor in prefix, got %q", got) + } +} + +func TestRenderListName_Active(t *testing.T) { + got := renderListName("my-provider", true) + if !strings.Contains(got, "my-provider") { + t.Errorf("expected name in output, got %q", got) + } +} + +func TestRenderListName_Inactive(t *testing.T) { + got := renderListName("my-provider", false) + if !strings.Contains(got, "my-provider") { + t.Errorf("expected name in output, got %q", got) + } +} + +func TestCloneProviderEntry_WithExtraBody(t *testing.T) { + orig := ProviderEntry{ + APIKey: "key", + URL: "http://localhost", + Protocol: "openai", + Model: "gpt-4", + Models: []string{"gpt-4", "gpt-3.5"}, + AuthHeader: "Authorization", + ExtraBody: map[string]any{"temperature": 0.7, "stream": true}, + } + clone := cloneProviderEntry(orig) + + if clone.APIKey != orig.APIKey || clone.URL != orig.URL || clone.Protocol != orig.Protocol { + t.Error("basic fields not copied") + } + if len(clone.Models) != 2 || clone.Models[0] != "gpt-4" { + t.Errorf("Models not cloned: %v", clone.Models) + } + if clone.ExtraBody == nil { + t.Fatal("ExtraBody should not be nil") + } + if clone.ExtraBody["temperature"] != 0.7 { + t.Errorf("ExtraBody[temperature] = %v", clone.ExtraBody["temperature"]) + } + + clone.ExtraBody["new_key"] = "value" + if _, ok := orig.ExtraBody["new_key"]; ok { + t.Error("modifying clone should not affect original ExtraBody") + } + + clone.Models = append(clone.Models, "gpt-5") + if len(orig.Models) != 2 { + t.Error("modifying clone should not affect original Models") + } +} + +func TestCloneProviderEntry_NilExtraBody(t *testing.T) { + orig := ProviderEntry{ + APIKey: "key", + URL: "http://localhost", + } + clone := cloneProviderEntry(orig) + if clone.ExtraBody != nil { + t.Error("ExtraBody should remain nil") + } +} + +func TestCustomListCount(t *testing.T) { + cfg := &Config{ + CustomProviders: map[string]ProviderEntry{ + "a": {URL: "http://a"}, + "b": {URL: "http://b"}, + }, + } + m := newProviderTUI(cfg, "") + got := m.customListCount() + if got != len(m.customProviders)+1 { + t.Errorf("customListCount() = %d, want %d", got, len(m.customProviders)+1) + } +} + +func TestIsCustomModelItem(t *testing.T) { + m := newProviderTUI(&Config{}, "") + models := m.models() + if m.isCustomModelItem(len(models)) != true { + t.Error("expected true for custom model item index") + } + if len(models) > 0 && m.isCustomModelItem(0) { + t.Error("expected false for non-custom model index") + } +} + +func upKey() tea.KeyPressMsg { + return tea.KeyPressMsg{Code: tea.KeyUp} +} + +func TestHandleUp_OfficialTab(t *testing.T) { + m := newProviderTUI(&Config{}, "") + if len(m.providers) < 2 { + t.Skip("need at least 2 providers") + } + result, _ := m.Update(downKey()) + m2 := result.(providerTUIModel) + if m2.officialIdx != 1 { + t.Fatalf("after down, officialIdx = %d, want 1", m2.officialIdx) + } + result, _ = m2.Update(upKey()) + m3 := result.(providerTUIModel) + if m3.officialIdx != 0 { + t.Errorf("after up, officialIdx = %d, want 0", m3.officialIdx) + } +} + +func TestHandleUp_Wraps(t *testing.T) { + m := newProviderTUI(&Config{}, "") + if len(m.providers) == 0 { + t.Skip("no providers") + } + result, _ := m.Update(upKey()) + m2 := result.(providerTUIModel) + if m2.officialIdx != len(m2.providers)-1 { + t.Errorf("up from 0 should wrap to %d, got %d", len(m2.providers)-1, m2.officialIdx) + } +} + +func TestHandleUp_CustomTab(t *testing.T) { + cfg := &Config{ + CustomProviders: map[string]ProviderEntry{ + "a": {URL: "http://a"}, + "b": {URL: "http://b"}, + }, + } + m := newProviderTUI(cfg, "") + m.activeTab = tabCustom + m.customIdx = 1 + result, _ := m.Update(upKey()) + m2 := result.(providerTUIModel) + if m2.customIdx != 0 { + t.Errorf("customIdx = %d, want 0", m2.customIdx) + } +} + +func TestHandleUp_ModelStep(t *testing.T) { + m := newProviderTUI(&Config{}, "") + m.step = stepModel + m.modelIdx = 1 + result, _ := m.Update(upKey()) + m2 := result.(providerTUIModel) + if m2.modelIdx != 0 { + t.Errorf("modelIdx = %d, want 0", m2.modelIdx) + } +} + +func TestHandleUp_ModelStepWraps(t *testing.T) { + m := newProviderTUI(&Config{}, "") + m.step = stepModel + m.modelIdx = 0 + result, _ := m.Update(upKey()) + m2 := result.(providerTUIModel) + expected := m2.modelCount() - 1 + if m2.modelIdx != expected { + t.Errorf("modelIdx = %d, want %d", m2.modelIdx, expected) + } +} + +func TestBlurCPStep_AllSteps(t *testing.T) { + m := newProviderTUI(&Config{}, "") + for _, step := range []customProviderStep{cpStepName, cpStepBaseURL, cpStepAPIKey, cpStepAuthHeader, cpStepProtocol} { + m.cpStep = step + m.blurCPStep() + } +} + +func TestFocusCPStep_AllSteps(t *testing.T) { + m := newProviderTUI(&Config{}, "") + for _, step := range []customProviderStep{cpStepName, cpStepBaseURL, cpStepAPIKey, cpStepAuthHeader, cpStepProtocol} { + m.cpStep = step + m.focusCPStep() + } +} + +func TestCollectCustomProviders_Nil(t *testing.T) { + got := collectCustomProviders(nil) + if got != nil { + t.Errorf("expected nil, got %v", got) + } +} + +func TestCollectCustomProviders_NilMap(t *testing.T) { + got := collectCustomProviders(&Config{}) + if got != nil { + t.Errorf("expected nil, got %v", got) + } +} + +func TestCollectCustomProviders_Sorted(t *testing.T) { + cfg := &Config{ + CustomProviders: map[string]ProviderEntry{ + "zebra": {URL: "http://z"}, + "alpha": {URL: "http://a"}, + "mid": {URL: "http://m"}, + }, + } + got := collectCustomProviders(cfg) + if len(got) != 3 { + t.Fatalf("expected 3 items, got %d", len(got)) + } + if got[0].name != "alpha" || got[1].name != "mid" || got[2].name != "zebra" { + t.Errorf("not sorted: %v, %v, %v", got[0].name, got[1].name, got[2].name) + } +} + +func TestCustomProviderNameTaken(t *testing.T) { + m := providerTUIModel{existingCfg: nil} + if m.customProviderNameTaken("test") { + t.Error("nil cfg should return false") + } + + m2 := newProviderTUI(&Config{}, "") + if m2.customProviderNameTaken("test") { + t.Error("nil CustomProviders should return false") + } + + m3 := newProviderTUI(&Config{ + CustomProviders: map[string]ProviderEntry{"test": {URL: "http://test"}}, + }, "") + if !m3.customProviderNameTaken("test") { + t.Error("existing name should return true") + } + if m3.customProviderNameTaken("other") { + t.Error("non-existing name should return false") + } +} + +func TestCurrentProvider_OutOfBounds(t *testing.T) { + m := newProviderTUI(&Config{}, "") + m.officialIdx = 9999 + p := m.currentProvider() + if p.Name != "" { + t.Errorf("expected empty provider, got %q", p.Name) + } +} + +func TestCurrentProvider_WrongTab(t *testing.T) { + m := newProviderTUI(&Config{}, "") + m.activeTab = tabCustom + p := m.currentProvider() + if p.Name != "" { + t.Errorf("expected empty provider for non-official tab, got %q", p.Name) + } +} + +func TestSelectedCustomProvider_NotCustomTab(t *testing.T) { + m := newProviderTUI(&Config{}, "") + _, ok := m.selectedCustomProvider() + if ok { + t.Error("expected false for non-custom tab") + } +} + +func TestSelectedCustomProvider_OutOfBounds(t *testing.T) { + m := newProviderTUI(&Config{}, "") + m.activeTab = tabCustom + m.customIdx = 9999 + _, ok := m.selectedCustomProvider() + if ok { + t.Error("expected false for out-of-bounds index") + } +} + +func TestWindowSizeMsg(t *testing.T) { + m := newProviderTUI(&Config{}, "") + result, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + m2 := result.(providerTUIModel) + if m2.width != 120 || m2.height != 40 { + t.Errorf("size = %dx%d, want 120x40", m2.width, m2.height) + } +} + +func TestBlurManualStep_AllSteps(t *testing.T) { + m := newProviderTUI(&Config{}, "") + for _, step := range []manualStep{manualStepURL, manualStepProtocol, manualStepModel, manualStepAuthToken, manualStepAuthHeader} { + m.manualStep = step + m.blurManualStep() + } +} + +func TestFocusManualStep_AllSteps(t *testing.T) { + m := newProviderTUI(&Config{}, "") + for _, step := range []manualStep{manualStepURL, manualStepProtocol, manualStepModel, manualStepAuthToken, manualStepAuthHeader} { + m.manualStep = step + m.focusManualStep() + } +} + +func TestHandleDown_OfficialTab(t *testing.T) { + m := newProviderTUI(&Config{}, "") + if len(m.providers) < 2 { + t.Skip("need at least 2 providers") + } + result, _ := m.Update(downKey()) + m2 := result.(providerTUIModel) + if m2.officialIdx != 1 { + t.Errorf("officialIdx = %d, want 1", m2.officialIdx) + } +} + +func TestHandleDown_OfficialTab_Wraps(t *testing.T) { + m := newProviderTUI(&Config{}, "") + if len(m.providers) == 0 { + t.Skip("no providers") + } + m.officialIdx = len(m.providers) - 1 + result, _ := m.Update(downKey()) + m2 := result.(providerTUIModel) + if m2.officialIdx != 0 { + t.Errorf("down from last should wrap to 0, got %d", m2.officialIdx) + } +} + +func TestHandleDown_CustomTab(t *testing.T) { + cfg := &Config{ + CustomProviders: map[string]ProviderEntry{ + "a": {URL: "http://a"}, + "b": {URL: "http://b"}, + }, + } + m := newProviderTUI(cfg, "") + m.activeTab = tabCustom + m.customIdx = 0 + result, _ := m.Update(downKey()) + m2 := result.(providerTUIModel) + if m2.customIdx != 1 { + t.Errorf("customIdx = %d, want 1", m2.customIdx) + } +} + +func TestHandleDown_CustomTab_Wraps(t *testing.T) { + cfg := &Config{ + CustomProviders: map[string]ProviderEntry{ + "a": {URL: "http://a"}, + }, + } + m := newProviderTUI(cfg, "") + m.activeTab = tabCustom + m.customIdx = m.customListCount() - 1 + result, _ := m.Update(downKey()) + m2 := result.(providerTUIModel) + if m2.customIdx != 0 { + t.Errorf("down from last custom should wrap to 0, got %d", m2.customIdx) + } +} + +func TestHandleDown_ModelStep(t *testing.T) { + m := newProviderTUI(&Config{}, "") + m.step = stepModel + m.modelIdx = 0 + result, _ := m.Update(downKey()) + m2 := result.(providerTUIModel) + if m2.modelIdx != 1 { + t.Errorf("modelIdx = %d, want 1", m2.modelIdx) + } +} + +func TestHandleDown_ModelStep_Wraps(t *testing.T) { + m := newProviderTUI(&Config{}, "") + m.step = stepModel + m.modelIdx = m.modelCount() - 1 + result, _ := m.Update(downKey()) + m2 := result.(providerTUIModel) + if m2.modelIdx != 0 { + t.Errorf("modelIdx = %d, want 0", m2.modelIdx) + } +} + +func TestCloneCustomProvidersMap(t *testing.T) { + src := map[string]ProviderEntry{ + "a": {URL: "http://a", ExtraBody: map[string]any{"k": "v"}}, + "b": {URL: "http://b"}, + } + clone := cloneCustomProvidersMap(src) + if len(clone) != 2 { + t.Fatalf("expected 2, got %d", len(clone)) + } + clone["a"] = ProviderEntry{URL: "http://changed"} + if src["a"].URL != "http://a" { + t.Error("modifying clone should not affect original") + } +} + +func TestCloneCustomProvidersMap_Nil(t *testing.T) { + got := cloneCustomProvidersMap(nil) + if got != nil { + t.Errorf("expected nil, got %v", got) + } +} + +func TestCloneCustomProviderList(t *testing.T) { + src := []customProviderListItem{ + {name: "a", entry: ProviderEntry{URL: "http://a"}}, + {name: "b", entry: ProviderEntry{URL: "http://b"}}, + } + clone := cloneCustomProviderList(src) + if len(clone) != 2 { + t.Fatalf("expected 2, got %d", len(clone)) + } + clone[0].name = "changed" + if src[0].name != "a" { + t.Error("modifying clone should not affect original") + } +} + +func TestCustomProviderEntry_FromConfig(t *testing.T) { + cfg := &Config{ + CustomProviders: map[string]ProviderEntry{ + "test": {URL: "http://real", Model: "m1"}, + }, + } + m := newProviderTUI(cfg, "") + fallback := ProviderEntry{URL: "http://fallback"} + got := m.customProviderEntry("test", fallback) + if got.URL != "http://real" { + t.Errorf("expected config entry, got URL %q", got.URL) + } +} + +func TestCustomProviderEntry_Fallback(t *testing.T) { + m := newProviderTUI(&Config{}, "") + fallback := ProviderEntry{URL: "http://fallback"} + got := m.customProviderEntry("nonexist", fallback) + if got.URL != "http://fallback" { + t.Errorf("expected fallback, got URL %q", got.URL) + } +} + +func TestNewModelTUI(t *testing.T) { + p := llm.ListProviders() + if len(p) == 0 { + t.Skip("no providers") + } + m := newModelTUI(p[0], "") + if m.modelIdx != 0 { + t.Errorf("modelIdx = %d, want 0 for empty currentModel", m.modelIdx) + } + cmd := m.Init() + if cmd != nil { + t.Error("Init() should return nil") + } +} + +func TestNewModelTUI_WithCurrentModel(t *testing.T) { + p := llm.ListProviders() + if len(p) == 0 || len(p[0].Models) == 0 { + t.Skip("need provider with models") + } + current := p[0].Models[0] + m := newModelTUI(p[0], current) + if m.modelIdx != 0 { + t.Errorf("modelIdx = %d, want 0 for first model", m.modelIdx) + } + if m.activeModel != current { + t.Errorf("activeModel = %q, want %q", m.activeModel, current) + } +} + +func TestNewModelTUI_CustomModel(t *testing.T) { + p := llm.ListProviders() + if len(p) == 0 { + t.Skip("no providers") + } + m := newModelTUI(p[0], "custom-model-xyz") + if m.modelIdx != len(p[0].Models) { + t.Errorf("modelIdx = %d, want %d for custom model", m.modelIdx, len(p[0].Models)) + } +} + +func TestModelTUI_IsCustomItem(t *testing.T) { + p := llm.ListProviders() + if len(p) == 0 { + t.Skip("no providers") + } + m := newModelTUI(p[0], "") + if !m.isCustomItem(len(p[0].Models)) { + t.Error("expected true for custom item index") + } + if m.isCustomItem(0) { + t.Error("expected false for index 0") + } +} + +func TestModelTUI_ItemCount(t *testing.T) { + p := llm.ListProviders() + if len(p) == 0 { + t.Skip("no providers") + } + m := newModelTUI(p[0], "") + if m.itemCount() != len(p[0].Models)+1 { + t.Errorf("itemCount() = %d, want %d", m.itemCount(), len(p[0].Models)+1) + } +} + +func TestModelTUI_SelectedModel(t *testing.T) { + p := llm.ListProviders() + if len(p) == 0 || len(p[0].Models) == 0 { + t.Skip("need provider with models") + } + m := newModelTUI(p[0], "") + got := m.selectedModel() + if got != p[0].Models[0] { + t.Errorf("selectedModel() = %q, want %q", got, p[0].Models[0]) + } +} + +func TestModelTUI_SelectedModel_OutOfBounds(t *testing.T) { + p := llm.ListProviders() + if len(p) == 0 { + t.Skip("no providers") + } + m := newModelTUI(p[0], "") + m.modelIdx = 9999 + got := m.selectedModel() + if got != "" { + t.Errorf("expected empty for out-of-bounds, got %q", got) + } +} + +func TestModelTUI_Update_UpDown(t *testing.T) { + p := llm.ListProviders() + if len(p) == 0 || len(p[0].Models) < 2 { + t.Skip("need provider with at least 2 models") + } + m := newModelTUI(p[0], "") + result, _ := m.Update(downKey()) + m2 := result.(modelTUIModel) + if m2.modelIdx != 1 { + t.Errorf("after down, modelIdx = %d, want 1", m2.modelIdx) + } + result, _ = m2.Update(upKey()) + m3 := result.(modelTUIModel) + if m3.modelIdx != 0 { + t.Errorf("after up, modelIdx = %d, want 0", m3.modelIdx) + } +} + +func TestModelTUI_Update_WindowSize(t *testing.T) { + p := llm.ListProviders() + if len(p) == 0 { + t.Skip("no providers") + } + m := newModelTUI(p[0], "") + result, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 50}) + m2 := result.(modelTUIModel) + if m2.width != 100 || m2.height != 50 { + t.Errorf("size = %dx%d, want 100x50", m2.width, m2.height) + } +} + +func TestModelTUI_Update_EscCancels(t *testing.T) { + p := llm.ListProviders() + if len(p) == 0 { + t.Skip("no providers") + } + m := newModelTUI(p[0], "") + result, _ := m.Update(escKey()) + m2 := result.(modelTUIModel) + if !m2.cancelled { + t.Error("expected cancelled after esc") + } +} + +// --- View rendering tests (smoke) --- + +func TestProviderTUIView_StepProvider_OfficialTab(t *testing.T) { + m := newProviderTUI(&Config{}, "") + v := m.View() + if v.Content == "" { + t.Error("expected non-empty view") + } +} + +func TestProviderTUIView_StepProvider_CustomTab(t *testing.T) { + cfg := &Config{ + CustomProviders: map[string]ProviderEntry{ + "my-llm": {URL: "http://localhost", Model: "m"}, + }, + } + m := newProviderTUI(cfg, "") + m.activeTab = tabCustom + v := m.View() + if !strings.Contains(v.Content, "my-llm") { + t.Errorf("expected custom provider name in view, got %q", v.Content) + } +} + +func TestProviderTUIView_StepProvider_CustomTab_CreatingCustom(t *testing.T) { + m := newProviderTUI(&Config{}, "") + m.activeTab = tabCustom + m.creatingCustom = true + v := m.View() + if !strings.Contains(v.Content, "Add Custom Provider") { + t.Errorf("expected 'Add Custom Provider' in view") + } +} + +func TestProviderTUIView_StepProvider_CustomTab_EditingCustom(t *testing.T) { + cfg := &Config{ + CustomProviders: map[string]ProviderEntry{ + "ed": {URL: "http://ed"}, + }, + } + m := newProviderTUI(cfg, "") + m.activeTab = tabCustom + m.editingCustom = true + m.editTargetName = "ed" + v := m.View() + if !strings.Contains(v.Content, "Edit Custom Provider") { + t.Errorf("expected 'Edit Custom Provider' in view") + } +} + +func TestProviderTUIView_StepProvider_ManualTab(t *testing.T) { + m := newProviderTUI(&Config{}, "") + m.activeTab = tabManual + v := m.View() + if !strings.Contains(v.Content, "Manual") { + t.Errorf("expected 'Manual' in view") + } +} + +func TestProviderTUIView_StepProvider_ManualTab_InForm(t *testing.T) { + m := newProviderTUI(&Config{}, "") + m.activeTab = tabManual + m.inManualForm = true + v := m.View() + if !strings.Contains(v.Content, "Manual Configuration") { + t.Errorf("expected 'Manual Configuration' in view") + } +} + +func TestProviderTUIView_StepProvider_ConfirmingDelete(t *testing.T) { + cfg := &Config{ + CustomProviders: map[string]ProviderEntry{ + "del": {URL: "http://del"}, + }, + } + m := newProviderTUI(cfg, "") + m.activeTab = tabCustom + m.confirmingDelete = true + m.deleteTargetName = "del" + v := m.View() + if !strings.Contains(v.Content, "Confirm") { + t.Errorf("expected confirm help text in view") + } +} + +func TestProviderTUIView_StepModel(t *testing.T) { + m := newProviderTUI(&Config{}, "") + m.step = stepModel + v := m.View() + if !strings.Contains(v.Content, "Select a model") { + t.Errorf("expected 'Select a model' in view, got %q", v.Content) + } +} + +func TestProviderTUIView_StepModel_CustomModel(t *testing.T) { + m := newProviderTUI(&Config{}, "") + m.step = stepModel + m.customModel = true + v := m.View() + if v.Content == "" { + t.Error("expected non-empty view") + } +} + +func TestProviderTUIView_StepModel_FormError(t *testing.T) { + m := newProviderTUI(&Config{}, "") + m.step = stepModel + m.customModel = true + m.formError = "model name required" + v := m.View() + if !strings.Contains(v.Content, "model name required") { + t.Errorf("expected form error in view") + } +} + +func TestProviderTUIView_StepModel_ConfirmingDeleteModel(t *testing.T) { + m := newProviderTUI(&Config{}, "") + m.step = stepModel + m.confirmingDeleteModel = true + m.deleteModelName = "gpt-4" + v := m.View() + if !strings.Contains(v.Content, "gpt-4") { + t.Errorf("expected delete model name in view") + } +} + +func TestProviderTUIView_StepAPIKey(t *testing.T) { + m := newProviderTUI(&Config{}, "") + m.step = stepAPIKey + v := m.View() + if !strings.Contains(v.Content, "API Key") { + t.Errorf("expected 'API Key' in view, got %q", v.Content) + } +} + +func TestProviderTUIView_StepAPIKey_CustomProvider(t *testing.T) { + cfg := &Config{ + CustomProviders: map[string]ProviderEntry{ + "cp": {URL: "http://cp"}, + }, + } + m := newProviderTUI(cfg, "") + m.step = stepAPIKey + m.activeTab = tabCustom + m.customIdx = 0 + v := m.View() + if !strings.Contains(v.Content, "cp") { + t.Errorf("expected custom provider name in API Key view") + } +} + +func TestRenderTabBar_AllTabs(t *testing.T) { + for _, tab := range []providerTab{tabOfficial, tabCustom, tabManual} { + got := renderTabBar(tab) + if got == "" { + t.Errorf("renderTabBar(%d) returned empty", tab) + } + } +} + +func TestModelTUI_View(t *testing.T) { + p := llm.ListProviders() + if len(p) == 0 { + t.Skip("no providers") + } + m := newModelTUI(p[0], "") + v := m.View() + if !strings.Contains(v.Content, "Select a model") { + t.Errorf("expected 'Select a model' in view, got %q", v.Content) + } +} + +func TestModelTUI_View_CustomModel(t *testing.T) { + p := llm.ListProviders() + if len(p) == 0 { + t.Skip("no providers") + } + m := newModelTUI(p[0], "") + m.customModel = true + v := m.View() + if v.Content == "" { + t.Error("expected non-empty view") + } +} + +// --- result() tests --- + +func TestResult_OfficialTab(t *testing.T) { + m := newProviderTUI(&Config{}, "") + r := m.result() + if r.provider == "" && len(m.providers) > 0 { + t.Error("expected non-empty provider") + } +} + +func TestResult_OfficialTab_WithMaskedKey(t *testing.T) { + m := newProviderTUI(&Config{}, "") + m.apiKeyMasked = true + m.apiKeyOriginal = "sk-secret" + r := m.result() + if r.apiKey != "sk-secret" { + t.Errorf("expected masked key, got %q", r.apiKey) + } +} + +func TestResult_CustomTab_Creating(t *testing.T) { + m := newProviderTUI(&Config{}, "") + m.activeTab = tabCustom + m.creatingCustom = true + m.cpNameInput.SetValue("new-prov") + m.cpURLInput.SetValue("http://url") + r := m.result() + if !r.isCustom { + t.Error("expected isCustom=true") + } + if r.provider != "new-prov" { + t.Errorf("provider = %q, want new-prov", r.provider) + } +} + +func TestResult_CustomTab_Editing(t *testing.T) { + cfg := &Config{ + CustomProviders: map[string]ProviderEntry{ + "ed": {URL: "http://ed", Model: "m1", Models: []string{"m1", "m2"}}, + }, + } + m := newProviderTUI(cfg, "") + m.activeTab = tabCustom + m.editingCustom = true + m.editTargetName = "ed" + m.cpNameInput.SetValue("ed") + m.cpURLInput.SetValue("http://ed") + r := m.result() + if !r.isEdit { + t.Error("expected isEdit=true") + } + if r.model != "m1" { + t.Errorf("model = %q, want m1", r.model) + } +} + +func TestResult_CustomTab_Selected(t *testing.T) { + cfg := &Config{ + CustomProviders: map[string]ProviderEntry{ + "sel": {URL: "http://sel", Model: "gpt", Models: []string{"gpt"}}, + }, + } + m := newProviderTUI(cfg, "") + m.activeTab = tabCustom + m.customIdx = 0 + r := m.result() + if !r.isCustom { + t.Error("expected isCustom=true") + } + if r.provider != "sel" { + t.Errorf("provider = %q, want sel", r.provider) + } +} + +func TestResult_CustomTab_OutOfBounds(t *testing.T) { + m := newProviderTUI(&Config{}, "") + m.activeTab = tabCustom + m.customIdx = 999 + r := m.result() + if r.provider != "" { + t.Errorf("expected empty provider, got %q", r.provider) + } +} + +func TestResult_ManualTab(t *testing.T) { + m := newProviderTUI(&Config{}, "") + m.activeTab = tabManual + m.manualURLInput.SetValue("http://manual") + m.manualModelInput.SetValue("model-x") + r := m.result() + if !r.isManual { + t.Error("expected isManual=true") + } + if r.url != "http://manual" { + t.Errorf("url = %q, want http://manual", r.url) + } + if r.model != "model-x" { + t.Errorf("model = %q, want model-x", r.model) + } +} + +func TestResult_ManualTab_MaskedToken(t *testing.T) { + m := newProviderTUI(&Config{}, "") + m.activeTab = tabManual + m.manualTokenMasked = true + m.manualTokenOriginal = "tok-secret" + r := m.result() + if r.apiKey != "tok-secret" { + t.Errorf("expected masked token, got %q", r.apiKey) + } +} + +// --- loadExistingAPIKey tests --- + +func TestLoadExistingAPIKey_CustomTab_HasKey(t *testing.T) { + cfg := &Config{ + CustomProviders: map[string]ProviderEntry{ + "cp": {URL: "http://cp", APIKey: "sk-key"}, + }, + } + m := newProviderTUI(cfg, "") + m.activeTab = tabCustom + m.customIdx = 0 + m.loadExistingAPIKey() + if !m.apiKeyMasked { + t.Error("expected apiKeyMasked=true") + } + if m.apiKeyOriginal != "sk-key" { + t.Errorf("apiKeyOriginal = %q, want sk-key", m.apiKeyOriginal) + } +} + +func TestLoadExistingAPIKey_CustomTab_NoKey(t *testing.T) { + cfg := &Config{ + CustomProviders: map[string]ProviderEntry{ + "cp": {URL: "http://cp"}, + }, + } + m := newProviderTUI(cfg, "") + m.activeTab = tabCustom + m.customIdx = 0 + m.loadExistingAPIKey() + if m.apiKeyMasked { + t.Error("expected apiKeyMasked=false") + } +} + +func TestLoadExistingAPIKey_OfficialTab_NilCfg(t *testing.T) { + m := providerTUIModel{existingCfg: nil} + m.loadExistingAPIKey() + if m.apiKeyMasked { + t.Error("expected apiKeyMasked=false for nil cfg") + } +} + +func TestLoadExistingAPIKey_OfficialTab_HasKey(t *testing.T) { + cfg := &Config{} + m := newProviderTUI(cfg, "") + if len(m.providers) == 0 { + t.Skip("no providers") + } + provName := m.providers[0].Name + cfg.Providers = map[string]ProviderEntry{ + provName: {APIKey: "official-key"}, + } + m.existingCfg = cfg + m.officialIdx = 0 + m.loadExistingAPIKey() + if !m.apiKeyMasked { + t.Error("expected apiKeyMasked=true") + } + if m.apiKeyOriginal != "official-key" { + t.Errorf("apiKeyOriginal = %q, want official-key", m.apiKeyOriginal) + } +} + +// --- selectedModelFromState tests --- + +func TestSelectedModelFromState_CustomModelInput(t *testing.T) { + m := newProviderTUI(&Config{}, "") + m.customModel = true + m.modelInput.SetValue("my-model") + got := m.selectedModelFromState() + if got != "my-model" { + t.Errorf("expected my-model, got %q", got) + } +} + +func TestSelectedModelFromState_OutOfBounds(t *testing.T) { + m := newProviderTUI(&Config{}, "") + m.modelIdx = 9999 + got := m.selectedModelFromState() + if got != "" { + t.Errorf("expected empty, got %q", got) + } +} + +// --- syncSessionModelSelection tests --- + +func TestSyncSessionModelSelection_NilCfg(t *testing.T) { + m := providerTUIModel{existingCfg: nil} + err := m.syncSessionModelSelection() + if err != nil { + t.Errorf("unexpected error: %v", err) + } +} + +func TestSyncSessionModelSelection_EmptyModel(t *testing.T) { + m := newProviderTUI(&Config{}, "") + m.modelIdx = 9999 + err := m.syncSessionModelSelection() + if err != nil { + t.Errorf("unexpected error: %v", err) + } +} + +// --- viewCustomProviderForm field steps --- + +func TestProviderTUIView_CustomForm_AllSteps(t *testing.T) { + m := newProviderTUI(&Config{}, "") + m.activeTab = tabCustom + m.creatingCustom = true + for _, step := range []customProviderStep{cpStepName, cpStepBaseURL, cpStepAPIKey, cpStepAuthHeader, cpStepProtocol} { + m.cpStep = step + v := m.View() + if v.Content == "" { + t.Errorf("empty view for step %d", step) + } + } +} + +func TestProviderTUIView_CustomForm_WithError(t *testing.T) { + m := newProviderTUI(&Config{}, "") + m.activeTab = tabCustom + m.creatingCustom = true + m.formError = "name is required" + v := m.View() + if !strings.Contains(v.Content, "name is required") { + t.Error("expected form error in view") + } +} + +// --- viewManualTab field steps --- + +func TestProviderTUIView_ManualForm_AllSteps(t *testing.T) { + m := newProviderTUI(&Config{}, "") + m.activeTab = tabManual + m.inManualForm = true + for _, step := range []manualStep{manualStepURL, manualStepProtocol, manualStepModel, manualStepAuthToken, manualStepAuthHeader} { + m.manualStep = step + v := m.View() + if v.Content == "" { + t.Errorf("empty view for manual step %d", step) + } + } +} + +func TestProviderTUIView_ManualForm_WithError(t *testing.T) { + m := newProviderTUI(&Config{}, "") + m.activeTab = tabManual + m.inManualForm = true + m.formError = "URL required" + v := m.View() + if !strings.Contains(v.Content, "URL required") { + t.Error("expected form error in view") + } +} + +func TestProviderTUIView_ManualTab_WithExistingConfig(t *testing.T) { + cfg := &Config{ + Llm: LlmConfig{URL: "http://existing", Model: "old-model"}, + } + m := newProviderTUI(cfg, "") + m.activeTab = tabManual + v := m.View() + if !strings.Contains(v.Content, "http://existing") { + t.Errorf("expected existing URL in manual tab") + } +} + +// --- viewModel with custom tab and deleteModel --- + +func TestProviderTUIView_StepModel_CustomTabDeleteHelp(t *testing.T) { + cfg := &Config{ + CustomProviders: map[string]ProviderEntry{ + "cp": {URL: "http://cp", Models: []string{"m1"}}, + }, + } + m := newProviderTUI(cfg, "") + m.step = stepModel + m.activeTab = tabCustom + m.customIdx = 0 + v := m.View() + if !strings.Contains(v.Content, "Delete") { + t.Errorf("expected delete help for custom tab model view") + } +} diff --git a/cmd/opencodereview/scan_cmd_test.go b/cmd/opencodereview/scan_cmd_test.go index c20e20a..0205842 100644 --- a/cmd/opencodereview/scan_cmd_test.go +++ b/cmd/opencodereview/scan_cmd_test.go @@ -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) + } +} diff --git a/cmd/opencodereview/shared_test.go b/cmd/opencodereview/shared_test.go new file mode 100644 index 0000000..8c9b819 --- /dev/null +++ b/cmd/opencodereview/shared_test.go @@ -0,0 +1,127 @@ +package main + +import ( + "os" + "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, _ := os.Getwd() + defer os.Chdir(origDir) + os.Chdir(dir) + + 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") + } +} + +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 +} diff --git a/cmd/opencodereview/smallfiles_test.go b/cmd/opencodereview/smallfiles_test.go new file mode 100644 index 0000000..cae63f2 --- /dev/null +++ b/cmd/opencodereview/smallfiles_test.go @@ -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) + } +} From 793bbc6a61b5f7c1f987828a29f5c47a951611ec Mon Sep 17 00:00:00 2001 From: kite Date: Sat, 27 Jun 2026 10:43:45 +0800 Subject: [PATCH 13/87] test: improve coverage for telemetry package and exclude extensions from build targets Add comprehensive tests for events, metrics, provider, shutdown, span, and exporter in the telemetry package. Update Makefile to exclude the extensions directory from test, fmt, vet, and check targets. --- Makefile | 12 ++- internal/telemetry/events_test.go | 152 ++++++++++++++++++++++++++++ internal/telemetry/exporter_test.go | 76 ++++++++++++++ internal/telemetry/metrics_test.go | 34 ++++++- internal/telemetry/provider_test.go | 114 +++++++++++++++++++++ internal/telemetry/shutdown_test.go | 9 ++ internal/telemetry/span_test.go | 101 +++++++++++++++++- 7 files changed, 490 insertions(+), 8 deletions(-) create mode 100644 internal/telemetry/exporter_test.go diff --git a/Makefile b/Makefile index fd1e389..bb83bc0 100644 --- a/Makefile +++ b/Makefile @@ -31,8 +31,10 @@ 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) clean: rm -rf $(DIST_DIR) @@ -44,15 +46,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 ─────────────────────────────────────────────────── diff --git a/internal/telemetry/events_test.go b/internal/telemetry/events_test.go index 88e8b02..1bcd170 100644 --- a/internal/telemetry/events_test.go +++ b/internal/telemetry/events_test.go @@ -1,10 +1,162 @@ package telemetry import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "strings" "testing" "time" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/resource" + sdktrace "go.opentelemetry.io/otel/sdk/trace" ) +func setupEnabledTelemetry(t *testing.T) { + t.Helper() + tp := sdktrace.NewTracerProvider(sdktrace.WithResource(resource.Default())) + mp := sdkmetric.NewMeterProvider(sdkmetric.WithResource(resource.Default())) + otel.SetTracerProvider(tp) + otel.SetMeterProvider(mp) + tracerProvider = tp + meterProvider = mp + initialized = true + shutdownFuncs = []func(context.Context) error{ + func(ctx context.Context) error { return tp.Shutdown(ctx) }, + func(ctx context.Context) error { return mp.Shutdown(ctx) }, + } + initMetricsOnce = false + t.Cleanup(func() { + _ = tp.Shutdown(context.Background()) + _ = mp.Shutdown(context.Background()) + tracerProvider = nil + meterProvider = nil + initialized = false + shutdownFuncs = nil + initMetricsOnce = false + }) +} + +func captureStderr(t *testing.T, fn func()) string { + t.Helper() + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + oldStderr := os.Stderr + os.Stderr = w + defer func() { os.Stderr = oldStderr }() + + fn() + w.Close() + + var buf bytes.Buffer + _, _ = io.Copy(&buf, r) + return buf.String() +} + +func TestEvent_Enabled(t *testing.T) { + setupEnabledTelemetry(t) + ctx := context.Background() + Event(ctx, "test.event", attribute.String("key", "value")) +} + +func TestEvent_Disabled(t *testing.T) { + initialized = false + shutdownFuncs = nil + defer func() { initialized = false }() + Event(context.Background(), "test.event") +} + +func TestEvent_NilCtx(t *testing.T) { + setupEnabledTelemetry(t) + Event(nil, "test.event") //nolint:staticcheck +} + +func TestEventf_Enabled(t *testing.T) { + setupEnabledTelemetry(t) + ctx := context.Background() + Eventf(ctx, "test.eventf", "hello world", attribute.Int("count", 5)) +} + +func TestErrorEvent_Enabled(t *testing.T) { + setupEnabledTelemetry(t) + ctx := context.Background() + ErrorEvent(ctx, "test.error", errors.New("something failed"), attribute.String("detail", "extra")) +} + +func TestErrorEvent_NilErr(t *testing.T) { + setupEnabledTelemetry(t) + ctx := context.Background() + ErrorEvent(ctx, "test.error", nil) +} + +func TestErrorEvent_NilCtx(t *testing.T) { + setupEnabledTelemetry(t) + ErrorEvent(nil, "test.error", errors.New("fail")) //nolint:staticcheck +} + +func TestErrorEvent_Disabled(t *testing.T) { + initialized = false + shutdownFuncs = nil + defer func() { initialized = false }() + ErrorEvent(context.Background(), "test.error", errors.New("fail")) +} + +func TestPhaseEvent_Success(t *testing.T) { + setupEnabledTelemetry(t) + ctx := context.Background() + PhaseEvent(ctx, "scan", "main.go", 500*time.Millisecond, nil) +} + +func TestPhaseEvent_WithError(t *testing.T) { + setupEnabledTelemetry(t) + ctx := context.Background() + PhaseEvent(ctx, "scan", "main.go", 500*time.Millisecond, errors.New("parse error")) +} + +func TestPrintTraceSummary_WithTokenDetails(t *testing.T) { + PrintTraceSummary(5, 10, 1000, 200, 1200, 0, 0, 3*time.Second) +} + +func TestPrintTraceSummary_WithCacheTokens(t *testing.T) { + PrintTraceSummary(3, 2, 500, 100, 600, 200, 50, 2*time.Second) +} + +func TestPrintTraceSummary_NoTokenDetails(t *testing.T) { + PrintTraceSummary(2, 1, 0, 0, 500, 0, 0, 1*time.Second) +} + +func TestPrintToolCallStarted_WithArgs(t *testing.T) { + PrintToolCallStarted("file_read", map[string]any{"path": "main.go"}) +} + +func TestPrintToolCallStarted_NoArgs(t *testing.T) { + PrintToolCallStarted("list_files", nil) +} + +func TestPrintToolCallFinished(t *testing.T) { + PrintToolCallFinished("file_read", 123*time.Millisecond) +} + +func TestPrintToolCallError(t *testing.T) { + out := captureStderr(t, func() { + PrintToolCallError("file_read", fmt.Errorf("permission denied")) + }) + if !strings.Contains(out, "✘ file_read") { + t.Errorf("expected tool name with X mark, got %q", out) + } + if !strings.Contains(out, "permission denied") { + t.Errorf("expected error message, got %q", out) + } +} + func TestFormatDuration(t *testing.T) { tests := []struct { dur time.Duration diff --git a/internal/telemetry/exporter_test.go b/internal/telemetry/exporter_test.go new file mode 100644 index 0000000..64f8573 --- /dev/null +++ b/internal/telemetry/exporter_test.go @@ -0,0 +1,76 @@ +package telemetry + +import ( + "context" + "testing" + + "go.opentelemetry.io/otel/sdk/resource" +) + +func TestNewStdoutTraceExporter(t *testing.T) { + exp, err := newStdoutTraceExporter() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if exp == nil { + t.Error("expected non-nil exporter") + } +} + +func TestNewStdoutMetricExporter(t *testing.T) { + exp, err := newStdoutMetricExporter() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if exp == nil { + t.Error("expected non-nil exporter") + } +} + +func TestInitConsoleProviders(t *testing.T) { + tracerProvider = nil + meterProvider = nil + shutdownFuncs = nil + defer func() { + for _, fn := range shutdownFuncs { + _ = fn(context.Background()) + } + tracerProvider = nil + meterProvider = nil + shutdownFuncs = nil + }() + + initConsoleProviders(resource.Default()) + if tracerProvider == nil { + t.Error("expected tracerProvider to be set after initConsoleProviders") + } + if meterProvider == nil { + t.Error("expected meterProvider to be set after initConsoleProviders") + } + if len(shutdownFuncs) != 2 { + t.Errorf("expected 2 shutdown funcs, got %d", len(shutdownFuncs)) + } +} + +func TestInitOTLPProviders_InvalidEndpoint(t *testing.T) { + tracerProvider = nil + meterProvider = nil + shutdownFuncs = nil + defer func() { + for _, fn := range shutdownFuncs { + _ = fn(context.Background()) + } + tracerProvider = nil + meterProvider = nil + shutdownFuncs = nil + }() + + cfg := Config{ + Exporter: "otlp", + OTLPEndpoint: "localhost:0", + } + initOTLPProviders(context.Background(), resource.Default(), cfg) + if tracerProvider == nil { + t.Error("expected tracerProvider to be set (OTLP exporter creation is lazy)") + } +} diff --git a/internal/telemetry/metrics_test.go b/internal/telemetry/metrics_test.go index c678964..a028527 100644 --- a/internal/telemetry/metrics_test.go +++ b/internal/telemetry/metrics_test.go @@ -24,7 +24,39 @@ func TestRecordFunctions_DisabledTelemetry(t *testing.T) { } func TestCheckMetricErr(t *testing.T) { - // Should not panic checkMetricErr(nil) checkMetricErr(fmt.Errorf("some error")) } + +func TestRecordFunctions_EnabledTelemetry(t *testing.T) { + setupEnabledTelemetry(t) + ctx := context.Background() + + RecordReviewDuration(ctx, 5*time.Second) + RecordFilesReviewed(ctx, 10) + RecordCommentsGenerated(ctx, 3) + RecordLLMRequest(ctx, "gpt-4", 2*time.Second, 1000, "ok") + RecordLLMRequest(ctx, "gpt-4", 1*time.Second, 0, "error") + RecordToolCall(ctx, "file_read", 100*time.Millisecond, true) + RecordToolCall(ctx, "file_read", 200*time.Millisecond, false) +} + +func TestEnsureMetrics_Idempotent(t *testing.T) { + setupEnabledTelemetry(t) + ensureMetrics() + ensureMetrics() + if mReviewDuration == nil { + t.Error("expected mReviewDuration to be initialized") + } + if mFilesReviewed == nil { + t.Error("expected mFilesReviewed to be initialized") + } +} + +func TestGetMeter(t *testing.T) { + setupEnabledTelemetry(t) + m := getMeter() + if m == nil { + t.Error("expected non-nil meter") + } +} diff --git a/internal/telemetry/provider_test.go b/internal/telemetry/provider_test.go index 1454e54..3895636 100644 --- a/internal/telemetry/provider_test.go +++ b/internal/telemetry/provider_test.go @@ -2,6 +2,7 @@ package telemetry import ( "context" + "os" "testing" ) @@ -50,3 +51,116 @@ func TestContentLogging_Disabled(t *testing.T) { t.Error("expected ContentLogging()=false when telemetry disabled") } } + +func TestContentLogging_EnabledWithEnv(t *testing.T) { + setupEnabledTelemetry(t) + t.Setenv("OCR_CONTENT_LOGGING", "1") + if !ContentLogging() { + t.Error("expected ContentLogging()=true when enabled and env var set") + } +} + +func TestContentLogging_EnabledWithoutEnv(t *testing.T) { + setupEnabledTelemetry(t) + t.Setenv("OCR_CONTENT_LOGGING", "") + os.Unsetenv("OCR_CONTENT_LOGGING") + if ContentLogging() { + t.Error("expected ContentLogging()=false when enabled but env var not set") + } +} + +func TestInit_AlreadyInitialized(t *testing.T) { + initialized = true + shutdownFuncs = nil + defer func() { + initialized = false + shutdownFuncs = nil + }() + + result := Init(context.Background()) + if result { + t.Error("expected false when already initialized with no shutdown funcs") + } +} + +func TestInit_AlreadyInitializedWithShutdowns(t *testing.T) { + initialized = true + shutdownFuncs = []func(context.Context) error{ + func(ctx context.Context) error { return nil }, + } + defer func() { + initialized = false + shutdownFuncs = nil + }() + + result := Init(context.Background()) + if !result { + t.Error("expected true when already initialized with shutdown funcs") + } +} + +func TestInit_DisabledByDefault(t *testing.T) { + initialized = false + shutdownFuncs = nil + tracerProvider = nil + meterProvider = nil + + envKeys := []string{ + "OCR_ENABLE_TELEMETRY", "OTEL_SERVICE_NAME", + "OTEL_EXPORTER_OTLP_ENDPOINT", "OTEL_EXPORTER_OTLP_PROTOCOL", + "OCR_CONTENT_LOGGING", + } + for _, k := range envKeys { + t.Setenv(k, "") + os.Unsetenv(k) + } + + defer func() { + initialized = false + shutdownFuncs = nil + tracerProvider = nil + meterProvider = nil + }() + + result := Init(context.Background()) + if result { + t.Error("expected false when telemetry is not enabled via env") + } +} + +func TestInit_EnabledConsole(t *testing.T) { + initialized = false + shutdownFuncs = nil + tracerProvider = nil + meterProvider = nil + + t.Setenv("OCR_ENABLE_TELEMETRY", "1") + envKeys := []string{ + "OTEL_EXPORTER_OTLP_ENDPOINT", "OTEL_EXPORTER_OTLP_PROTOCOL", + } + for _, k := range envKeys { + t.Setenv(k, "") + os.Unsetenv(k) + } + + defer func() { + for _, fn := range shutdownFuncs { + _ = fn(context.Background()) + } + initialized = false + shutdownFuncs = nil + tracerProvider = nil + meterProvider = nil + }() + + result := Init(context.Background()) + if !result { + t.Error("expected true when telemetry is enabled") + } + if tracerProvider == nil { + t.Error("expected tracerProvider to be set") + } + if meterProvider == nil { + t.Error("expected meterProvider to be set") + } +} diff --git a/internal/telemetry/shutdown_test.go b/internal/telemetry/shutdown_test.go index a56f999..e8c8dcf 100644 --- a/internal/telemetry/shutdown_test.go +++ b/internal/telemetry/shutdown_test.go @@ -69,3 +69,12 @@ func TestShutdownWithTimeout(t *testing.T) { t.Error("expected shutdown function to be called via ShutdownWithTimeout") } } + +func TestShutdownWithTimeout_Error(t *testing.T) { + shutdownFuncs = []func(context.Context) error{ + func(ctx context.Context) error { return errors.New("shutdown fail") }, + } + defer func() { shutdownFuncs = nil }() + + ShutdownWithTimeout(context.Background(), 5*time.Second) +} diff --git a/internal/telemetry/span_test.go b/internal/telemetry/span_test.go index 033561b..a8ca9d1 100644 --- a/internal/telemetry/span_test.go +++ b/internal/telemetry/span_test.go @@ -1,10 +1,13 @@ package telemetry import ( + "context" + "errors" "fmt" "testing" "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" ) func TestAnyToAttr(t *testing.T) { @@ -31,13 +34,107 @@ func TestAnyToAttr(t *testing.T) { } } +func TestStartSpan_Enabled(t *testing.T) { + setupEnabledTelemetry(t) + ctx := context.Background() + newCtx, span := StartSpan(ctx, "test.span") + defer span.End() + if newCtx == nil { + t.Error("expected non-nil context") + } + if !span.SpanContext().IsValid() { + t.Error("expected valid span context when telemetry is enabled") + } +} + +func TestStartSpan_Disabled(t *testing.T) { + initialized = false + shutdownFuncs = nil + defer func() { initialized = false }() + + ctx := context.Background() + newCtx, span := StartSpan(ctx, "test.span") + if newCtx != ctx { + t.Error("expected same context when disabled") + } + _ = span +} + +func TestStartSpan_WithOptions(t *testing.T) { + setupEnabledTelemetry(t) + ctx := context.Background() + _, span := StartSpan(ctx, "test.span", trace.WithAttributes(attribute.String("key", "val"))) + defer span.End() + if !span.SpanContext().IsValid() { + t.Error("expected valid span") + } +} + +func TestEndSpan_NoError(t *testing.T) { + setupEnabledTelemetry(t) + _, span := StartSpan(context.Background(), "test.span") + EndSpan(span, nil) +} + +func TestEndSpan_WithError(t *testing.T) { + setupEnabledTelemetry(t) + _, span := StartSpan(context.Background(), "test.span") + EndSpan(span, errors.New("something went wrong")) +} + +func TestStartToolSpan_Enabled(t *testing.T) { + setupEnabledTelemetry(t) + ctx := context.Background() + newCtx, span := StartToolSpan(ctx, "file_read") + defer span.End() + if newCtx == nil { + t.Error("expected non-nil context") + } + if !span.SpanContext().IsValid() { + t.Error("expected valid span for tool") + } +} + +func TestGetTracer(t *testing.T) { + setupEnabledTelemetry(t) + tracer := getTracer() + if tracer == nil { + t.Error("expected non-nil tracer") + } +} + +func TestSetAttr_AllTypes(t *testing.T) { + setupEnabledTelemetry(t) + _, span := StartSpan(context.Background(), "test.setattr") + defer span.End() + + SetAttr(span, "str", "hello") + SetAttr(span, "int", 42) + SetAttr(span, "int64", int64(100)) + SetAttr(span, "bool", true) + SetAttr(span, "float64", 3.14) + SetAttr(span, "default", []int{1, 2}) +} + func TestSetAttr_NilSpan(t *testing.T) { - // Should not panic SetAttr(nil, "key", "value") } +func TestRecordToolResult_Success(t *testing.T) { + setupEnabledTelemetry(t) + _, span := StartSpan(context.Background(), "test.tool") + RecordToolResult(span, "file_read", 123, nil) + span.End() +} + +func TestRecordToolResult_Error(t *testing.T) { + setupEnabledTelemetry(t) + _, span := StartSpan(context.Background(), "test.tool") + RecordToolResult(span, "file_read", 456, errors.New("read failed")) + span.End() +} + func TestRecordToolResult_NilSpan(t *testing.T) { - // Should not panic RecordToolResult(nil, "tool", 100, nil) RecordToolResult(nil, "tool", 100, fmt.Errorf("err")) } From 30c083c0de4ca33e470240ce2fa20250541101b2 Mon Sep 17 00:00:00 2001 From: kite Date: Sat, 27 Jun 2026 11:00:23 +0800 Subject: [PATCH 14/87] test: improve coverage for tool package from 79.5% to 93.6% Add tests for response_message, stub providers, code_search Execute/Tool, filereader commit mode with and without Runner, file_find git repo modes, file_read error paths, and comment_collector ReplaceSince edge cases. --- internal/tool/code_search_test.go | 155 +++++++++++++++++++++++ internal/tool/comment_collector_test.go | 24 ++++ internal/tool/file_find_test.go | 158 ++++++++++++++++++++++++ internal/tool/file_read_test.go | 105 ++++++++++++++++ internal/tool/filereader_read_test.go | 87 +++++++++++++ internal/tool/response_message_test.go | 33 +++++ internal/tool/stub_test.go | 48 +++++++ 7 files changed, 610 insertions(+) create mode 100644 internal/tool/response_message_test.go create mode 100644 internal/tool/stub_test.go diff --git a/internal/tool/code_search_test.go b/internal/tool/code_search_test.go index 4002122..c2d0fb9 100644 --- a/internal/tool/code_search_test.go +++ b/internal/tool/code_search_test.go @@ -2,12 +2,15 @@ package tool import ( "context" + "errors" "os" "os/exec" "path/filepath" "slices" "strings" "testing" + + "github.com/open-code-review/open-code-review/internal/gitcmd" ) func TestBuildGrepArgs_WorkspaceMode(t *testing.T) { @@ -380,3 +383,155 @@ func TestGitGrep_NonGitDirectoryNoMatch(t *testing.T) { t.Errorf("expected 'No matches found', got: %q", out) } } + +func TestCodeSearchProvider_Tool(t *testing.T) { + p := NewCodeSearch(&FileReader{RepoDir: "/tmp"}) + if p.Tool() != CodeSearch { + t.Errorf("Tool() = %v, want CodeSearch", p.Tool()) + } +} + +func TestCodeSearchProvider_Execute_BlankSearchText(t *testing.T) { + p := NewCodeSearch(&FileReader{RepoDir: "/tmp"}) + got, err := p.Execute(context.Background(), map[string]any{"search_text": " "}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "Error: search_text is blank" { + t.Errorf("Execute() = %q, want blank error", got) + } +} + +func TestCodeSearchProvider_Execute_Found(t *testing.T) { + dir := setupTestRepo(t) + p := NewCodeSearch(&FileReader{RepoDir: dir, Mode: ModeWorkspace}) + + got, err := p.Execute(context.Background(), map[string]any{ + "search_text": "Hello", + }) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(got, "hello.go") { + t.Errorf("expected hello.go in result, got: %s", got) + } +} + +func TestCodeSearchProvider_Execute_WithFilePatterns(t *testing.T) { + dir := setupTestRepo(t) + p := NewCodeSearch(&FileReader{RepoDir: dir, Mode: ModeWorkspace}) + + got, err := p.Execute(context.Background(), map[string]any{ + "search_text": "Util", + "file_patterns": []any{"pkg/"}, + }) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(got, "util.go") { + t.Errorf("expected util.go in result, got: %s", got) + } +} + +func TestCodeSearchProvider_Execute_CaseSensitive(t *testing.T) { + dir := setupTestRepo(t) + p := NewCodeSearch(&FileReader{RepoDir: dir, Mode: ModeWorkspace}) + + got, err := p.Execute(context.Background(), map[string]any{ + "search_text": "hello", + "case_sensitive": true, + }) + if err != nil { + t.Fatal(err) + } + if strings.Contains(got, "Hello") { + t.Errorf("case-sensitive search for 'hello' should not match 'Hello', got: %s", got) + } +} + +func TestCodeSearchProvider_Execute_PerlRegexp(t *testing.T) { + dir := setupTestRepo(t) + p := NewCodeSearch(&FileReader{RepoDir: dir, Mode: ModeWorkspace}) + + got, err := p.Execute(context.Background(), map[string]any{ + "search_text": "Hell\\w+", + "use_perl_regexp": true, + }) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(got, "hello.go") { + t.Errorf("expected hello.go in perl regexp result, got: %s", got) + } +} + +func TestGitGrep_WithRunner(t *testing.T) { + dir := setupTestRepo(t) + runner := gitcmd.New(4) + p := NewCodeSearch(&FileReader{RepoDir: dir, Mode: ModeWorkspace, Runner: runner}) + + result, err := p.gitGrep(context.Background(), "Hello", false, false, nil) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(result, "hello.go") { + t.Errorf("expected hello.go in result via Runner, got: %s", result) + } +} + +func TestGitGrep_WithRunner_NoMatch(t *testing.T) { + dir := setupTestRepo(t) + runner := gitcmd.New(4) + p := NewCodeSearch(&FileReader{RepoDir: dir, Mode: ModeWorkspace, Runner: runner}) + + result, err := p.gitGrep(context.Background(), "nonexistentXYZ", false, false, nil) + if err != nil { + t.Fatal(err) + } + if result != "No matches found" { + t.Errorf("expected 'No matches found', got: %s", result) + } +} + +func TestGitGrep_WithRunner_CommitMode(t *testing.T) { + dir := setupTestRepo(t) + commit := getHeadCommit(t, dir) + runner := gitcmd.New(4) + p := NewCodeSearch(&FileReader{RepoDir: dir, Ref: commit, Mode: ModeCommit, Runner: runner}) + + result, err := p.gitGrep(context.Background(), "Hello", false, false, nil) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(result, "hello.go") { + t.Errorf("expected hello.go in result via Runner commit mode, got: %s", result) + } +} + +func TestGitGrep_Timeout(t *testing.T) { + dir := setupTestRepo(t) + p := NewCodeSearch(&FileReader{RepoDir: dir, Mode: ModeWorkspace}) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + result, err := p.gitGrep(ctx, "Hello", false, false, nil) + if err != nil { + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context.Canceled, got: %v", err) + } + return + } + if !strings.Contains(result, "timed out") && !strings.Contains(result, "No matches found") { + t.Errorf("expected timeout or no matches message, got: %s", result) + } +} + +func TestBuildGrepArgs_NoIndex(t *testing.T) { + p := NewCodeSearch(&FileReader{RepoDir: "/tmp", Ref: ""}) + args := p.buildGrepArgs("foo", false, false, true, nil) + + assertContains(t, args, "--no-index") + assertContains(t, args, "--exclude-standard") + assertNotContains(t, args, "--untracked") +} diff --git a/internal/tool/comment_collector_test.go b/internal/tool/comment_collector_test.go index e2628e8..a1198ca 100644 --- a/internal/tool/comment_collector_test.go +++ b/internal/tool/comment_collector_test.go @@ -150,3 +150,27 @@ func TestCommentCollector_RemoveByPathAndIndices_NoMatch(t *testing.T) { t.Error("remove from non-matching path should be no-op") } } + +func TestCommentCollector_ReplaceSince_NegativeSnap(t *testing.T) { + c := NewCommentCollector() + c.Add(cm("a.go", "keep")) + c.ReplaceSince(-1, []model.LlmComment{cm("new.go", "replaced")}) + + got := c.Comments() + if len(got) != 1 || got[0].Path != "new.go" { + t.Errorf("ReplaceSince(-1) should replace all, got %v", got) + } +} + +func TestCommentCollector_ReplaceSince_Zero(t *testing.T) { + c := NewCommentCollector() + c.Add(cm("a.go", "x")) + c.Add(cm("b.go", "y")) + + c.ReplaceSince(0, []model.LlmComment{cm("only.go", "z")}) + + got := c.Comments() + if len(got) != 1 || got[0].Path != "only.go" { + t.Errorf("ReplaceSince(0) should replace all, got %v", got) + } +} diff --git a/internal/tool/file_find_test.go b/internal/tool/file_find_test.go index 6340cd8..3f4dace 100644 --- a/internal/tool/file_find_test.go +++ b/internal/tool/file_find_test.go @@ -3,9 +3,12 @@ package tool import ( "context" "os" + "os/exec" "path/filepath" "strings" "testing" + + "github.com/open-code-review/open-code-review/internal/gitcmd" ) // TestFileFind_NonGitDirectoryFallback verifies file_find works in a plain @@ -64,3 +67,158 @@ func TestFileFind_NonGitDirectoryNoMatch(t *testing.T) { t.Errorf("expected not-found sentinel, got: %q", out) } } + +func TestFileFindProvider_Tool(t *testing.T) { + p := NewFileFind(&FileReader{RepoDir: "/tmp"}) + if p.Tool() != FileFind { + t.Errorf("Tool() = %v, want FileFind", p.Tool()) + } +} + +func TestFileFind_BlankQuery(t *testing.T) { + p := NewFileFind(&FileReader{RepoDir: "/tmp"}) + got, err := p.Execute(context.Background(), map[string]any{"query_name": " "}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(got, "not found") { + t.Errorf("expected not-found for blank query, got: %q", got) + } +} + +func setupFileFindRepo(t *testing.T) string { + t.Helper() + dir := t.TempDir() + run := func(args ...string) { + t.Helper() + cmd := exec.Command(args[0], args[1:]...) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("setup %v: %v\n%s", args, err, out) + } + } + run("git", "init") + run("git", "config", "user.email", "test@test.com") + run("git", "config", "user.name", "Test") + + write := func(rel, content string) { + full := filepath.Join(dir, rel) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + write("main.go", "package main\n") + write("pkg/util.go", "package pkg\n") + write("Makefile", "all:\n") + write("Dockerfile", "FROM scratch\n") + write("LICENSE", "MIT\n") + write("data_binary", "binary\n") + + run("git", "add", ".") + run("git", "commit", "-m", "init") + return dir +} + +func TestFileFind_GitRepo_WorkspaceMode(t *testing.T) { + dir := setupFileFindRepo(t) + p := NewFileFind(&FileReader{RepoDir: dir, Mode: ModeWorkspace}) + + got, err := p.Execute(context.Background(), map[string]any{"query_name": ".go"}) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(got, "main.go") || !strings.Contains(got, "util.go") { + t.Errorf("expected .go files, got: %s", got) + } +} + +func TestFileFind_GitRepo_CommitMode(t *testing.T) { + dir := setupFileFindRepo(t) + cmd := exec.Command("git", "rev-parse", "HEAD") + cmd.Dir = dir + out, err := cmd.Output() + if err != nil { + t.Fatal(err) + } + commit := strings.TrimSpace(string(out)) + + p := NewFileFind(&FileReader{RepoDir: dir, Mode: ModeCommit, Ref: commit}) + + got, execErr := p.Execute(context.Background(), map[string]any{"query_name": ".go"}) + if execErr != nil { + t.Fatal(execErr) + } + if !strings.Contains(got, "main.go") { + t.Errorf("expected main.go in commit mode, got: %s", got) + } +} + +func TestFileFind_GitRepo_WithRunner(t *testing.T) { + dir := setupFileFindRepo(t) + runner := gitcmd.New(4) + p := NewFileFind(&FileReader{RepoDir: dir, Mode: ModeWorkspace, Runner: runner}) + + got, err := p.Execute(context.Background(), map[string]any{"query_name": ".go"}) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(got, "main.go") { + t.Errorf("expected main.go via Runner, got: %s", got) + } +} + +func TestFileFind_CaseSensitive(t *testing.T) { + dir := setupFileFindRepo(t) + p := NewFileFind(&FileReader{RepoDir: dir, Mode: ModeWorkspace}) + + got, err := p.Execute(context.Background(), map[string]any{ + "query_name": "makefile", + "case_sensitive": false, + }) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(got, "Makefile") { + t.Errorf("case-insensitive should find Makefile, got: %s", got) + } + + got2, err2 := p.Execute(context.Background(), map[string]any{ + "query_name": "makefile", + "case_sensitive": true, + }) + if err2 != nil { + t.Fatal(err2) + } + if strings.Contains(got2, "Makefile") { + t.Errorf("case-sensitive should not find Makefile when searching 'makefile', got: %s", got2) + } +} + +func TestShouldSkipFile(t *testing.T) { + tests := []struct { + path string + want bool + }{ + {"main.go", false}, + {"pkg/util.go", false}, + {"README.md", false}, + {"Makefile", false}, + {"Dockerfile", false}, + {"LICENSE", false}, + {"Vagrantfile", false}, + {"Containerfile", false}, + {"some_binary", true}, + {"dir/unknown_file", true}, + } + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + got := shouldSkipFile(tt.path) + if got != tt.want { + t.Errorf("shouldSkipFile(%q) = %v, want %v", tt.path, got, tt.want) + } + }) + } +} diff --git a/internal/tool/file_read_test.go b/internal/tool/file_read_test.go index 8647447..954b01f 100644 --- a/internal/tool/file_read_test.go +++ b/internal/tool/file_read_test.go @@ -330,6 +330,111 @@ func TestExecute_Truncation(t *testing.T) { } } +func TestFileReadProvider_Tool(t *testing.T) { + p := NewFileRead(&FileReader{RepoDir: "/tmp"}) + if p.Tool() != FileRead { + t.Errorf("Tool() = %v, want FileRead", p.Tool()) + } +} + +func TestExecute_EmptyFilePath(t *testing.T) { + fr := &FileReader{RepoDir: t.TempDir(), Mode: ModeWorkspace} + p := NewFileRead(fr) + + got, err := p.Execute(context.Background(), map[string]any{"file_path": ""}) + if err != nil { + t.Fatal(err) + } + if got != "Error: file_path is required" { + t.Errorf("Execute() = %q, want file_path required error", got) + } +} + +func TestExecute_InvalidLineRange(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "test.txt", "a\nb\nc\n") + + fr := &FileReader{RepoDir: dir, Mode: ModeWorkspace} + p := NewFileRead(fr) + + _, err := p.Execute(context.Background(), map[string]any{ + "file_path": "test.txt", + "start_line": float64(5), + "end_line": float64(2), + }) + if err == nil { + t.Error("expected error for invalid line range") + } +} + +func TestExecute_StartBeyondTotalLines(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "short.txt", "one\n") + + fr := &FileReader{RepoDir: dir, Mode: ModeWorkspace} + p := NewFileRead(fr) + + _, err := p.Execute(context.Background(), map[string]any{ + "file_path": "short.txt", + "start_line": float64(100), + "end_line": float64(200), + }) + if err == nil { + t.Error("expected error for start beyond total lines") + } +} + +func TestExecute_MissingFile(t *testing.T) { + fr := &FileReader{RepoDir: t.TempDir(), Mode: ModeWorkspace} + p := NewFileRead(fr) + + _, err := p.Execute(context.Background(), map[string]any{"file_path": "missing.txt"}) + if err == nil { + t.Error("expected error for missing file") + } +} + +func TestExecute_CommitMode(t *testing.T) { + dir := setupTestRepo(t) + commit := getHeadCommit(t, dir) + + fr := &FileReader{RepoDir: dir, Mode: ModeCommit, Ref: commit} + p := NewFileRead(fr) + + got, err := p.Execute(context.Background(), map[string]any{"file_path": "hello.go"}) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(got, "package main") { + t.Errorf("expected file content, got: %s", got) + } + if !strings.Contains(got, "IS_TRUNCATED: false") { + t.Error("expected IS_TRUNCATED: false") + } +} + +func TestExecute_DefaultStartLine(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "d.txt", "a\nb\nc\n") + + fr := &FileReader{RepoDir: dir, Mode: ModeWorkspace} + p := NewFileRead(fr) + + got, err := p.Execute(context.Background(), map[string]any{ + "file_path": "d.txt", + "start_line": float64(0), + }) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(got, "LINE_RANGE: 1-") { + t.Errorf("expected default start_line=1, got: %s", got) + } +} + +// setupTestRepo is already defined in code_search_test.go (same package) +// getHeadCommit is already defined in code_search_test.go (same package) + func TestExecute_WithEndLine(t *testing.T) { dir := t.TempDir() writeTestFile(t, dir, "c.txt", "a\nb\nc\nd\ne\n") diff --git a/internal/tool/filereader_read_test.go b/internal/tool/filereader_read_test.go index 74bffbc..96e2956 100644 --- a/internal/tool/filereader_read_test.go +++ b/internal/tool/filereader_read_test.go @@ -4,7 +4,10 @@ import ( "context" "os" "path/filepath" + "strings" "testing" + + "github.com/open-code-review/open-code-review/internal/gitcmd" ) func TestFileReader_Read_Workspace(t *testing.T) { @@ -109,6 +112,90 @@ func TestFileReader_ReadLines_Workspace(t *testing.T) { }) } +func TestFileReader_Read_CommitMode(t *testing.T) { + dir := setupTestRepo(t) + commit := getHeadCommit(t, dir) + + fr := &FileReader{RepoDir: dir, Mode: ModeCommit, Ref: commit} + got, err := fr.Read(context.Background(), "hello.go") + if err != nil { + t.Fatalf("Read() error: %v", err) + } + if !strings.Contains(got, "package main") { + t.Errorf("Read() = %q, want containing 'package main'", got) + } + if !strings.Contains(got, "func Hello()") { + t.Errorf("Read() = %q, want containing 'func Hello()'", got) + } +} + +func TestFileReader_Read_CommitMode_MissingFile(t *testing.T) { + dir := setupTestRepo(t) + commit := getHeadCommit(t, dir) + + fr := &FileReader{RepoDir: dir, Mode: ModeCommit, Ref: commit} + _, err := fr.Read(context.Background(), "nonexistent.go") + if err == nil { + t.Error("expected error for missing file in commit mode") + } +} + +func TestFileReader_Read_CommitMode_WithRunner(t *testing.T) { + dir := setupTestRepo(t) + commit := getHeadCommit(t, dir) + runner := gitcmd.New(4) + + fr := &FileReader{RepoDir: dir, Mode: ModeCommit, Ref: commit, Runner: runner} + got, err := fr.Read(context.Background(), "hello.go") + if err != nil { + t.Fatalf("Read() error: %v", err) + } + if !strings.Contains(got, "package main") { + t.Errorf("Read() = %q, want containing 'package main'", got) + } +} + +func TestFileReader_Read_CommitMode_WithRunner_MissingFile(t *testing.T) { + dir := setupTestRepo(t) + commit := getHeadCommit(t, dir) + runner := gitcmd.New(4) + + fr := &FileReader{RepoDir: dir, Mode: ModeCommit, Ref: commit, Runner: runner} + _, err := fr.Read(context.Background(), "nonexistent.go") + if err == nil { + t.Error("expected error for missing file in commit mode with runner") + } +} + +func TestFileReader_ReadLines_CommitMode_WithRunner(t *testing.T) { + dir := setupTestRepo(t) + commit := getHeadCommit(t, dir) + runner := gitcmd.New(4) + + fr := &FileReader{RepoDir: dir, Mode: ModeCommit, Ref: commit, Runner: runner} + lines, total, err := fr.ReadLines(context.Background(), "hello.go", 1, 100) + if err != nil { + t.Fatal(err) + } + if total != 4 { + t.Errorf("totalLines = %d, want 4", total) + } + if len(lines) < 1 || lines[0] != "package main" { + t.Errorf("first line = %q, want %q", lines[0], "package main") + } +} + +func TestFileReader_ReadLines_CommitMode_MissingFile(t *testing.T) { + dir := setupTestRepo(t) + commit := getHeadCommit(t, dir) + + fr := &FileReader{RepoDir: dir, Mode: ModeCommit, Ref: commit} + _, _, err := fr.ReadLines(context.Background(), "nonexistent.go", 1, 100) + if err == nil { + t.Error("expected error for missing file in commit mode") + } +} + func TestFileReader_Read_SubdirectoryFile(t *testing.T) { dir := t.TempDir() sub := filepath.Join(dir, "src", "pkg") diff --git a/internal/tool/response_message_test.go b/internal/tool/response_message_test.go new file mode 100644 index 0000000..0fa89d6 --- /dev/null +++ b/internal/tool/response_message_test.go @@ -0,0 +1,33 @@ +package tool + +import "testing" + +func TestComplete(t *testing.T) { + cp := Complete() + if !cp.Completed { + t.Error("Complete() should set Completed=true") + } + if cp.Data != "" { + t.Errorf("Complete() Data = %q, want empty", cp.Data) + } +} + +func TestOf(t *testing.T) { + cp := Of("hello") + if cp.Completed { + t.Error("Of() should set Completed=false") + } + if cp.Data != "hello" { + t.Errorf("Of() Data = %q, want %q", cp.Data, "hello") + } +} + +func TestOf_Empty(t *testing.T) { + cp := Of("") + if cp.Completed { + t.Error("Of(\"\") should set Completed=false") + } + if cp.Data != "" { + t.Errorf("Of(\"\") Data = %q, want empty", cp.Data) + } +} diff --git a/internal/tool/stub_test.go b/internal/tool/stub_test.go new file mode 100644 index 0000000..5c35d81 --- /dev/null +++ b/internal/tool/stub_test.go @@ -0,0 +1,48 @@ +package tool + +import ( + "context" + "testing" +) + +func TestStubProvider_Tool(t *testing.T) { + s := NewStub(FileRead) + if s.Tool() != FileRead { + t.Errorf("Tool() = %v, want FileRead", s.Tool()) + } +} + +func TestStubProvider_Execute(t *testing.T) { + s := NewStub(FileRead) + got, err := s.Execute(context.Background(), nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != NotAvailableMsg { + t.Errorf("Execute() = %q, want %q", got, NotAvailableMsg) + } +} + +func TestBuiltinToolProvider(t *testing.T) { + called := false + fn := func(_ context.Context, args map[string]any) (string, error) { + called = true + return "result", nil + } + + b := NewBuiltin(TaskDone, fn) + if b.Tool() != TaskDone { + t.Errorf("Tool() = %v, want TaskDone", b.Tool()) + } + + got, err := b.Execute(context.Background(), map[string]any{"key": "val"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !called { + t.Error("expected fn to be called") + } + if got != "result" { + t.Errorf("Execute() = %q, want %q", got, "result") + } +} From b109baf0c23c67b07a773023f1bc89a4bd4a4d4c Mon Sep 17 00:00:00 2001 From: zephyrq-z <122677241+zephyrq-z@users.noreply.github.com> Date: Sat, 27 Jun 2026 11:06:38 +0800 Subject: [PATCH 15/87] Feature/rule link to files (#226) * feat: support inline content and file path resolution for rule field - rule field auto-detects: .md/.txt/.markdown ending = file path, otherwise inline - file paths: project-relative first, then as absolute path - safety: stat before read (512KB cap), extension whitelist, symlink resolution - tightened heuristic: values with spaces treated as inline to avoid false positives - guard against empty repoDir to avoid CWD-relative resolution - 5-language README docs updated with file path usage and first-match-wins behavior - 15 new unit tests covering all resolution branches Closes #67 Supersedes #87 * update readme * feat: update rule resolution logic to clear rules for missing or invalid files * feat: update rule field description to clarify file path detection criteria * feat: enhance rule file resolution to block path traversal and improve validation * fix: clean up tryReadRuleFile and add missing blank line - Add blank line between matchProjectRuleEntry and allowedRuleExts (Issue 1) - Remove dead code '|| repoDir == ' in tryReadRuleFile (Issue 2) - Remove unnecessary warning when repoDir is empty but path is absolute (Issue 3) --- README.ja-JP.md | 39 ++ README.ko-KR.md | 39 ++ README.md | 39 ++ README.ru-RU.md | 39 ++ README.zh-CN.md | 39 ++ internal/config/rules/system_rules.go | 113 ++++++ internal/config/rules/system_rules_test.go | 403 +++++++++++++++++++++ 7 files changed, 711 insertions(+) diff --git a/README.ja-JP.md b/README.ja-JP.md index 8998dd9..016b3cb 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -565,6 +565,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` — 相対パス、`/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` フィールドも使用でき、どのファイルをレビュー対象にするかを制御できます: diff --git a/README.ko-KR.md b/README.ko-KR.md index c0d4558..5ce4dbb 100644 --- a/README.ko-KR.md +++ b/README.ko-KR.md @@ -565,6 +565,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` — 상대 경로, `/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` diff --git a/README.md b/README.md index b37e65c..a5b8946 100644 --- a/README.md +++ b/README.md @@ -570,6 +570,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 `/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: diff --git a/README.ru-RU.md b/README.ru-RU.md index 522aabd..8f74bf9 100644 --- a/README.ru-RU.md +++ b/README.ru-RU.md @@ -567,6 +567,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` — относительный путь, загружается из `/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`, управляющие тем, какие файлы попадают в область ревью: diff --git a/README.zh-CN.md b/README.zh-CN.md index ba594d4..1d37593 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -555,6 +555,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` — 相对路径,从 `/docs/sql-rules.md` 加载。 +- `始终检查空值安全…` — 内联字符串,直接使用。 +- `shared/go-concurrency.md` — 相对路径,同上。 +- `/Users/me/team-rules/python.md` — 绝对路径,直接使用。 + +> 绝对路径可以访问项目目录之外的文件,这是有意为之的设计——`rule.json` 由项目维护者编写,属于受信输入。团队可将共享规则放在统一路径下(如 `/opt/company-rules/`),无需在各项目中复制。 + ### 路径过滤 规则文件同时支持 `include` 和 `exclude` 字段,用于控制哪些文件进入审查范围: diff --git a/internal/config/rules/system_rules.go b/internal/config/rules/system_rules.go index a752861..2da4d14 100644 --- a/internal/config/rules/system_rules.go +++ b/internal/config/rules/system_rules.go @@ -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,6 +340,7 @@ 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 } @@ -355,6 +357,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 +441,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 +} diff --git a/internal/config/rules/system_rules_test.go b/internal/config/rules/system_rules_test.go index 2d22a25..8d18fdf 100644 --- a/internal/config/rules/system_rules_test.go +++ b/internal/config/rules/system_rules_test.go @@ -945,3 +945,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) + } +} From f2a6f7c5c70f71a2cccc08590c3fd17dd23726c4 Mon Sep 17 00:00:00 2001 From: kite Date: Sat, 27 Jun 2026 11:13:22 +0800 Subject: [PATCH 16/87] ci: add 80% statement coverage threshold check Add coverage threshold enforcement in CI workflow and Makefile to satisfy OpenSSF Best Practices Silver badge test_statement_coverage80 criterion. --- .github/workflows/ci.yml | 16 ++++++++++++++-- Makefile | 16 ++++++++++++++-- internal/config/rules/system_rules_test.go | 2 +- 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a36405c..ef44b2a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/Makefile b/Makefile index bb83bc0..af0803f 100644 --- a/Makefile +++ b/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 @@ -36,8 +36,20 @@ PACKAGES := $(shell $(GO) list ./... | grep -v /extensions/) test: 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 diff --git a/internal/config/rules/system_rules_test.go b/internal/config/rules/system_rules_test.go index 8d18fdf..374e077 100644 --- a/internal/config/rules/system_rules_test.go +++ b/internal/config/rules/system_rules_test.go @@ -1281,7 +1281,7 @@ func TestResolveRuleEntries_PathTraversalBlocked(t *testing.T) { } entries := []ProjectRuleEntry{ - {Path: "**/*.go", Rule: outside}, // absolute path to outside — allowed + {Path: "**/*.go", Rule: outside}, // absolute path to outside — allowed {Path: "**/*.ts", Rule: "../outside.md"}, // relative traversal — blocked } resolveRuleEntries(entries, dir) From dde66f1dee6b5a81367f65584f47b12963214645 Mon Sep 17 00:00:00 2001 From: kite Date: Sat, 27 Jun 2026 11:19:28 +0800 Subject: [PATCH 17/87] docs: update OpenSSF badge to Silver level --- README.ja-JP.md | 2 +- README.ko-KR.md | 2 +- README.md | 2 +- README.ru-RU.md | 2 +- README.zh-CN.md | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.ja-JP.md b/README.ja-JP.md index 016b3cb..45f0a8d 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -16,7 +16,7 @@ Go Report Card License Ask DeepWiki - OpenSSF Best Practices + OpenSSF Best Practices

Windows diff --git a/README.ko-KR.md b/README.ko-KR.md index 5ce4dbb..3efd104 100644 --- a/README.ko-KR.md +++ b/README.ko-KR.md @@ -16,7 +16,7 @@ Go Report Card License Ask DeepWiki - OpenSSF Best Practices + OpenSSF Best Practices

Windows diff --git a/README.md b/README.md index a5b8946..047b29c 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Go Report Card License Ask DeepWiki - OpenSSF Best Practices + OpenSSF Best Practices

Windows diff --git a/README.ru-RU.md b/README.ru-RU.md index 8f74bf9..72e1274 100644 --- a/README.ru-RU.md +++ b/README.ru-RU.md @@ -16,7 +16,7 @@ Go Report Card License Ask DeepWiki - OpenSSF Best Practices + OpenSSF Best Practices

Windows diff --git a/README.zh-CN.md b/README.zh-CN.md index 1d37593..01446a6 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -16,7 +16,7 @@ Go Report Card License Ask DeepWiki - OpenSSF Best Practices + OpenSSF Best Practices

Windows From 0d601eaf582eeb86569084b508d0dd982e14dd50 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 27 Jun 2026 18:02:15 +0800 Subject: [PATCH 18/87] chore(deps): bump the go-dependencies group with 12 updates (#232) Bumps the go-dependencies group with 12 updates: | Package | From | To | | --- | --- | --- | | [charm.land/lipgloss/v2](https://github.com/charmbracelet/lipgloss) | `2.0.3` | `2.0.4` | | [github.com/anthropics/anthropic-sdk-go](https://github.com/anthropics/anthropic-sdk-go) | `1.47.0` | `1.52.0` | | [github.com/openai/openai-go/v3](https://github.com/openai/openai-go) | `3.39.0` | `3.41.0` | | [go.opentelemetry.io/otel](https://github.com/open-telemetry/opentelemetry-go) | `1.43.0` | `1.44.0` | | [go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc](https://github.com/open-telemetry/opentelemetry-go) | `1.43.0` | `1.44.0` | | [go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc](https://github.com/open-telemetry/opentelemetry-go) | `1.43.0` | `1.44.0` | | [go.opentelemetry.io/otel/exporters/stdout/stdoutmetric](https://github.com/open-telemetry/opentelemetry-go) | `1.43.0` | `1.44.0` | | [go.opentelemetry.io/otel/exporters/stdout/stdouttrace](https://github.com/open-telemetry/opentelemetry-go) | `1.43.0` | `1.44.0` | | [go.opentelemetry.io/otel/metric](https://github.com/open-telemetry/opentelemetry-go) | `1.43.0` | `1.44.0` | | [go.opentelemetry.io/otel/sdk](https://github.com/open-telemetry/opentelemetry-go) | `1.43.0` | `1.44.0` | | [go.opentelemetry.io/otel/sdk/metric](https://github.com/open-telemetry/opentelemetry-go) | `1.43.0` | `1.44.0` | | [go.opentelemetry.io/otel/trace](https://github.com/open-telemetry/opentelemetry-go) | `1.43.0` | `1.44.0` | Updates `charm.land/lipgloss/v2` from 2.0.3 to 2.0.4 - [Release notes](https://github.com/charmbracelet/lipgloss/releases) - [Commits](https://github.com/charmbracelet/lipgloss/compare/v2.0.3...v2.0.4) Updates `github.com/anthropics/anthropic-sdk-go` from 1.47.0 to 1.52.0 - [Release notes](https://github.com/anthropics/anthropic-sdk-go/releases) - [Changelog](https://github.com/anthropics/anthropic-sdk-go/blob/main/CHANGELOG.md) - [Commits](https://github.com/anthropics/anthropic-sdk-go/compare/v1.47.0...v1.52.0) Updates `github.com/openai/openai-go/v3` from 3.39.0 to 3.41.0 - [Release notes](https://github.com/openai/openai-go/releases) - [Changelog](https://github.com/openai/openai-go/blob/main/CHANGELOG.md) - [Commits](https://github.com/openai/openai-go/compare/v3.39.0...v3.41.0) Updates `go.opentelemetry.io/otel` from 1.43.0 to 1.44.0 - [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases) - [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md) - [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.43.0...v1.44.0) Updates `go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc` from 1.43.0 to 1.44.0 - [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases) - [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md) - [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.43.0...v1.44.0) Updates `go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc` from 1.43.0 to 1.44.0 - [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases) - [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md) - [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.43.0...v1.44.0) Updates `go.opentelemetry.io/otel/exporters/stdout/stdoutmetric` from 1.43.0 to 1.44.0 - [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases) - [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md) - [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.43.0...v1.44.0) Updates `go.opentelemetry.io/otel/exporters/stdout/stdouttrace` from 1.43.0 to 1.44.0 - [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases) - [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md) - [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.43.0...v1.44.0) Updates `go.opentelemetry.io/otel/metric` from 1.43.0 to 1.44.0 - [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases) - [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md) - [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.43.0...v1.44.0) Updates `go.opentelemetry.io/otel/sdk` from 1.43.0 to 1.44.0 - [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases) - [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md) - [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.43.0...v1.44.0) Updates `go.opentelemetry.io/otel/sdk/metric` from 1.43.0 to 1.44.0 - [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases) - [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md) - [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.43.0...v1.44.0) Updates `go.opentelemetry.io/otel/trace` from 1.43.0 to 1.44.0 - [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases) - [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md) - [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.43.0...v1.44.0) --- updated-dependencies: - dependency-name: charm.land/lipgloss/v2 dependency-version: 2.0.4 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: go-dependencies - dependency-name: github.com/anthropics/anthropic-sdk-go dependency-version: 1.52.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: go-dependencies - dependency-name: github.com/openai/openai-go/v3 dependency-version: 3.41.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: go-dependencies - dependency-name: go.opentelemetry.io/otel dependency-version: 1.44.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: go-dependencies - dependency-name: go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc dependency-version: 1.44.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: go-dependencies - dependency-name: go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc dependency-version: 1.44.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: go-dependencies - dependency-name: go.opentelemetry.io/otel/exporters/stdout/stdoutmetric dependency-version: 1.44.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: go-dependencies - dependency-name: go.opentelemetry.io/otel/exporters/stdout/stdouttrace dependency-version: 1.44.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: go-dependencies - dependency-name: go.opentelemetry.io/otel/metric dependency-version: 1.44.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: go-dependencies - dependency-name: go.opentelemetry.io/otel/sdk dependency-version: 1.44.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: go-dependencies - dependency-name: go.opentelemetry.io/otel/sdk/metric dependency-version: 1.44.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: go-dependencies - dependency-name: go.opentelemetry.io/otel/trace dependency-version: 1.44.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: go-dependencies ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 38 ++++++++++++++-------------- go.sum | 78 ++++++++++++++++++++++++++++++---------------------------- 2 files changed, 59 insertions(+), 57 deletions(-) diff --git a/go.mod b/go.mod index c6dcda1..3f48112 100644 --- a/go.mod +++ b/go.mod @@ -5,20 +5,20 @@ go 1.25.0 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.52.0 github.com/bmatcuk/doublestar/v4 v4.10.0 - github.com/openai/openai-go/v3 v3.39.0 + 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/otlptrace/otlptracegrpc 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 ( @@ -39,7 +39,7 @@ require ( github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // 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 @@ -53,15 +53,15 @@ require ( github.com/tidwall/sjson v1.2.5 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // 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/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 ) diff --git a/go.sum b/go.sum index 291ab0b..5085065 100644 --- a/go.sum +++ b/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.52.0 h1:1TB9jt4DN87VMwS/hB1VK26tYzK0ipEOtqPaPGFtJQg= +github.com/anthropics/anthropic-sdk-go v1.52.0/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= @@ -55,8 +55,8 @@ 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/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= @@ -65,8 +65,8 @@ github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3Ry github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= 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= @@ -93,26 +93,28 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 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/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/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 +123,22 @@ 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/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= 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= From 0881ad87b849ffee9faef3b7fb9cfb8b2f914f9c Mon Sep 17 00:00:00 2001 From: kite Date: Mon, 29 Jun 2026 11:37:23 +0800 Subject: [PATCH 19/87] fix(ci): use --replace-all for git safe.directory to prevent self-hosted runner failure On self-hosted runners, _github_home/.gitconfig persists across jobs. The ocr-review workflow used --add which accumulated multiple safe.directory values over time. Once multiple values existed, other workflows using plain git config (without --add/--replace-all) failed with "cannot overwrite multiple values with a single value". Unify all workflows to use --replace-all, which clears previous values and writes exactly one entry regardless of prior state. --- .github/workflows/ci.yml | 2 +- .github/workflows/deploy-pages.yml | 2 +- .github/workflows/ocr-review.yml | 2 +- .github/workflows/release.yml | 6 +++--- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ef44b2a..31557d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: - 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 ./... diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index 14e7822..e89f248 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -25,7 +25,7 @@ jobs: - 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 diff --git a/.github/workflows/ocr-review.yml b/.github/workflows/ocr-review.yml index 4ffcc22..e6bee99 100644 --- a/.github/workflows/ocr-review.yml +++ b/.github/workflows/ocr-review.yml @@ -73,7 +73,7 @@ jobs: ref: ${{ github.event.pull_request.head.sha }} - name: Mark repository as safe directory - run: git config --global --add safe.directory '*' + run: git config --global --replace-all safe.directory '*' - name: Fetch PR head ref (ensures fork commits are available) run: git fetch origin pull/${{ github.event.pull_request.number }}/head diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1536910..e11ee7f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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: @@ -73,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 @@ -167,7 +167,7 @@ jobs: - 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 From 08e7846a37f97dfacdac62ec2e61e8fffd0a68e6 Mon Sep 17 00:00:00 2001 From: "hezheng.lsw" Date: Mon, 29 Jun 2026 14:42:43 +0800 Subject: [PATCH 20/87] refactor: cleanup design drafts, rename Chinese assets, UI improvements (#239) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(pages): redesign landing page UI with i18n support - Redesign all sections based on design file: Navbar, Hero, Highlights, UseCases, Features, Benchmark, QuickStart, Footer - Add scroll fade-in animation with IntersectionObserver - Implement full i18n (English/Chinese) with language toggle button - Add SVG icons and image assets - Style refinements: table borders, step containers, button styles, spacing * feat(pages): redesign Docs page with i18n, responsive sidebar, and aligned copy interaction - Redesign DocsPage UI based on reference HTML design - Add i18n support for all Docs content (en/zh/ja) - Fix right sidebar CONTENTS nav with fixed positioning - Add container backgrounds (4% opacity, 16% border) - Hide CONTENTS sidebar on mobile instead of alternative UI - Align copy icon and toast interaction with QuickStart section - Add new SVG icon assets for docs page - Add Japanese language support - Add ColorBends animated background component - Add responsive hooks and FadeInSection animation - Add Benchmark, Features, QuickStart standalone pages - Update Navbar, Footer, and LandingPage components * refactor: cleanup design drafts, rename Chinese assets, UI improvements - Remove unused design draft folders (html-files, html-files (1), inpt, doc) - Rename Chinese-named image files (容器-4_*.svg → provider-*.svg) - Add language switcher icon to Navbar with dynamic character display - Align Footer padding with Navbar (32px desktop, 16px mobile) - Fix CONTENTS sidebar position on wide screens - Add i18n support for Docs CONTENTS title - Fix clipboard copy fallback for HTTP/LAN environments - Adjust Toast padding, benchmark medal sizes - Swap highlight stats positions (Adoption Rate ↔ Active Users) - Update stat4 value to 25.10% and stat5 caption to Battle-tested - Unify Hero buttons size with Navbar Get Started button * refactor: rename icon assets with semantic English names and cleanup unused files - Rename all svg_*.svg icons to semantic names based on usage context: - icon-feature-*.svg for feature section icons - icon-usecase-*.svg for use case section icons - icon-chevron-*.svg, icon-copy.svg, icon-play.svg for UI controls - icon-github.svg, icon-language.svg, icon-sort.svg, icon-terminal-prompt.svg - Remove unused assets: 5 unreferenced svg icons, image_9e7821.png, provider-8.svg - Remove debug screenshots: debug-screenshot.png, debug-wide.png - Remove .vsix build artifact and add *.vsix to .gitignore - Update all import paths in components accordingly * fix: address PR review feedback - bugs and improvements - Fix copy-paste bug: step3Label1 had wrong text in all 3 languages - en: 'Interactive Setup (Recommended)' → 'Review Commands' - zh: '交互式设置(推荐)' → '运行审查命令' - ja: '対話式セットアップ(推奨)' → 'レビュー実行' - Fix inconsistent indentation (4 spaces → 2 spaces) in i18n files - Add .catch() handler for navigator.clipboard.writeText() in QuickStartSection - Check document.execCommand('copy') return value before showing toast - Extract fallbackCopy helper to deduplicate clipboard logic - Remove unused useCallback import from HighlightsSection - Move @types/three from dependencies to devDependencies - Add i18n key 'hero.terminal' for hardcoded Terminal label * fix: revert vscode .gitignore change and remove duplicate root SVGs - Revert extensions/vscode/.gitignore to upstream state (remove *.vsix rule) - Remove root-level brandicon.svg, claude code icon.svg, codex icon.svg (identical copies already exist in pages/src/assets/images/ with proper names) * fix: reorder highlights stats to match design reference - Reorder stats: 20K+ Active Users → > 30% Adoption Rate → 1M+ Tasks → 1/9 Token Cost → 25.10% AACR-BENCH - Update stat1 value from 30K+ to 20K+ - Sync all three languages (en/zh/ja) * chore: remove unused debug screenshots * chore: remove unused image_9e7821.png --- pages/package.json | 4 +- pages/src/App.tsx | 9 +- pages/src/assets.d.ts | 10 + pages/src/assets/icons/doc-check-circle.svg | 1 + pages/src/assets/icons/doc-contents.svg | 1 + pages/src/assets/icons/doc-copy.svg | 1 + pages/src/assets/icons/doc-download.svg | 1 + pages/src/assets/icons/doc-edit.svg | 1 + pages/src/assets/icons/icon-chevron-down.svg | 1 + pages/src/assets/icons/icon-chevron-right.svg | 1 + pages/src/assets/icons/icon-copy.svg | 1 + .../icons/icon-feature-architecture.svg | 1 + .../assets/icons/icon-feature-compression.svg | 1 + .../assets/icons/icon-feature-concurrent.svg | 1 + .../assets/icons/icon-feature-multi-model.svg | 1 + .../assets/icons/icon-feature-positioning.svg | 1 + pages/src/assets/icons/icon-feature-rules.svg | 1 + pages/src/assets/icons/icon-github.svg | 1 + pages/src/assets/icons/icon-language.svg | 1 + pages/src/assets/icons/icon-play.svg | 1 + pages/src/assets/icons/icon-sort.svg | 1 + .../src/assets/icons/icon-terminal-prompt.svg | 1 + .../assets/icons/icon-usecase-developer.svg | 1 + .../assets/icons/icon-usecase-platform-a.svg | 1 + .../assets/icons/icon-usecase-platform-b.svg | 1 + .../assets/icons/icon-usecase-platform-c.svg | 1 + .../assets/icons/icon-usecase-researcher.svg | 1 + pages/src/assets/icons/trophy-bronze.svg | 1 + pages/src/assets/icons/trophy-gold.svg | 1 + pages/src/assets/icons/trophy-silver.svg | 1 + pages/src/assets/images/brandicon.svg | 1 + pages/src/assets/images/icon-claude-code.svg | 1 + pages/src/assets/images/icon-codex.svg | 1 + pages/src/assets/images/provider-1.svg | 1 + pages/src/assets/images/provider-2.svg | 1 + pages/src/assets/images/provider-3.svg | 1 + pages/src/assets/images/provider-4.svg | 1 + pages/src/assets/images/provider-5.svg | 1 + pages/src/assets/images/provider-6.svg | 1 + pages/src/assets/images/provider-7.svg | 1 + pages/src/components/BenchmarkSection.tsx | 703 ++++++----------- pages/src/components/ColorBends.css | 11 + pages/src/components/ColorBends.tsx | 321 ++++++++ pages/src/components/FadeInSection.tsx | 43 + pages/src/components/FeaturesSection.tsx | 157 ++-- pages/src/components/Footer.tsx | 125 +++ pages/src/components/HeroSection.tsx | 495 ++++++++---- pages/src/components/HighlightsSection.tsx | 195 +++-- pages/src/components/LandingPage.tsx | 53 +- pages/src/components/Navbar.tsx | 302 ++++--- pages/src/components/QuickStartSection.tsx | 319 ++++---- pages/src/components/UseCasesSection.tsx | 148 ++++ pages/src/hooks/useResponsive.ts | 28 + pages/src/i18n/context.tsx | 5 +- pages/src/i18n/en.ts | 121 ++- pages/src/i18n/ja.ts | 184 +++++ pages/src/i18n/types.ts | 2 +- pages/src/i18n/zh.ts | 241 +++--- pages/src/index.tsx | 5 + pages/src/pages/BenchmarkPage.tsx | 19 + pages/src/pages/DocsPage.tsx | 744 +++++++++--------- pages/src/pages/FeaturesPage.tsx | 39 + pages/src/pages/QuickStartPage.tsx | 19 + pages/src/styles/index.css | 443 ++--------- pages/webpack.config.js | 8 +- 65 files changed, 2790 insertions(+), 2000 deletions(-) create mode 100644 pages/src/assets/icons/doc-check-circle.svg create mode 100644 pages/src/assets/icons/doc-contents.svg create mode 100644 pages/src/assets/icons/doc-copy.svg create mode 100644 pages/src/assets/icons/doc-download.svg create mode 100644 pages/src/assets/icons/doc-edit.svg create mode 100644 pages/src/assets/icons/icon-chevron-down.svg create mode 100644 pages/src/assets/icons/icon-chevron-right.svg create mode 100644 pages/src/assets/icons/icon-copy.svg create mode 100644 pages/src/assets/icons/icon-feature-architecture.svg create mode 100644 pages/src/assets/icons/icon-feature-compression.svg create mode 100644 pages/src/assets/icons/icon-feature-concurrent.svg create mode 100644 pages/src/assets/icons/icon-feature-multi-model.svg create mode 100644 pages/src/assets/icons/icon-feature-positioning.svg create mode 100644 pages/src/assets/icons/icon-feature-rules.svg create mode 100644 pages/src/assets/icons/icon-github.svg create mode 100644 pages/src/assets/icons/icon-language.svg create mode 100644 pages/src/assets/icons/icon-play.svg create mode 100644 pages/src/assets/icons/icon-sort.svg create mode 100644 pages/src/assets/icons/icon-terminal-prompt.svg create mode 100644 pages/src/assets/icons/icon-usecase-developer.svg create mode 100644 pages/src/assets/icons/icon-usecase-platform-a.svg create mode 100644 pages/src/assets/icons/icon-usecase-platform-b.svg create mode 100644 pages/src/assets/icons/icon-usecase-platform-c.svg create mode 100644 pages/src/assets/icons/icon-usecase-researcher.svg create mode 100644 pages/src/assets/icons/trophy-bronze.svg create mode 100644 pages/src/assets/icons/trophy-gold.svg create mode 100644 pages/src/assets/icons/trophy-silver.svg create mode 100644 pages/src/assets/images/brandicon.svg create mode 100644 pages/src/assets/images/icon-claude-code.svg create mode 100644 pages/src/assets/images/icon-codex.svg create mode 100644 pages/src/assets/images/provider-1.svg create mode 100644 pages/src/assets/images/provider-2.svg create mode 100644 pages/src/assets/images/provider-3.svg create mode 100644 pages/src/assets/images/provider-4.svg create mode 100644 pages/src/assets/images/provider-5.svg create mode 100644 pages/src/assets/images/provider-6.svg create mode 100644 pages/src/assets/images/provider-7.svg create mode 100644 pages/src/components/ColorBends.css create mode 100644 pages/src/components/ColorBends.tsx create mode 100644 pages/src/components/FadeInSection.tsx create mode 100644 pages/src/components/Footer.tsx create mode 100644 pages/src/components/UseCasesSection.tsx create mode 100644 pages/src/hooks/useResponsive.ts create mode 100644 pages/src/i18n/ja.ts create mode 100644 pages/src/pages/BenchmarkPage.tsx create mode 100644 pages/src/pages/FeaturesPage.tsx create mode 100644 pages/src/pages/QuickStartPage.tsx diff --git a/pages/package.json b/pages/package.json index 4ca474a..f6a0775 100644 --- a/pages/package.json +++ b/pages/package.json @@ -10,7 +10,8 @@ "dependencies": { "react": "^18.2.0", "react-dom": "^18.2.0", - "react-router-dom": "^6.8.0" + "react-router-dom": "^6.8.0", + "three": "^0.185.0" }, "overrides": { "fast-uri": "^3.1.2" @@ -22,6 +23,7 @@ "@babel/preset-typescript": "^7.23.3", "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", + "@types/three": "^0.185.0", "autoprefixer": "^10.4.16", "babel-loader": "^9.1.3", "css-loader": "^6.8.1", diff --git a/pages/src/App.tsx b/pages/src/App.tsx index d5e58e0..7b2f761 100644 --- a/pages/src/App.tsx +++ b/pages/src/App.tsx @@ -1,13 +1,18 @@ import React from 'react'; import { Routes, Route } from 'react-router-dom'; import LandingPage from './components/LandingPage'; +import FeaturesPage from './pages/FeaturesPage'; +import BenchmarkPage from './pages/BenchmarkPage'; +import QuickStartPage from './pages/QuickStartPage'; import DocsPage from './pages/DocsPage'; const App: React.FC = () => { return ( - } /> - } /> + } /> + } /> + } /> + } /> ); }; diff --git a/pages/src/assets.d.ts b/pages/src/assets.d.ts index 3623b38..6ac3aff 100644 --- a/pages/src/assets.d.ts +++ b/pages/src/assets.d.ts @@ -2,3 +2,13 @@ declare module '*.svg' { const src: string; export default src; } + +declare module '*.png' { + const src: string; + export default src; +} + +declare module '*.jpg' { + const src: string; + export default src; +} diff --git a/pages/src/assets/icons/doc-check-circle.svg b/pages/src/assets/icons/doc-check-circle.svg new file mode 100644 index 0000000..151d981 --- /dev/null +++ b/pages/src/assets/icons/doc-check-circle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pages/src/assets/icons/doc-contents.svg b/pages/src/assets/icons/doc-contents.svg new file mode 100644 index 0000000..e240b3f --- /dev/null +++ b/pages/src/assets/icons/doc-contents.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pages/src/assets/icons/doc-copy.svg b/pages/src/assets/icons/doc-copy.svg new file mode 100644 index 0000000..bc0531f --- /dev/null +++ b/pages/src/assets/icons/doc-copy.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pages/src/assets/icons/doc-download.svg b/pages/src/assets/icons/doc-download.svg new file mode 100644 index 0000000..b06a6f3 --- /dev/null +++ b/pages/src/assets/icons/doc-download.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pages/src/assets/icons/doc-edit.svg b/pages/src/assets/icons/doc-edit.svg new file mode 100644 index 0000000..2db55e4 --- /dev/null +++ b/pages/src/assets/icons/doc-edit.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pages/src/assets/icons/icon-chevron-down.svg b/pages/src/assets/icons/icon-chevron-down.svg new file mode 100644 index 0000000..48f3a53 --- /dev/null +++ b/pages/src/assets/icons/icon-chevron-down.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pages/src/assets/icons/icon-chevron-right.svg b/pages/src/assets/icons/icon-chevron-right.svg new file mode 100644 index 0000000..6ed70a2 --- /dev/null +++ b/pages/src/assets/icons/icon-chevron-right.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pages/src/assets/icons/icon-copy.svg b/pages/src/assets/icons/icon-copy.svg new file mode 100644 index 0000000..8bc28fa --- /dev/null +++ b/pages/src/assets/icons/icon-copy.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pages/src/assets/icons/icon-feature-architecture.svg b/pages/src/assets/icons/icon-feature-architecture.svg new file mode 100644 index 0000000..faaf445 --- /dev/null +++ b/pages/src/assets/icons/icon-feature-architecture.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pages/src/assets/icons/icon-feature-compression.svg b/pages/src/assets/icons/icon-feature-compression.svg new file mode 100644 index 0000000..69e7ac1 --- /dev/null +++ b/pages/src/assets/icons/icon-feature-compression.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pages/src/assets/icons/icon-feature-concurrent.svg b/pages/src/assets/icons/icon-feature-concurrent.svg new file mode 100644 index 0000000..dac64e2 --- /dev/null +++ b/pages/src/assets/icons/icon-feature-concurrent.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pages/src/assets/icons/icon-feature-multi-model.svg b/pages/src/assets/icons/icon-feature-multi-model.svg new file mode 100644 index 0000000..e47278a --- /dev/null +++ b/pages/src/assets/icons/icon-feature-multi-model.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pages/src/assets/icons/icon-feature-positioning.svg b/pages/src/assets/icons/icon-feature-positioning.svg new file mode 100644 index 0000000..c1f115a --- /dev/null +++ b/pages/src/assets/icons/icon-feature-positioning.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pages/src/assets/icons/icon-feature-rules.svg b/pages/src/assets/icons/icon-feature-rules.svg new file mode 100644 index 0000000..4e6f6b0 --- /dev/null +++ b/pages/src/assets/icons/icon-feature-rules.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pages/src/assets/icons/icon-github.svg b/pages/src/assets/icons/icon-github.svg new file mode 100644 index 0000000..d66421f --- /dev/null +++ b/pages/src/assets/icons/icon-github.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pages/src/assets/icons/icon-language.svg b/pages/src/assets/icons/icon-language.svg new file mode 100644 index 0000000..69351c6 --- /dev/null +++ b/pages/src/assets/icons/icon-language.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pages/src/assets/icons/icon-play.svg b/pages/src/assets/icons/icon-play.svg new file mode 100644 index 0000000..98ff051 --- /dev/null +++ b/pages/src/assets/icons/icon-play.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pages/src/assets/icons/icon-sort.svg b/pages/src/assets/icons/icon-sort.svg new file mode 100644 index 0000000..b7334e8 --- /dev/null +++ b/pages/src/assets/icons/icon-sort.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pages/src/assets/icons/icon-terminal-prompt.svg b/pages/src/assets/icons/icon-terminal-prompt.svg new file mode 100644 index 0000000..4efb04f --- /dev/null +++ b/pages/src/assets/icons/icon-terminal-prompt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pages/src/assets/icons/icon-usecase-developer.svg b/pages/src/assets/icons/icon-usecase-developer.svg new file mode 100644 index 0000000..d6d0fef --- /dev/null +++ b/pages/src/assets/icons/icon-usecase-developer.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pages/src/assets/icons/icon-usecase-platform-a.svg b/pages/src/assets/icons/icon-usecase-platform-a.svg new file mode 100644 index 0000000..5a5d8a6 --- /dev/null +++ b/pages/src/assets/icons/icon-usecase-platform-a.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pages/src/assets/icons/icon-usecase-platform-b.svg b/pages/src/assets/icons/icon-usecase-platform-b.svg new file mode 100644 index 0000000..475c382 --- /dev/null +++ b/pages/src/assets/icons/icon-usecase-platform-b.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pages/src/assets/icons/icon-usecase-platform-c.svg b/pages/src/assets/icons/icon-usecase-platform-c.svg new file mode 100644 index 0000000..66185fe --- /dev/null +++ b/pages/src/assets/icons/icon-usecase-platform-c.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pages/src/assets/icons/icon-usecase-researcher.svg b/pages/src/assets/icons/icon-usecase-researcher.svg new file mode 100644 index 0000000..ed663dc --- /dev/null +++ b/pages/src/assets/icons/icon-usecase-researcher.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pages/src/assets/icons/trophy-bronze.svg b/pages/src/assets/icons/trophy-bronze.svg new file mode 100644 index 0000000..8b0e476 --- /dev/null +++ b/pages/src/assets/icons/trophy-bronze.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pages/src/assets/icons/trophy-gold.svg b/pages/src/assets/icons/trophy-gold.svg new file mode 100644 index 0000000..1ec91f8 --- /dev/null +++ b/pages/src/assets/icons/trophy-gold.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pages/src/assets/icons/trophy-silver.svg b/pages/src/assets/icons/trophy-silver.svg new file mode 100644 index 0000000..5e62c3a --- /dev/null +++ b/pages/src/assets/icons/trophy-silver.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pages/src/assets/images/brandicon.svg b/pages/src/assets/images/brandicon.svg new file mode 100644 index 0000000..d4fe70e --- /dev/null +++ b/pages/src/assets/images/brandicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pages/src/assets/images/icon-claude-code.svg b/pages/src/assets/images/icon-claude-code.svg new file mode 100644 index 0000000..2f23cf2 --- /dev/null +++ b/pages/src/assets/images/icon-claude-code.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pages/src/assets/images/icon-codex.svg b/pages/src/assets/images/icon-codex.svg new file mode 100644 index 0000000..4b48dad --- /dev/null +++ b/pages/src/assets/images/icon-codex.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pages/src/assets/images/provider-1.svg b/pages/src/assets/images/provider-1.svg new file mode 100644 index 0000000..ecc9fd1 --- /dev/null +++ b/pages/src/assets/images/provider-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pages/src/assets/images/provider-2.svg b/pages/src/assets/images/provider-2.svg new file mode 100644 index 0000000..35b388d --- /dev/null +++ b/pages/src/assets/images/provider-2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pages/src/assets/images/provider-3.svg b/pages/src/assets/images/provider-3.svg new file mode 100644 index 0000000..e892e26 --- /dev/null +++ b/pages/src/assets/images/provider-3.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pages/src/assets/images/provider-4.svg b/pages/src/assets/images/provider-4.svg new file mode 100644 index 0000000..ecf3ffd --- /dev/null +++ b/pages/src/assets/images/provider-4.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pages/src/assets/images/provider-5.svg b/pages/src/assets/images/provider-5.svg new file mode 100644 index 0000000..992baf3 --- /dev/null +++ b/pages/src/assets/images/provider-5.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pages/src/assets/images/provider-6.svg b/pages/src/assets/images/provider-6.svg new file mode 100644 index 0000000..9038235 --- /dev/null +++ b/pages/src/assets/images/provider-6.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pages/src/assets/images/provider-7.svg b/pages/src/assets/images/provider-7.svg new file mode 100644 index 0000000..16120ef --- /dev/null +++ b/pages/src/assets/images/provider-7.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pages/src/components/BenchmarkSection.tsx b/pages/src/components/BenchmarkSection.tsx index baf0096..9c4b1ef 100644 --- a/pages/src/components/BenchmarkSection.tsx +++ b/pages/src/components/BenchmarkSection.tsx @@ -1,497 +1,262 @@ -import React, { useState } from 'react'; +import React, { useState, useMemo, useCallback } from 'react'; import { useTranslation } from '../i18n'; -import { OcrIcon, ClaudeIcon, OpenAIIcon } from './icons'; +import { useResponsive } from '../hooks/useResponsive'; +import ocrIcon1 from '../assets/images/provider-1.svg'; +import ocrIcon2 from '../assets/images/provider-2.svg'; +import ocrIcon3 from '../assets/images/provider-3.svg'; +import ocrIcon4 from '../assets/images/provider-4.svg'; +import ocrIcon5 from '../assets/images/provider-5.svg'; +import ocrIcon6 from '../assets/images/provider-6.svg'; +import ocrIcon7 from '../assets/images/provider-7.svg'; +import claudeCodeIcon from '../assets/images/icon-claude-code.svg'; +import codexIcon from '../assets/images/icon-codex.svg'; +import sortIcon from '../assets/icons/icon-sort.svg'; +import trophyGold from '../assets/icons/trophy-gold.svg'; +import trophySilver from '../assets/icons/trophy-silver.svg'; +import trophyBronze from '../assets/icons/trophy-bronze.svg'; -interface BenchmarkEntry { +const TROPHY_ICONS = [trophyGold, trophySilver, trophyBronze]; + +interface BenchmarkRow { model: string; - company: string; - sourceType: 'ocr' | 'cc' | 'codex'; - version: string; - precision?: number; - precisionDetail?: string; - recall?: number; - recallDetail?: string; - f1?: number; - avgTime?: string; - avgInputToken?: string; - avgOutputToken?: string; - avgTotalToken?: string; -} - -const OCR_VERSION = 'v1.3.1'; -const CC_VERSION = 'v2.1.169'; -const CODEX_VERSION = 'v0.140.0'; - -const sourceColorMap: Record = { - ocr: 'text-brand-400', - cc: 'text-[#D97757]', - codex: 'text-[#10a37f]', -}; -const sourceNameMap: Record = { - ocr: 'Open Code Review', - cc: 'Claude Code', - codex: 'Codex', -}; - -const benchmarkData: BenchmarkEntry[] = [ - { - model: 'Claude-4.6-Opus', - company: 'Anthropic', - sourceType: 'ocr', - version: OCR_VERSION, - precision: 33.90, - precisionDetail: '301/889', - recall: 20.00, - recallDetail: '301/1505', - f1: 25.10, - avgTime: '1m23s', - avgInputToken: '375K', - avgOutputToken: '10K', - avgTotalToken: '385K', - }, - { - model: 'Qwen3.7-Max', - company: 'Alibaba', - sourceType: 'ocr', - version: OCR_VERSION, - precision: 25.20, - precisionDetail: '276/1096', - recall: 18.30, - recallDetail: '276/1505', - f1: 21.20, - avgTime: '4m41s', - avgInputToken: '587K', - avgOutputToken: '38K', - avgTotalToken: '625K', - }, - { - model: 'GPT-5.5', - company: 'OpenAI', - sourceType: 'ocr', - version: OCR_VERSION, - precision: 32.10, - precisionDetail: '234/728', - recall: 15.50, - recallDetail: '234/1505', - f1: 21.00, - avgTime: '2m51s', - avgInputToken: '409K', - avgOutputToken: '13K', - avgTotalToken: '422K', - }, - { - model: 'Claude-4.8-Opus', - company: 'Anthropic', - sourceType: 'ocr', - version: OCR_VERSION, - precision: 37.80, - precisionDetail: '176/465', - recall: 11.70, - recallDetail: '176/1505', - f1: 17.90, - avgTime: '1m6s', - avgInputToken: '342K', - avgOutputToken: '11K', - avgTotalToken: '352K', - }, - { - model: 'Deepseek-V4-Pro', - company: 'DeepSeek', - sourceType: 'ocr', - version: OCR_VERSION, - precision: 30.60, - precisionDetail: '191/624', - recall: 12.70, - recallDetail: '191/1505', - f1: 17.90, - avgTime: '6m28s', - avgInputToken: '350K', - avgOutputToken: '44K', - avgTotalToken: '394K', - }, - { - model: 'GLM-5.1', - company: 'Zhipu AI', - sourceType: 'ocr', - version: OCR_VERSION, - precision: 28.90, - precisionDetail: '237/820', - recall: 15.70, - recallDetail: '237/1505', - f1: 20.40, - avgTime: '4m11s', - avgInputToken: '707K', - avgOutputToken: '36K', - avgTotalToken: '743K', - }, - { - model: 'GLM-5.2', - company: 'Zhipu AI', - sourceType: 'ocr', - version: OCR_VERSION, - precision: 32.30, - precisionDetail: '239/741', - recall: 15.90, - recallDetail: '239/1505', - f1: 21.30, - avgTime: '7m58s', - avgInputToken: '624K', - avgOutputToken: '58K', - avgTotalToken: '682K', - }, - { - model: 'Claude-4.6-Opus', - company: 'Anthropic', - sourceType: 'cc', - version: CC_VERSION, - precision: 7.23, - precisionDetail: '435/5980', - recall: 28.90, - recallDetail: '435/1505', - f1: 11.57, - avgTime: '13m6s', - avgInputToken: '5603K', - avgOutputToken: '60K', - avgTotalToken: '5664K', - }, - { - model: 'Qwen3.7-Max', - company: 'Alibaba', - sourceType: 'cc', - version: CC_VERSION, - precision: 8.23, - precisionDetail: '351/4260', - recall: 23.37, - recallDetail: '351/1505', - f1: 12.17, - avgTime: '8m6s', - avgInputToken: '5108K', - avgOutputToken: '44K', - avgTotalToken: '5153K', - }, - { - model: 'Claude-4.8-Opus', - company: 'Anthropic', - sourceType: 'cc', - version: CC_VERSION, - precision: 15.93, - precisionDetail: '191/1200', - recall: 12.70, - recallDetail: '191/1505', - f1: 14.13, - avgTime: '5m38s', - avgInputToken: '2,039K', - avgOutputToken: '23K', - avgTotalToken: '2,062K', - }, - { - model: 'Deepseek-V4-Pro', - company: 'DeepSeek', - sourceType: 'cc', - version: CC_VERSION, - precision: 8.27, - precisionDetail: '243/2945', - recall: 16.13, - recallDetail: '243/1505', - f1: 10.93, - avgTime: '14m24s', - avgInputToken: '5389K', - avgOutputToken: '60K', - avgTotalToken: '5450K', - }, - { - model: 'GLM-5.1', - company: 'Zhipu AI', - sourceType: 'cc', - version: CC_VERSION, - precision: 8.37, - precisionDetail: '313/3742', - recall: 20.80, - recallDetail: '313/1505', - f1: 11.93, - avgTime: '14m10s', - avgInputToken: '3,998K', - avgOutputToken: '39K', - avgTotalToken: '4,038K', - }, - { - model: 'GPT-5.5', - company: 'OpenAI', - sourceType: 'codex', - version: CODEX_VERSION, - precision: 27.82, - precisionDetail: '74/266', - recall: 4.92, - recallDetail: '74/1505', - f1: 8.36, - avgTime: '2m58s', - avgInputToken: '520K', - avgOutputToken: '5K', - avgTotalToken: '525K', - }, -]; - -const medalIcons: Record = { - gold: '🥇', - silver: '🥈', - bronze: '🥉', -}; - -function computeMedals( - entries: BenchmarkEntry[], - field: 'precision' | 'recall' -): Map { - const indexed = entries - .map((e, i) => ({ index: i, value: e[field] })) - .filter((e): e is { index: number; value: number } => e.value !== undefined) - .sort((a, b) => b.value - a.value); - - const medals = new Map(); - const types = ['gold', 'silver', 'bronze']; - indexed.slice(0, 3).forEach((item, i) => { - medals.set(item.index, types[i]); - }); - return medals; + provider: string; + source: string; + sourceIcon?: string; + f1: string; + f1Value: number; + precision: string; + precisionValue: number; + precisionDetail: string; + recall: string; + recallValue: number; + recallDetail: string; + avgTime: string; + avgToken: string; + tokenDetail: string; + precisionMedal?: number; // 0=gold, 1=silver, 2=bronze + recallMedal?: number; // 0=gold, 1=silver, 2=bronze } type SortField = 'f1' | 'precision' | 'recall'; +type SortOrder = 'desc' | 'asc'; + +const rawRows: BenchmarkRow[] = [ + { model: 'Claude-4.6-Opus', provider: 'Anthropic', source: 'Open Code Review', sourceIcon: ocrIcon1, f1: '25.10%', f1Value: 25.10, precision: '33.90%', precisionValue: 33.90, precisionDetail: '301/889', recall: '20.00%', recallValue: 20.00, recallDetail: '301/1505', avgTime: '1m23s', avgToken: '385K', tokenDetail: '375K / 10K', precisionMedal: 1 }, + { model: 'GLM-5.2', provider: 'Zhipu AI', source: 'Open Code Review', sourceIcon: ocrIcon2, f1: '21.30%', f1Value: 21.30, precision: '32.30%', precisionValue: 32.30, precisionDetail: '239/741', recall: '15.90%', recallValue: 15.90, recallDetail: '239/1505', avgTime: '7m58s', avgToken: '682K', tokenDetail: '624K / 58K', precisionMedal: 2 }, + { model: 'Qwen3.7-Max', provider: 'Alibaba', source: 'Open Code Review', sourceIcon: ocrIcon3, f1: '21.20%', f1Value: 21.20, precision: '25.20%', precisionValue: 25.20, precisionDetail: '276/1096', recall: '18.30%', recallValue: 18.30, recallDetail: '276/1505', avgTime: '4m41s', avgToken: '625K', tokenDetail: '587K / 38K' }, + { model: 'GPT-5.5', provider: 'OpenAI', source: 'Open Code Review', sourceIcon: ocrIcon4, f1: '21.00%', f1Value: 21.00, precision: '32.10%', precisionValue: 32.10, precisionDetail: '234/728', recall: '15.50%', recallValue: 15.50, recallDetail: '234/1505', avgTime: '2m51s', avgToken: '422K', tokenDetail: '409K / 13K' }, + { model: 'GLM-5.1', provider: 'Zhipu AI', source: 'Open Code Review', sourceIcon: ocrIcon5, f1: '20.40%', f1Value: 20.40, precision: '28.90%', precisionValue: 28.90, precisionDetail: '237/820', recall: '15.70%', recallValue: 15.70, recallDetail: '237/1505', avgTime: '4m11s', avgToken: '743K', tokenDetail: '707K / 36K' }, + { model: 'Claude-4.8-Opus', provider: 'Anthropic', source: 'Open Code Review', sourceIcon: ocrIcon6, f1: '17.90%', f1Value: 17.90, precision: '37.80%', precisionValue: 37.80, precisionDetail: '176/465', recall: '11.70%', recallValue: 11.70, recallDetail: '176/1505', avgTime: '1m6s', avgToken: '352K', tokenDetail: '342K / 11K', precisionMedal: 0 }, + { model: 'Deepseek-V4-Pro', provider: 'DeepSeek', source: 'Open Code Review', sourceIcon: ocrIcon7, f1: '17.90%', f1Value: 17.90, precision: '30.60%', precisionValue: 30.60, precisionDetail: '191/624', recall: '12.70%', recallValue: 12.70, recallDetail: '191/1505', avgTime: '6m28s', avgToken: '394K', tokenDetail: '350K / 44K' }, + { model: 'Claude-4.8-Opus', provider: 'Anthropic', source: 'Claude Code', sourceIcon: claudeCodeIcon, f1: '14.13%', f1Value: 14.13, precision: '15.93%', precisionValue: 15.93, precisionDetail: '191/1200', recall: '12.70%', recallValue: 12.70, recallDetail: '191/1505', avgTime: '5m38s', avgToken: '2,062K', tokenDetail: '2,039K / 23K' }, + { model: 'Qwen3.7-Max', provider: 'Alibaba', source: 'Claude Code', sourceIcon: claudeCodeIcon, f1: '12.17%', f1Value: 12.17, precision: '8.23%', precisionValue: 8.23, precisionDetail: '351/4260', recall: '23.37%', recallValue: 23.37, recallDetail: '351/1505', avgTime: '8m6s', avgToken: '5,153K', tokenDetail: '5,108K / 44K', recallMedal: 1 }, + { model: 'GLM-5.1', provider: 'Zhipu AI', source: 'Claude Code', sourceIcon: claudeCodeIcon, f1: '11.93%', f1Value: 11.93, precision: '8.37%', precisionValue: 8.37, precisionDetail: '313/3742', recall: '20.80%', recallValue: 20.80, recallDetail: '313/1505', avgTime: '14m10s', avgToken: '4,038K', tokenDetail: '3,998K / 39K', recallMedal: 2 }, + { model: 'Claude-4.6-Opus', provider: 'Anthropic', source: 'Claude Code', sourceIcon: claudeCodeIcon, f1: '11.57%', f1Value: 11.57, precision: '7.23%', precisionValue: 7.23, precisionDetail: '435/5980', recall: '28.90%', recallValue: 28.90, recallDetail: '435/1505', avgTime: '13m6s', avgToken: '5,664K', tokenDetail: '5,603K / 60K', recallMedal: 0 }, + { model: 'Deepseek-V4-Pro', provider: 'DeepSeek', source: 'Claude Code', sourceIcon: claudeCodeIcon, f1: '10.93%', f1Value: 10.93, precision: '8.27%', precisionValue: 8.27, precisionDetail: '243/2945', recall: '16.13%', recallValue: 16.13, recallDetail: '243/1505', avgTime: '14m24s', avgToken: '5,450K', tokenDetail: '5,389K / 60K' }, + { model: 'GPT-5.5', provider: 'OpenAI', source: 'Codex', sourceIcon: codexIcon, f1: '8.36%', f1Value: 8.36, precision: '27.82%', precisionValue: 27.82, precisionDetail: '74/266', recall: '4.92%', recallValue: 4.92, recallDetail: '74/1505', avgTime: '2m58s', avgToken: '525K', tokenDetail: '520K / 5K' }, +]; + +const MEDAL_RANKS = ['🥇', '🥈', '🥉']; const BenchmarkSection: React.FC = () => { - const [hoveredRow, setHoveredRow] = useState(null); - const [sortField, setSortField] = useState('f1'); const { t } = useTranslation(); + const { isMobile, isTablet } = useResponsive(); + const [sortField, setSortField] = useState('f1'); + const [sortOrder, setSortOrder] = useState('desc'); - const sortedData = [...benchmarkData].sort((a, b) => { - const av = a[sortField]; - const bv = b[sortField]; - if (av == null && bv == null) return 0; - if (av == null) return 1; - if (bv == null) return -1; - return bv - av; - }); + const sortedRows = useMemo(() => { + const key = `${sortField}Value` as keyof BenchmarkRow; + const sorted = [...rawRows].sort((a, b) => { + const aVal = a[key] as number; + const bVal = b[key] as number; + return sortOrder === 'desc' ? bVal - aVal : aVal - bVal; + }); + return sorted; + }, [sortField, sortOrder]); - const precisionMedals = computeMedals(sortedData, 'precision'); - const recallMedals = computeMedals(sortedData, 'recall'); - - const ranks = new Map(); - let rank = 0; - sortedData.forEach((entry, index) => { - if (entry[sortField] != null) { - rank++; - ranks.set(index, rank); + const handleSort = useCallback((field: SortField) => { + if (sortField === field) { + setSortOrder(prev => prev === 'desc' ? 'asc' : 'desc'); + } else { + setSortField(field); + setSortOrder('desc'); } - }); + }, [sortField]); + + const sortableHeaders: { key: string; label: string; field?: SortField }[] = [ + { key: 'rank', label: t('benchmark.colRank') }, + { key: 'model', label: t('benchmark.colModel') }, + { key: 'source', label: t('benchmark.colSource') }, + { key: 'f1', label: t('benchmark.colF1'), field: 'f1' }, + { key: 'precision', label: t('benchmark.colPrecision'), field: 'precision' }, + { key: 'recall', label: t('benchmark.colRecall'), field: 'recall' }, + { key: 'avgTime', label: t('benchmark.colAvgTime') }, + { key: 'avgToken', label: t('benchmark.colAvgToken') }, + ]; return ( -

-
- -
-
-
-
-

- {t('benchmark.sectionLabel')} -

-

- {t('benchmark.title')} -

-

- {t('benchmark.subtitlePreRepos')} - 50 - {t('benchmark.subtitlePrePRs')} - 200 - {t('benchmark.subtitlePreLangs')} - 10 - {t('benchmark.subtitleEnd')} -

-
- - {/* Legend */} -
-
- - Open Code Review · {OCR_VERSION} -
-
- - Claude Code · {CC_VERSION} · /code-review -
-
- - Codex · {CODEX_VERSION} · /review -
-
- - {/* Table */} -
- {/* Header */} -
-
{t('benchmark.colRank')}
-
{t('benchmark.colModel')}
-
{t('benchmark.colSource')}
- {(['f1', 'precision', 'recall'] as const).map((field) => { - const labels: Record = { - f1: 'F1', - precision: t('benchmark.colPrecision'), - recall: t('benchmark.colRecall'), - }; - return ( -
setSortField(field)} - > - {labels[field]} - -
- ); - })} -
{t('benchmark.colAvgTime')}
-
{t('benchmark.colAvgToken')}
-
- - {/* Rows */} - {sortedData.map((entry, index) => { - const entryRank = ranks.get(index); - const hasData = entry.f1 != null; - const pMedal = precisionMedals.get(index); - const rMedal = recallMedals.get(index); +
+
+ {/* Header */} +
+ + {t('benchmark.sectionLabel')} + +

+ {t('benchmark.title')} +

+

+ {t('benchmark.subtitle')} +

+
+ {/* Table */} +
+ {/* Table Header */} +
+ {sortableHeaders.map((col, i) => { + const isActive = col.field === sortField; + const isSortable = !!col.field; return (
setHoveredRow(index)} - onMouseLeave={() => setHoveredRow(null)} + key={col.key} + onClick={isSortable ? () => handleSort(col.field!) : undefined} + style={{ + width: i === 0 ? 120 : i === 1 ? 200 : i === 2 ? 200 : undefined, + flex: i > 2 ? 1 : undefined, + height: 44, + display: 'flex', + alignItems: 'center', + gap: 4, + padding: i === 0 ? '10px 20px' : '10px 12px', + cursor: isSortable ? 'pointer' : 'default', + userSelect: 'none', + }} > - {/* Rank */} -
- {entryRank != null ? ( - - {entryRank <= 3 ? medalIcons[['gold', 'silver', 'bronze'][entryRank - 1]] : ( - {entryRank} - )} - - ) : ( - - - - )} -
- - {/* Model + Company */} -
-
{entry.model}
-
{entry.company}
-
- - {/* Source */} -
- {entry.sourceType === 'ocr' && } - {entry.sourceType === 'cc' && } - {entry.sourceType === 'codex' && } - - {sourceNameMap[entry.sourceType]} - -
- - {/* F1 */} -
- {entry.f1 != null ? ( - - {entry.f1.toFixed(2)}% - - ) : ( - - - Running - - )} -
- - {/* Precision with medal */} -
- {entry.precision != null ? ( -
-
- {entry.precision.toFixed(2)}% - - {pMedal ? medalIcons[pMedal] : ''} - -
- {entry.precisionDetail && ( -
{entry.precisionDetail}
- )} -
- ) : ( -
-
-
- )} -
- - {/* Recall with medal */} -
- {entry.recall != null ? ( -
-
- {entry.recall.toFixed(2)}% - - {rMedal ? medalIcons[rMedal] : ''} - -
- {entry.recallDetail && ( -
{entry.recallDetail}
- )} -
- ) : ( -
-
-
- )} -
- - {/* Avg Time */} -
- {entry.avgTime ? ( - {entry.avgTime} - ) : ( -
-
-
- )} -
- - {/* Avg Token */} -
- {entry.avgTotalToken ? ( -
- {entry.avgTotalToken} - {entry.avgInputToken && ( -
{entry.avgInputToken} / {entry.avgOutputToken}
- )} -
- ) : ( -
-
-
- )} -
+ + {col.label} + + {isSortable && ( + + )}
); })}
+ {/* Table Rows */} + {sortedRows.map((row, idx) => { + const bgOpacity = idx === 0 ? 0.25 : idx === 1 ? 0.18 : idx === 2 ? 0.1 : 0; + const rankDisplay = idx < 3 ? MEDAL_RANKS[idx] : String(idx + 1); + return ( +
0 ? `rgba(43,222,94,${bgOpacity})` : 'transparent', + borderStyle: 'solid', + borderColor: 'rgba(255,255,255,0.16)', + borderTopWidth: 0, + borderBottomWidth: idx === sortedRows.length - 1 ? 0 : 1, + borderRightWidth: 0, + borderLeftWidth: 0, + overflow: 'hidden', + }} + > + {/* Rank */} +
+ + {rankDisplay} + +
+ {/* Model */} +
+ {row.model} + {row.provider} +
+ {/* Source */} +
+ {row.sourceIcon && } + {row.source} +
+ {/* F1 */} +
+ + {row.f1} + +
+ {/* Precision */} +
+
+ + {row.precision} + + {row.precisionMedal !== undefined && } +
+ {row.precisionDetail} +
+ {/* Recall */} +
+
+ + {row.recall} + + {row.recallMedal !== undefined && } +
+ {row.recallDetail} +
+ {/* Avg Time */} +
+ {row.avgTime} +
+ {/* Avg Token */} +
+ {row.avgToken} + {row.tokenDetail} +
+
+ ); + })}
-
+ {/* Footer note */} +

+ Open Code Review · v1.3.1 | Claude Code · v2.1.169 · /code-review | Codex · v0.140.0 · /review +

+
); }; diff --git a/pages/src/components/ColorBends.css b/pages/src/components/ColorBends.css new file mode 100644 index 0000000..b546eb2 --- /dev/null +++ b/pages/src/components/ColorBends.css @@ -0,0 +1,11 @@ +.color-bends-container { + width: 100%; + height: 100%; + overflow: hidden; +} + +.color-bends-container canvas { + display: block; + width: 100% !important; + height: 100% !important; +} diff --git a/pages/src/components/ColorBends.tsx b/pages/src/components/ColorBends.tsx new file mode 100644 index 0000000..7449fb0 --- /dev/null +++ b/pages/src/components/ColorBends.tsx @@ -0,0 +1,321 @@ +import React, { useEffect, useRef } from 'react'; +import * as THREE from 'three'; +import './ColorBends.css'; + +const MAX_COLORS = 8; + +const frag = ` +#define MAX_COLORS ${MAX_COLORS} +uniform vec2 uCanvas; +uniform float uTime; +uniform float uSpeed; +uniform vec2 uRot; +uniform int uColorCount; +uniform vec3 uColors[MAX_COLORS]; +uniform int uTransparent; +uniform float uScale; +uniform float uFrequency; +uniform float uWarpStrength; +uniform vec2 uPointer; +uniform float uMouseInfluence; +uniform float uParallax; +uniform float uNoise; +uniform int uIterations; +uniform float uIntensity; +uniform float uBandWidth; +varying vec2 vUv; + +void main() { + float t = uTime * uSpeed; + vec2 p = vUv * 2.0 - 1.0; + p += uPointer * uParallax * 0.1; + vec2 rp = vec2(p.x * uRot.x - p.y * uRot.y, p.x * uRot.y + p.y * uRot.x); + vec2 q = vec2(rp.x * (uCanvas.x / uCanvas.y), rp.y); + q /= max(uScale, 0.0001); + q /= 0.5 + 0.2 * dot(q, q); + q += 0.2 * cos(t) - 7.56; + vec2 toward = (uPointer - rp); + q += toward * uMouseInfluence * 0.2; + + for (int j = 0; j < 5; j++) { + if (j >= uIterations - 1) break; + vec2 rr = sin(1.5 * (q.yx * uFrequency) + 2.0 * cos(q * uFrequency)); + q += (rr - q) * 0.15; + } + + vec3 col = vec3(0.0); + float a = 1.0; + + if (uColorCount > 0) { + vec2 s = q; + vec3 sumCol = vec3(0.0); + float cover = 0.0; + for (int i = 0; i < MAX_COLORS; ++i) { + if (i >= uColorCount) break; + s -= 0.01; + vec2 r = sin(1.5 * (s.yx * uFrequency) + 2.0 * cos(s * uFrequency)); + float m0 = length(r + sin(5.0 * r.y * uFrequency - 3.0 * t + float(i)) / 4.0); + float kBelow = clamp(uWarpStrength, 0.0, 1.0); + float kMix = pow(kBelow, 0.3); + float gain = 1.0 + max(uWarpStrength - 1.0, 0.0); + vec2 disp = (r - s) * kBelow; + vec2 warped = s + disp * gain; + float m1 = length(warped + sin(5.0 * warped.y * uFrequency - 3.0 * t + float(i)) / 4.0); + float m = mix(m0, m1, kMix); + float w = 1.0 - exp(-uBandWidth / exp(uBandWidth * m)); + sumCol += uColors[i] * w; + cover = max(cover, w); + } + col = clamp(sumCol, 0.0, 1.0); + a = uTransparent > 0 ? cover : 1.0; + } else { + vec2 s = q; + for (int k = 0; k < 3; ++k) { + s -= 0.01; + vec2 r = sin(1.5 * (s.yx * uFrequency) + 2.0 * cos(s * uFrequency)); + float m0 = length(r + sin(5.0 * r.y * uFrequency - 3.0 * t + float(k)) / 4.0); + float kBelow = clamp(uWarpStrength, 0.0, 1.0); + float kMix = pow(kBelow, 0.3); + float gain = 1.0 + max(uWarpStrength - 1.0, 0.0); + vec2 disp = (r - s) * kBelow; + vec2 warped = s + disp * gain; + float m1 = length(warped + sin(5.0 * warped.y * uFrequency - 3.0 * t + float(k)) / 4.0); + float m = mix(m0, m1, kMix); + col[k] = 1.0 - exp(-uBandWidth / exp(uBandWidth * m)); + } + a = uTransparent > 0 ? max(max(col.r, col.g), col.b) : 1.0; + } + + col *= uIntensity; + + if (uNoise > 0.0001) { + float n = fract(sin(dot(gl_FragCoord.xy + vec2(uTime), vec2(12.9898, 78.233))) * 43758.5453123); + col += (n - 0.5) * uNoise; + col = clamp(col, 0.0, 1.0); + } + + vec3 rgb = (uTransparent > 0) ? col * a : col; + gl_FragColor = vec4(rgb, a); +} +`; + +const vert = ` +varying vec2 vUv; +void main() { + vUv = uv; + gl_Position = vec4(position, 1.0); +} +`; + +interface ColorBendsProps { + className?: string; + style?: React.CSSProperties; + rotation?: number; + speed?: number; + colors?: string[]; + transparent?: boolean; + autoRotate?: number; + scale?: number; + frequency?: number; + warpStrength?: number; + mouseInfluence?: number; + parallax?: number; + noise?: number; + iterations?: number; + intensity?: number; + bandWidth?: number; +} + +export default function ColorBends({ + className = '', + style, + rotation = 90, + speed = 0.2, + colors = [], + transparent = true, + autoRotate = 0, + scale = 1, + frequency = 1, + warpStrength = 1, + mouseInfluence = 1, + parallax = 0.5, + noise = 0.15, + iterations = 1, + intensity = 1.5, + bandWidth = 6, +}: ColorBendsProps) { + const containerRef = useRef(null); + const rendererRef = useRef(null); + const rafRef = useRef(null); + const materialRef = useRef(null); + const resizeObserverRef = useRef(null); + const rotationRef = useRef(rotation); + const autoRotateRef = useRef(autoRotate); + const pointerTargetRef = useRef(new THREE.Vector2(0, 0)); + const pointerCurrentRef = useRef(new THREE.Vector2(0, 0)); + const pointerSmoothRef = useRef(8); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const scene = new THREE.Scene(); + const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1); + const geometry = new THREE.PlaneGeometry(2, 2); + const uColorsArray = Array.from({ length: MAX_COLORS }, () => new THREE.Vector3(0, 0, 0)); + const material = new THREE.ShaderMaterial({ + vertexShader: vert, + fragmentShader: frag, + uniforms: { + uCanvas: { value: new THREE.Vector2(1, 1) }, + uTime: { value: 0 }, + uSpeed: { value: speed }, + uRot: { value: new THREE.Vector2(1, 0) }, + uColorCount: { value: 0 }, + uColors: { value: uColorsArray }, + uTransparent: { value: transparent ? 1 : 0 }, + uScale: { value: scale }, + uFrequency: { value: frequency }, + uWarpStrength: { value: warpStrength }, + uPointer: { value: new THREE.Vector2(0, 0) }, + uMouseInfluence: { value: mouseInfluence }, + uParallax: { value: parallax }, + uNoise: { value: noise }, + uIterations: { value: iterations }, + uIntensity: { value: intensity }, + uBandWidth: { value: bandWidth }, + }, + premultipliedAlpha: true, + transparent: true, + }); + materialRef.current = material; + + const mesh = new THREE.Mesh(geometry, material); + scene.add(mesh); + + const renderer = new THREE.WebGLRenderer({ + antialias: false, + powerPreference: 'high-performance', + alpha: true, + }); + rendererRef.current = renderer; + renderer.outputColorSpace = THREE.SRGBColorSpace; + renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); + renderer.setClearColor(0x000000, transparent ? 0 : 1); + renderer.domElement.style.width = '100%'; + renderer.domElement.style.height = '100%'; + renderer.domElement.style.display = 'block'; + container.appendChild(renderer.domElement); + + const clock = new THREE.Clock(); + + const handleResize = () => { + const w = container.clientWidth || 1; + const h = container.clientHeight || 1; + renderer.setSize(w, h, false); + material.uniforms.uCanvas.value.set(w, h); + }; + + handleResize(); + + if ('ResizeObserver' in window) { + const ro = new ResizeObserver(handleResize); + ro.observe(container); + resizeObserverRef.current = ro; + } else { + (window as any).addEventListener('resize', handleResize); + } + + const loop = () => { + const dt = clock.getDelta(); + const elapsed = clock.elapsedTime; + material.uniforms.uTime.value = elapsed; + + const deg = (rotationRef.current % 360) + autoRotateRef.current * elapsed; + const rad = (deg * Math.PI) / 180; + const c = Math.cos(rad); + const s = Math.sin(rad); + material.uniforms.uRot.value.set(c, s); + + const cur = pointerCurrentRef.current; + const tgt = pointerTargetRef.current; + const amt = Math.min(1, dt * pointerSmoothRef.current); + cur.lerp(tgt, amt); + material.uniforms.uPointer.value.copy(cur); + renderer.render(scene, camera); + rafRef.current = requestAnimationFrame(loop); + }; + rafRef.current = requestAnimationFrame(loop); + + return () => { + if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); + if (resizeObserverRef.current) resizeObserverRef.current.disconnect(); + else (window as any).removeEventListener('resize', handleResize); + geometry.dispose(); + material.dispose(); + renderer.dispose(); + renderer.forceContextLoss(); + if (renderer.domElement && renderer.domElement.parentElement === container) { + container.removeChild(renderer.domElement); + } + }; + }, [bandWidth, frequency, intensity, iterations, mouseInfluence, noise, parallax, scale, speed, transparent, warpStrength]); + + useEffect(() => { + const material = materialRef.current; + const renderer = rendererRef.current; + if (!material) return; + + rotationRef.current = rotation; + autoRotateRef.current = autoRotate; + material.uniforms.uSpeed.value = speed; + material.uniforms.uScale.value = scale; + material.uniforms.uFrequency.value = frequency; + material.uniforms.uWarpStrength.value = warpStrength; + material.uniforms.uMouseInfluence.value = mouseInfluence; + material.uniforms.uParallax.value = parallax; + material.uniforms.uNoise.value = noise; + material.uniforms.uIterations.value = iterations; + material.uniforms.uIntensity.value = intensity; + material.uniforms.uBandWidth.value = bandWidth; + + const toVec3 = (hex: string) => { + const h = hex.replace('#', '').trim(); + const v = + h.length === 3 + ? [parseInt(h[0] + h[0], 16), parseInt(h[1] + h[1], 16), parseInt(h[2] + h[2], 16)] + : [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]; + return new THREE.Vector3(v[0] / 255, v[1] / 255, v[2] / 255); + }; + + const arr = (colors || []).filter(Boolean).slice(0, MAX_COLORS).map(toVec3); + for (let i = 0; i < MAX_COLORS; i++) { + const vec = material.uniforms.uColors.value[i]; + if (i < arr.length) vec.copy(arr[i]); + else vec.set(0, 0, 0); + } + material.uniforms.uColorCount.value = arr.length; + + material.uniforms.uTransparent.value = transparent ? 1 : 0; + if (renderer) renderer.setClearColor(0x000000, transparent ? 0 : 1); + }, [rotation, autoRotate, speed, scale, frequency, warpStrength, mouseInfluence, parallax, noise, iterations, intensity, bandWidth, colors, transparent]); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const handlePointerMove = (e: PointerEvent) => { + const rect = container.getBoundingClientRect(); + const x = ((e.clientX - rect.left) / (rect.width || 1)) * 2 - 1; + const y = -(((e.clientY - rect.top) / (rect.height || 1)) * 2 - 1); + pointerTargetRef.current.set(x, y); + }; + + container.addEventListener('pointermove', handlePointerMove); + return () => { + container.removeEventListener('pointermove', handlePointerMove); + }; + }, []); + + return
; +} diff --git a/pages/src/components/FadeInSection.tsx b/pages/src/components/FadeInSection.tsx new file mode 100644 index 0000000..ceb5994 --- /dev/null +++ b/pages/src/components/FadeInSection.tsx @@ -0,0 +1,43 @@ +import React, { useRef, useEffect, useState } from 'react'; + +interface FadeInSectionProps { + children: React.ReactNode; + delay?: number; // ms +} + +const FadeInSection: React.FC = ({ children, delay = 0 }) => { + const ref = useRef(null); + const [isVisible, setIsVisible] = useState(false); + + useEffect(() => { + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + setIsVisible(true); + observer.unobserve(entry.target); + } + }, + { threshold: 0.08 } + ); + + if (ref.current) { + observer.observe(ref.current); + } + + return () => observer.disconnect(); + }, []); + + return ( +
+ {children} +
+ ); +}; + +export default FadeInSection; diff --git a/pages/src/components/FeaturesSection.tsx b/pages/src/components/FeaturesSection.tsx index 781dbf0..c17784d 100644 --- a/pages/src/components/FeaturesSection.tsx +++ b/pages/src/components/FeaturesSection.tsx @@ -1,86 +1,101 @@ import React from 'react'; import { useTranslation } from '../i18n'; +import { useResponsive } from '../hooks/useResponsive'; +import icon1 from '../assets/icons/icon-feature-architecture.svg'; +import icon2 from '../assets/icons/icon-feature-positioning.svg'; +import icon3 from '../assets/icons/icon-feature-multi-model.svg'; +import icon4 from '../assets/icons/icon-feature-concurrent.svg'; +import icon5 from '../assets/icons/icon-feature-compression.svg'; +import icon6 from '../assets/icons/icon-feature-rules.svg'; const FeaturesSection: React.FC = () => { const { t } = useTranslation(); + const { isMobile, isTablet } = useResponsive(); const features = [ - { - icon: 'fa-robot', - color: 'text-brand-400', - bgColor: 'bg-brand-500/10', - title: t('features.feat1Title'), - description: t('features.feat1Desc'), - }, - { - icon: 'fa-crosshairs', - color: 'text-cyan-400', - bgColor: 'bg-cyan-500/10', - title: t('features.feat2Title'), - description: t('features.feat2Desc'), - }, - { - icon: 'fa-link', - color: 'text-purple-400', - bgColor: 'bg-purple-500/10', - title: t('features.feat3Title'), - description: t('features.feat3Desc'), - }, - { - icon: 'fa-bolt', - color: 'text-yellow-400', - bgColor: 'bg-yellow-500/10', - title: t('features.feat4Title'), - description: t('features.feat4Desc'), - }, - { - icon: 'fa-brain', - color: 'text-orange-400', - bgColor: 'bg-orange-500/10', - title: t('features.feat5Title'), - description: t('features.feat5Desc'), - }, - { - icon: 'fa-scroll', - color: 'text-pink-400', - bgColor: 'bg-pink-500/10', - title: t('features.feat6Title'), - description: t('features.feat6Desc'), - }, + { icon: icon1, title: t('features.feat1Title'), desc: t('features.feat1Desc') }, + { icon: icon2, title: t('features.feat2Title'), desc: t('features.feat2Desc') }, + { icon: icon3, title: t('features.feat3Title'), desc: t('features.feat3Desc') }, + { icon: icon4, title: t('features.feat4Title'), desc: t('features.feat4Desc') }, + { icon: icon5, title: t('features.feat5Title'), desc: t('features.feat5Desc') }, + { icon: icon6, title: t('features.feat6Title'), desc: t('features.feat6Desc') }, ]; return ( -
- {/* Ambient glow */} -
-
+
+
+ {/* Header */} +
+ + {t('features.sectionBadge')} + +

+ {t('features.title')} +

+

+ {t('features.subtitle')} +

+
-
-
-
- {/* Section pill badge */} -
- {t('features.sectionBadge')} -
-

- {t('features.title')} -

-

- {t('features.subtitle')} -

-
- -
- {features.map((feature) => ( -
-
- -
-

{feature.title}

-

{feature.description}

+ {/* Grid */} +
+ {features.map((feat, i) => { + const cols = isMobile ? 1 : isTablet ? 2 : 3; + const isLastCol = (i % cols) === cols - 1; + const isLastRow = i >= features.length - (features.length % cols || cols); + return ( +
+
+
- ))} -
+
+

+ {feat.title} +

+

+ {feat.desc} +

+
+
+ ); + })}
diff --git a/pages/src/components/Footer.tsx b/pages/src/components/Footer.tsx new file mode 100644 index 0000000..48fb09c --- /dev/null +++ b/pages/src/components/Footer.tsx @@ -0,0 +1,125 @@ +import React, { useState, useRef, useEffect } from 'react'; +import githubIcon from '../assets/icons/icon-github.svg'; +import langIcon from '../assets/icons/icon-language.svg'; +import { useTranslation } from '../i18n/context'; +import { useResponsive } from '../hooks/useResponsive'; +import type { Language } from '../i18n/types'; + +const LANG_OPTIONS: { value: Language; label: string }[] = [ + { value: 'en', label: 'English' }, + { value: 'zh', label: '中文' }, + { value: 'ja', label: '日本語' }, +]; + +const Footer: React.FC = () => { + const { language, setLanguage, t } = useTranslation(); + const { isMobile } = useResponsive(); + const [open, setOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + const handler = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, []); + + const currentLabel = LANG_OPTIONS.find(o => o.value === language)?.label ?? 'English'; + return ( +
+
+ + + {t('footer.brand')} + + + {t('footer.copyright')} + + + {/* Language Switcher */} +
+ + {open && ( +
+ {LANG_OPTIONS.map(opt => ( + + ))} +
+ )} +
+
+
+ ); +}; + +export default Footer; diff --git a/pages/src/components/HeroSection.tsx b/pages/src/components/HeroSection.tsx index 54f022e..692403a 100644 --- a/pages/src/components/HeroSection.tsx +++ b/pages/src/components/HeroSection.tsx @@ -1,177 +1,356 @@ -import React, { useEffect, useState } from 'react'; +import React from 'react'; import { useTranslation } from '../i18n'; +import { useResponsive } from '../hooks/useResponsive'; +import ColorBends from './ColorBends'; +import lineIcon from '../assets/icons/icon-terminal-prompt.svg'; const terminalLines = [ - { delay: 0, text: '$ ocr review --from main --to feature-auth', type: 'command' }, - { delay: 600, text: '[ocr] Reviewing 5 file(s) in /home/user/project', type: 'info' }, - { delay: 1200, text: '[ocr] ▶ file_read "internal/auth/login.go"', type: 'tool' }, - { delay: 1600, text: '[ocr] ✔ file_read (15ms)', type: 'tool-ok' }, - { delay: 2000, text: '[ocr] ▶ code_search "password.*hash"', type: 'tool' }, - { delay: 2400, text: '[ocr] ✔ code_search (8ms)', type: 'tool-ok' }, - { delay: 2800, text: '[ocr] Plan completed for internal/auth/login.go', type: 'info' }, - { delay: 3200, text: '', type: 'separator' }, - { delay: 3400, text: '─── internal/auth/login.go:42-55 ───', type: 'header' }, - { delay: 3600, text: 'Consider using bcrypt cost factor ≥ 12 for password hashing.', type: 'comment' }, - { delay: 4000, text: '', type: 'separator' }, - { delay: 4200, text: '[ocr] Summary: 5 file(s), 3 comment(s), ~8421 tokens, 12.5s', type: 'result' } + { + num: 1, + hasIcon: true, + content: ( + + $ ocr re + v + iew --from + + mai + n --to feature-auth + + ), + }, + { + num: 2, + hasIcon: true, + content: ( + + [o + cr] R + e + v + iew + ing + + 5 file + (s) in /home/user/project + + ), + }, + { + num: 3, + hasIcon: true, + content: ( + + [o + cr] + + fi + l + e + _re + a + d + " + int + e + rna + l + /a + u + th/login.go" + + ), + }, + { + num: 4, + hasIcon: true, + content: ( + + [ocr + ] ✔ + f + ile + _ + r + ead + (1 + 5 + ms) + + ), + }, + { + num: 5, + hasIcon: true, + content: ( + + [ocr] + + co + de_ + s + e + arch + "p + a + s + swo + r + d.*hash" + + ), + }, + { + num: 6, + hasIcon: true, + content: ( + + [ocr] ✔ c + ode + _ + s + ear + ch + ( + 8 + ms) + + ), + }, + { num: 7, hasIcon: false, content: [ocr] Plan completed for internal/auth/login.go }, + { num: 8, hasIcon: false, content: ─── internal/auth/login.go:42-55 ─── }, + { num: 9, hasIcon: false, content: Consider using bcrypt cost factor ≥ 12 for password hashing. }, + { + num: 10, + hasIcon: false, + content: ( + + [o + cr] Su + m + mar + y: 5 file(s), 3 comment(s), ~8421 tokens, 12.5s + + ), + }, + { num: 11, hasIcon: false, content: }, ]; -const TerminalLine: React.FC<{ line: typeof terminalLines[0]; visible: boolean }> = ({ line, visible }) => { - const colorMap: Record = { - command: 'text-brand-400 font-semibold', - info: 'text-slate-400', - tool: 'text-cyan-400', - 'tool-ok': 'text-green-400', - separator: 'text-slate-600', - header: 'text-slate-500 font-mono', - comment: 'text-slate-300 text-xs', - result: 'text-brand-400 font-medium' - }; - - return ( -
- {line.text} -
- ); -}; - const HeroSection: React.FC = () => { - const [visibleLines, setVisibleLines] = useState([]); const { t } = useTranslation(); - - useEffect(() => { - const timers: ReturnType[] = []; - terminalLines.forEach((line, index) => { - const timer = setTimeout(() => { - setVisibleLines((prev) => [...prev, index]); - }, line.delay + 500); - timers.push(timer); - }); - return () => { timers.forEach((timer) => clearTimeout(timer)); }; - }, []); - - const pills = [ - { icon: 'fa-check-circle', color: 'text-brand-400', label: t('hero.pill1') }, - { icon: 'fa-shield-halved', color: 'text-cyan-400', label: t('hero.pill2') }, - { icon: 'fa-bolt', color: 'text-yellow-400', label: t('hero.pill3') } - ]; + const { isMobile, isTablet } = useResponsive(); return ( -
- {/* Layered glow orbs */} -
-
-
+
+ {/* Shader Background */} + - {/* Decorative horizontal lines */} -
+ {/* Gradient overlay */} +
-
- {/* Left content */} -
-
-

- {t('hero.title')} -
- {t('hero.titleHighlight')} -

-

- {t('hero.description')} -

+ {/* Content */} +
+ {/* Title */} +
+

+ {t('hero.title').split('\n').map((line, i, arr) => ( + + {line} + {i < arr.length - 1 &&
} +
+ ))} +

+

+ {t('hero.description')} +

+
+ + {/* Buttons */} + + + {/* Terminal */} +
+ {/* Terminal header */} +
+ + {t('hero.terminal')} +
- - {/* Feature pills */} -
- {pills.map((item) => ( -
- - {item.label} + {/* Terminal body */} +
+ {terminalLines.map((line) => ( +
+
+ + {line.num} + + {line.hasIcon && } +
+ + {line.content} +
))}
- - {/* CTA buttons */} -
- - -
- - {/* Social proof */} -
-
- - {t('hero.users')} -
-
-
- - {t('hero.openSource')} -
-
- - {/* Right terminal */} -
- {/* Outer glow frame */} -
- -
- {/* Scan line effect */} -
- -
-
-
-
- {t('hero.terminal')} -
- - {terminalLines.map((line, index) => ( - - ))} - - {visibleLines.length < terminalLines.length && ( -
- )} -
- - {/* F1 badge */} -
-
-
F1: {t('highlights.stat4Value')}
-
{t('hero.badgeLabel')}
-
-
- - {/* Decorative accent line */} -
-
-
- - {/* Scroll indicator */} -
- Scroll -
); diff --git a/pages/src/components/HighlightsSection.tsx b/pages/src/components/HighlightsSection.tsx index 1e48f07..42c98ba 100644 --- a/pages/src/components/HighlightsSection.tsx +++ b/pages/src/components/HighlightsSection.tsx @@ -1,85 +1,162 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useRef, useEffect, useState } from 'react'; import { useTranslation } from '../i18n'; +import { useResponsive } from '../hooks/useResponsive'; + +// 从字符串中解析数字和前后缀 +function parseStatValue(value: string): { prefix: string; number: number; suffix: string } { + // 匹配 "> 30%" 等格式 + const match = value.match(/^([^\d]*?)(\d+)(.*)$/); + if (match) { + return { prefix: match[1], number: parseInt(match[2], 10), suffix: match[3] }; + } + return { prefix: '', number: 0, suffix: value }; +} + +function useCountUp(target: number, duration: number = 1200, isActive: boolean) { + const [current, setCurrent] = useState(0); + const rafRef = useRef(0); + const startTimeRef = useRef(0); + + useEffect(() => { + if (!isActive) return; + startTimeRef.current = performance.now(); + + const animate = (now: number) => { + const elapsed = now - startTimeRef.current; + const progress = Math.min(elapsed / duration, 1); + // easeOutExpo + const eased = progress === 1 ? 1 : 1 - Math.pow(2, -10 * progress); + setCurrent(Math.round(eased * target)); + if (progress < 1) { + rafRef.current = requestAnimationFrame(animate); + } + }; + + rafRef.current = requestAnimationFrame(animate); + return () => cancelAnimationFrame(rafRef.current); + }, [isActive, target, duration]); + + return current; +} + +const CountUpValue: React.FC<{ value: string; isVisible: boolean }> = ({ value, isVisible }) => { + const { prefix, number, suffix } = parseStatValue(value); + const count = useCountUp(number, 2000, isVisible); + + if (number === 0) { + // 无法解析数字,直接显示原文 + return <>{value}; + } + + return <>{prefix}{isVisible ? count : 0}{suffix}; +}; const HighlightsSection: React.FC = () => { const { t } = useTranslation(); + const { isMobile, isTablet } = useResponsive(); const sectionRef = useRef(null); - const [visible, setVisible] = useState(false); + const [isVisible, setIsVisible] = useState(false); useEffect(() => { const observer = new IntersectionObserver( ([entry]) => { if (entry.isIntersecting) { - setVisible(true); - observer.disconnect(); + setIsVisible(true); + observer.unobserve(entry.target); } }, { threshold: 0.3 } ); - if (sectionRef.current) observer.observe(sectionRef.current); + if (sectionRef.current) { + observer.observe(sectionRef.current); + } return () => observer.disconnect(); }, []); const stats = [ - { - label: t('highlights.stat1Label'), - value: t('highlights.stat1Value'), - caption: t('highlights.stat1Caption'), - delay: 0, - }, - { - label: t('highlights.stat2Label'), - value: t('highlights.stat2Value'), - caption: t('highlights.stat2Caption'), - delay: 100, - }, - { - label: t('highlights.stat3Label'), - value: t('highlights.stat3Value'), - caption: t('highlights.stat3Caption'), - delay: 200, - }, - { - label: t('highlights.stat4Label'), - value: t('highlights.stat4Value'), - caption: t('highlights.stat4Caption'), - delay: 300, - }, + { value: t('highlights.stat1Value'), label: t('highlights.stat1Label'), caption: t('highlights.stat1Caption') }, + { value: t('highlights.stat2Value'), label: t('highlights.stat2Label'), caption: t('highlights.stat2Caption') }, + { value: t('highlights.stat3Value'), label: t('highlights.stat3Label'), caption: t('highlights.stat3Caption') }, + { value: t('highlights.stat4Value'), label: t('highlights.stat4Label'), caption: t('highlights.stat4Caption') }, + { value: t('highlights.stat5Value'), label: t('highlights.stat5Label'), caption: t('highlights.stat5Caption') }, ]; return ( -
-
- -
-
-
-
- {stats.map((stat, i) => ( -
-
- - - {stat.label} - -
-

- {stat.value} -

-

{stat.caption}

-
- ))} +
+
+ {stats.map((stat, i) => ( +
+ + + +
+

+ {stat.label} +

+

+ {stat.caption} +

-
+ ))}
); diff --git a/pages/src/components/LandingPage.tsx b/pages/src/components/LandingPage.tsx index 573ae7a..a011807 100644 --- a/pages/src/components/LandingPage.tsx +++ b/pages/src/components/LandingPage.tsx @@ -1,36 +1,31 @@ -import React, { useEffect } from 'react'; -import { useLocation } from 'react-router-dom'; +import React from 'react'; import Navbar from './Navbar'; -import HeroSection from './HeroSection'; -import HighlightsSection from './HighlightsSection'; -import WhySection from './WhySection'; -import FeaturesSection from './FeaturesSection'; -import BenchmarkSection from './BenchmarkSection'; -import QuickStartSection from './QuickStartSection'; -const LandingPage: React.FC = () => { - const location = useLocation(); - - useEffect(() => { - const scrollTo = (location.state as { scrollTo?: string })?.scrollTo; - if (scrollTo) { - const el = document.getElementById(scrollTo); - if (el) { - setTimeout(() => el.scrollIntoView({ behavior: 'smooth' }), 100); - } - window.history.replaceState({}, ''); - } - }, [location.state]); +interface LandingPageProps { + children: React.ReactNode; +} +const LandingPage: React.FC = ({ children }) => { return ( -
- - - - - - - +
+
+ + {children} +
); }; diff --git a/pages/src/components/Navbar.tsx b/pages/src/components/Navbar.tsx index 57cfc86..4488d75 100644 --- a/pages/src/components/Navbar.tsx +++ b/pages/src/components/Navbar.tsx @@ -1,146 +1,216 @@ -import React, { useState, useEffect } from 'react'; -import { Link, useLocation, useNavigate } from 'react-router-dom'; +import React, { useState, useRef, useEffect } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; import { useTranslation } from '../i18n'; -import logoSvg from '../../logo.svg'; +import { useResponsive } from '../hooks/useResponsive'; +import socialIcon from '../assets/icons/icon-github.svg'; +import brandIcon from '../assets/images/brandicon.svg'; + +import type { Language } from '../i18n/types'; + +const LANG_OPTIONS: { value: Language; label: string }[] = [ + { value: 'en', label: 'English' }, + { value: 'zh', label: '中文' }, + { value: 'ja', label: '日本語' }, +]; + +const navTabs = [ + { path: '/', labelKey: 'navbar.features' }, + { path: '/benchmark', labelKey: 'navbar.benchmark' }, + { path: '/quickstart', labelKey: 'navbar.quickstart' }, + { path: '/docs', labelKey: 'navbar.docs' }, +]; const Navbar: React.FC = () => { - const [isScrolled, setIsScrolled] = useState(false); - const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); + const { language, setLanguage, t } = useTranslation(); + const { isMobile } = useResponsive(); const location = useLocation(); const navigate = useNavigate(); - const isDocsPage = location.pathname === '/docs'; - const { t, language, setLanguage } = useTranslation(); - - const navItems = [ - { label: t('navbar.features'), id: 'features' }, - { label: t('navbar.benchmark'), id: 'benchmark' }, - { label: t('navbar.quickstart'), id: 'quickstart' } - ]; - - const navigateToSection = (sectionId: string) => { - navigate('/', { state: { scrollTo: sectionId } }); - setIsMobileMenuOpen(false); - }; + const [langOpen, setLangOpen] = useState(false); + const langRef = useRef(null); useEffect(() => { - const handleScroll = () => setIsScrolled(window.scrollY > 20); - window.addEventListener('scroll', handleScroll); - return () => window.removeEventListener('scroll', handleScroll); + const handler = (e: MouseEvent) => { + if (langRef.current && !langRef.current.contains(e.target as Node)) setLangOpen(false); + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); }, []); - const scrollToSection = (sectionId: string) => { - const element = document.getElementById(sectionId); - if (element) { - element.scrollIntoView({ behavior: 'smooth' }); - } - setIsMobileMenuOpen(false); - }; - - const toggleLanguage = () => { - setLanguage(language === 'en' ? 'zh' : 'en'); - }; + const currentPath = location.pathname; return ( ); }; diff --git a/pages/src/components/QuickStartSection.tsx b/pages/src/components/QuickStartSection.tsx index 8c6d750..8402ef0 100644 --- a/pages/src/components/QuickStartSection.tsx +++ b/pages/src/components/QuickStartSection.tsx @@ -1,174 +1,205 @@ -import React, { useState } from 'react'; +import React, { useState, useCallback, useEffect } from 'react'; +import ReactDOM from 'react-dom'; import { useTranslation } from '../i18n'; +import { useResponsive } from '../hooks/useResponsive'; +import copyIcon from '../assets/icons/icon-copy.svg'; +import chevronDown from '../assets/icons/icon-chevron-down.svg'; +import chevronRight from '../assets/icons/icon-chevron-right.svg'; +import playIcon from '../assets/icons/icon-play.svg'; -const CodeBlock: React.FC<{ - block: { label?: string; code: string }; - index: string; - copiedIndex: string | null; - onCopy: (code: string, key: string) => void; - copyLabel: string; -}> = ({ block, index, copiedIndex, onCopy, copyLabel }) => ( -
- {block.label && ( -

{block.label}

- )} -
-
-
{block.code}
-
- + copy +
); const QuickStartSection: React.FC = () => { - const [copiedIndex, setCopiedIndex] = useState(null); const { t } = useTranslation(); + const { isMobile, isTablet } = useResponsive(); + const [toastVisible, setToastVisible] = useState(false); - const steps = [ - { - number: '01', - title: t('quickstart.step1Title'), - icon: 'fa-solid fa-cloud-arrow-down', - description: t('quickstart.step1Desc'), - codeBlocks: [ - { label: t('quickstart.step1Label1'), code: `npm i -g @alibaba-group/open-code-review` }, - { label: t('quickstart.step1Label2'), code: `ocr version` } - ] - }, - { - number: '02', - title: t('quickstart.step2Title'), - icon: 'fa-solid fa-sliders', - description: t('quickstart.step2Desc'), - codeBlocks: [ - { - label: t('quickstart.step2Label1'), - code: `ocr config provider` - }, - { - label: t('quickstart.step2Label2'), - code: `ocr config set llm.url https://api.anthropic.com \\\n && ocr config set llm.auth_token {{your-api-key}} \\\n && ocr config set llm.model claude-opus-4-6 \\\n && ocr config set llm.use_anthropic true` - }, - { label: t('quickstart.step2Label3'), code: `ocr llm test` } - ] - }, - { - number: '03', - title: t('quickstart.step3Title'), - icon: 'fa-solid fa-magnifying-glass-code', - description: t('quickstart.step3Desc'), - codeBlocks: [ - { - code: `${t('quickstart.commentReview')}\nocr review\n\n${t('quickstart.commentBranch')}\nocr review --from main --to feature-auth\n\n${t('quickstart.commentCommit')}\nocr review --commit abc123` - } - ] - } - ]; - - const handleCopy = (code: string, key: string) => { - if (navigator.clipboard?.writeText) { - navigator.clipboard.writeText(code).then(() => { - setCopiedIndex(key); - setTimeout(() => setCopiedIndex(null), 2000); + const handleCopy = useCallback((text: string) => { + if (navigator.clipboard && window.isSecureContext) { + navigator.clipboard.writeText(text).then(() => { + setToastVisible(true); + }).catch(() => { + fallbackCopy(text); }); } else { - const textarea = document.createElement('textarea'); - textarea.value = code; - textarea.style.position = 'fixed'; - textarea.style.opacity = '0'; - document.body.appendChild(textarea); - textarea.select(); - document.execCommand('copy'); - document.body.removeChild(textarea); - setCopiedIndex(key); - setTimeout(() => setCopiedIndex(null), 2000); + fallbackCopy(text); } + }, []); + + const fallbackCopy = (text: string) => { + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.style.position = 'fixed'; + textarea.style.opacity = '0'; + document.body.appendChild(textarea); + textarea.select(); + const success = document.execCommand('copy'); + document.body.removeChild(textarea); + if (success) setToastVisible(true); }; + useEffect(() => { + if (!toastVisible) return; + const timer = setTimeout(() => setToastVisible(false), 1200); + return () => clearTimeout(timer); + }, [toastVisible]); + return ( -
- {/* Ambient glow */} -
+
+
+ {/* Header */} +
+ + {t('quickstart.sectionLabel')} + +

+ {t('quickstart.title')} +

+

+ {t('quickstart.subtitle')} +

+
-
-
-
-
-

{t('quickstart.sectionLabel')}

-

- {t('quickstart.title')} -

-

- {t('quickstart.subtitle')} -

-
- -
- {steps.map((step, stepIndex) => ( -
- {/* Connector line between steps */} - {stepIndex < steps.length - 1 && ( -
- )} - - {/* Step header */} -
-
- {step.number} -
-
-
- -

{step.title}

-
-

{step.description}

-
+ {/* Steps */} +
+ {/* Step 1 */} +
+
+
+
+ 01
- - {/* Code blocks */} -
- {step.codeBlocks.map((block, blockIdx) => ( - - ))} +
+

{t('quickstart.step1Title')}

+

{t('quickstart.step1Desc')}

- ))} + +
+
+ + +
- {/* Zero-config callout */} -
-
-

- - {t('quickstart.zeroCfgTitle')} -

-

- {t('quickstart.zeroCfgDesc')} -

-
-
{`export ANTHROPIC_BASE_URL=https://api.anthropic.com
-export ANTHROPIC_AUTH_TOKEN=sk-ant-xxxxx
-export ANTHROPIC_MODEL=claude-opus-4-6
+          {/* Step 2 */}
+          
+
+
+
+ 02 +
+
+

{t('quickstart.step2Title')}

+

{t('quickstart.step2Desc')}

+
+
+ +
+
+ + + +
+
-${t('quickstart.commentEnvAuto')} ✨`}
+ {/* Step 3 */} +
+
+
+
+ 03 +
+
+

{t('quickstart.step3Title')}

+

{t('quickstart.step3Desc')}

+
+
+ +
+
+
+
); }; diff --git a/pages/src/components/UseCasesSection.tsx b/pages/src/components/UseCasesSection.tsx new file mode 100644 index 0000000..c115303 --- /dev/null +++ b/pages/src/components/UseCasesSection.tsx @@ -0,0 +1,148 @@ +import React, { useCallback, useRef } from 'react'; +import { useTranslation } from '../i18n'; +import { useResponsive } from '../hooks/useResponsive'; +import iconCase1 from '../assets/icons/icon-usecase-developer.svg'; +import iconCase2a from '../assets/icons/icon-usecase-platform-a.svg'; +import iconCase2b from '../assets/icons/icon-usecase-platform-b.svg'; +import iconCase2c from '../assets/icons/icon-usecase-platform-c.svg'; +import iconCase3 from '../assets/icons/icon-usecase-researcher.svg'; + +const UseCasesSection: React.FC = () => { + const { t } = useTranslation(); + const { isMobile, isTablet } = useResponsive(); + const cardRefs = useRef<(HTMLDivElement | null)[]>([]); + + const handleMouseMove = useCallback((e: React.MouseEvent, index: number) => { + const card = cardRefs.current[index]; + if (!card) return; + const rect = card.getBoundingClientRect(); + const cx = rect.left + rect.width / 2; + const cy = rect.top + rect.height / 2; + const angle = Math.atan2(e.clientY - cy, e.clientX - cx) * (180 / Math.PI) + 90; + card.style.setProperty('--sweep-angle', `${angle}deg`); + }, []); + + const handleMouseLeave = useCallback((index: number) => { + const card = cardRefs.current[index]; + if (!card) return; + card.style.setProperty('--sweep-angle', '0deg'); + }, []); + + const useCases = [ + { title: t('usecases.case1Title'), desc: t('usecases.case1Desc') }, + { title: t('usecases.case2Title'), desc: t('usecases.case2Desc') }, + { title: t('usecases.case3Title'), desc: t('usecases.case3Desc') }, + ]; + + return ( +
+
+ {/* Header */} +
+ + {t('usecases.sectionLabel')} + +

+ {t('usecases.title')} +

+
+ + {/* Cards */} +
+ {useCases.map((item, i) => ( +
{ cardRefs.current[i] = el; }} + className="usecase-card" + onMouseMove={(e) => handleMouseMove(e, i)} + onMouseLeave={() => handleMouseLeave(i)} + style={{ + flex: 1, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + padding: '48px 32px', + }} + > + {/* Icon */} +
+ {i === 0 && ( + + )} + {i === 1 && ( +
+ + + +
+ )} + {i === 2 && ( + + )} +
+
+

+ {item.title} +

+

+ {item.desc} +

+
+
+ ))} +
+
+
+ ); +}; + +export default UseCasesSection; diff --git a/pages/src/hooks/useResponsive.ts b/pages/src/hooks/useResponsive.ts new file mode 100644 index 0000000..77bc8b5 --- /dev/null +++ b/pages/src/hooks/useResponsive.ts @@ -0,0 +1,28 @@ +import { useState, useEffect } from 'react'; + +export interface Breakpoints { + isMobile: boolean; // < 768px + isTablet: boolean; // 768px ~ 1024px + isDesktop: boolean; // > 1024px +} + +export function useResponsive(): Breakpoints { + const [bp, setBp] = useState(() => getBreakpoints()); + + useEffect(() => { + const handleResize = () => setBp(getBreakpoints()); + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, []); + + return bp; +} + +function getBreakpoints(): Breakpoints { + const w = window.innerWidth; + return { + isMobile: w < 768, + isTablet: w >= 768 && w <= 1024, + isDesktop: w > 1024, + }; +} diff --git a/pages/src/i18n/context.tsx b/pages/src/i18n/context.tsx index 8550291..7024d17 100644 --- a/pages/src/i18n/context.tsx +++ b/pages/src/i18n/context.tsx @@ -2,8 +2,9 @@ import React, { createContext, useContext, useState, useCallback } from 'react'; import { Language, TranslationKeys } from './types'; import { en } from './en'; import { zh } from './zh'; +import { ja } from './ja'; -const translations: Record = { en, zh }; +const translations: Record = { en, zh, ja }; interface LanguageContextValue { language: Language; @@ -18,7 +19,7 @@ const STORAGE_KEY = 'ocr-lang'; function getInitialLanguage(): Language { try { const stored = localStorage.getItem(STORAGE_KEY); - if (stored === 'en' || stored === 'zh') return stored; + if (stored === 'en' || stored === 'zh' || stored === 'ja') return stored; } catch {} return 'en'; } diff --git a/pages/src/i18n/en.ts b/pages/src/i18n/en.ts index 18c3908..c1516c8 100644 --- a/pages/src/i18n/en.ts +++ b/pages/src/i18n/en.ts @@ -6,47 +6,42 @@ export const en: TranslationKeys = { 'navbar.benchmark': 'Benchmark', 'navbar.quickstart': 'Quick Start', 'navbar.docs': 'Docs', + 'navbar.blog': 'Blog', 'navbar.getStarted': 'Get Started', // Hero - 'hero.title': 'AI Code Review', - 'hero.titleHighlight': 'Validated on Millions of Real-World Tasks', + 'hero.title': 'AI Code Review\nValidated on Millions of\nReal-World Tasks', 'hero.description': 'Open Code Review brings Alibaba\'s battle-tested code review Agent into your workflow. Connect any LLM, keep data fully private, and get review comments developers actually adopt.', - 'hero.pill1': 'Adoption Rate > 30%', - 'hero.pill2': 'Data Stays Local', - 'hero.pill3': 'Token Cost Only 1/9', - 'hero.cta1': 'Quick Start', - 'hero.cta2': 'View Benchmark', - 'hero.users': '20K+ Active Users', - 'hero.openSource': 'Fully Open Source', - 'hero.terminal': 'ocr terminal', - 'hero.badgeLabel': 'Benchmark #1', + 'hero.quickStart': 'Quick Start', + 'hero.learnMore': 'Learn More', + 'hero.terminal': 'Terminal', // Highlights - 'highlights.stat1Label': 'Active Users', 'highlights.stat1Value': '20K+', + 'highlights.stat1Label': 'ACTIVE USERS', 'highlights.stat1Caption': 'Battle-tested inside Alibaba Group', - 'highlights.stat2Label': 'Real-World Tasks', - 'highlights.stat2Value': '1M+', - 'highlights.stat2Caption': 'Code review tasks executed', - 'highlights.stat3Label': 'Token Cost', - 'highlights.stat3Value': '1/9', - 'highlights.stat3Caption': 'vs. Claude Code · 1,000 PRs', - 'highlights.stat4Label': 'AACR-Bench', - 'highlights.stat4Value': '25.1%', - 'highlights.stat4Caption': 'SEM.F1 benchmark score', + 'highlights.stat2Value': '> 30%', + 'highlights.stat2Label': 'ADOPTION RATE', + 'highlights.stat2Caption': 'Battle-tested inside Alibaba Group', + 'highlights.stat3Value': '1M+', + 'highlights.stat3Label': 'REAL-WORLD TASKS', + 'highlights.stat3Caption': 'Code review tasks executed', + 'highlights.stat4Value': '1/9', + 'highlights.stat4Label': 'TOKEN COST', + 'highlights.stat4Caption': 'vs. Claude Code · 1,000 PRs', + 'highlights.stat5Value': '25.10%', + 'highlights.stat5Label': 'AACR-BENCH', + 'highlights.stat5Caption': 'SEM.F1 benchmark score', + 'usecases.sectionLabel': 'USE CASES', + 'usecases.title': 'Who Is Open Code Review For?', + 'usecases.case1Title': 'Individual Developers', + 'usecases.case1Desc': 'Embed Open Code Review in your local AI-assisted development workflow for instant, high-quality code review feedback.', + 'usecases.case2Title': 'Platform Teams', + 'usecases.case2Desc': 'Seamlessly integrate into your internal systems with full control over data flow and review policies.', + 'usecases.case3Title': 'ML Researchers', + 'usecases.case3Desc': 'Use as a code quality verifier in RL training pipelines, providing reliable reward signals for code generation models.', - // Why Section - 'why.sectionLabel': 'Use Cases', - 'why.title': 'Who Is Open Code Review For?', - 'why.case1Title': 'Individual Developers', - 'why.case1Desc': 'Embed Open Code Review in your local AI-assisted development workflow for instant, high-quality code review feedback.', - 'why.case2Title': 'Platform Teams', - 'why.case2Desc': 'Seamlessly integrate into your internal systems with full control over data flow and review policies.', - 'why.case3Title': 'ML Researchers', - 'why.case3Desc': 'Use as a code quality verifier in RL training pipelines, providing reliable reward signals for code generation models.', - - // Features Section + // Features 'features.sectionBadge': 'Core Features', 'features.title': 'An Agent System Purpose-Built for Code Review', 'features.subtitle': 'An AI Agent that reads files, searches the codebase, cross-references context, and applies fine-grained rules for deep review.', @@ -63,29 +58,21 @@ export const en: TranslationKeys = { 'features.feat6Title': 'Built-in Review Rules', 'features.feat6Desc': 'Review rules validated through massive real-world scenarios, covering 10+ languages including Java, TypeScript, Go, Python, Kotlin, Rust, C++, C, with specialized rules for NPE, thread safety, XSS, SQL injection, and more.', - // Benchmark Section - 'benchmark.sectionLabel': 'Open Benchmark', + // Benchmark + 'benchmark.sectionLabel': 'OPEN BENCHMARK', 'benchmark.title': 'Cross-Validated by 80+ Senior Engineers', - 'benchmark.subtitlePreRepos': 'A real-world CodeReview benchmark selecting from ', - 'benchmark.subtitlePrePRs': ' popular open-source repositories, comprising ', - 'benchmark.subtitlePreLangs': ' real PullRequests, covering ', - 'benchmark.subtitleEnd': ' programming languages, diverse issue types, and varying changeset sizes.', - 'benchmark.legendOcr': 'Open Code Review', - 'benchmark.legendCc': 'Claude Code · /code-review', - 'benchmark.colRank': 'Rank', - 'benchmark.colModel': 'Model', - 'benchmark.colSource': 'Source', - 'benchmark.colVersion': 'Version', - 'benchmark.colPrecision': 'Precision', - 'benchmark.colRecall': 'Recall', - 'benchmark.colAvgTime': 'Avg Time', - 'benchmark.colAvgToken': 'Avg Token', - 'benchmark.tooltipInput': 'Input Token', - 'benchmark.tooltipOutput': 'Output Token', - 'benchmark.footer': '', + 'benchmark.subtitle': 'A real-world CodeReview benchmark selecting from 50 popular open-source repositories, comprising 200 real PullRequests, covering 10 programming languages, diverse issue types, and varying changeset sizes.', + 'benchmark.colRank': 'RANK', + 'benchmark.colModel': 'MODEL', + 'benchmark.colSource': 'SOURCE', + 'benchmark.colF1': 'F1', + 'benchmark.colPrecision': 'PRECISION', + 'benchmark.colRecall': 'RECALL', + 'benchmark.colAvgTime': 'AVG TIME', + 'benchmark.colAvgToken': 'AVG TOKEN', - // QuickStart Section - 'quickstart.sectionLabel': 'Quick Start', + // QuickStart + 'quickstart.sectionLabel': 'QUICK START', 'quickstart.title': 'Up and Running in Minutes', 'quickstart.subtitle': 'No complex configuration. No third-party accounts needed beyond your chosen LLM provider.', 'quickstart.step1Title': 'Install', @@ -93,17 +80,23 @@ export const en: TranslationKeys = { 'quickstart.step1Label1': 'Install', 'quickstart.step1Label2': 'Verify Installation', 'quickstart.step2Title': 'Configure', - 'quickstart.step2Desc': 'Set up your LLM provider interactively, or configure manually. Built-in support for Anthropic, OpenAI, DashScope, DeepSeek, and Z.AI.', + 'quickstart.step2Desc': 'Set up your LLM provider interactively, or configure manually.', 'quickstart.step2Label1': 'Interactive Setup (Recommended)', 'quickstart.step2Label2': 'Manual Config (Alternative)', 'quickstart.step2Label3': 'Verify Configuration', 'quickstart.step3Title': 'Run Review', 'quickstart.step3Desc': 'Start your first code review.', - 'quickstart.zeroCfgTitle': 'Already using Claude Code? Zero extra config', - 'quickstart.zeroCfgDesc': 'If you\'ve already configured Claude Code environment variables (ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN / ANTHROPIC_MODEL), Open Code Review reads them automatically — no additional setup needed.', - 'quickstart.copy': 'Copy', + 'quickstart.step3Label1': 'Review Commands', + 'quickstart.commentReview': '# Review current changes', + 'quickstart.commentBranch': '# Review diff between branches', + 'quickstart.commentCommit': '# Review a specific commit', + 'quickstart.copied': 'Copied!', - // Docs Page + // Footer + 'footer.brand': 'Open Code Review', + 'footer.copyright': '© Copyright 2026. All rights reserved.', + + // Docs Page (keep existing) 'docs.toc': 'Table of Contents', 'docs.overview': 'Overview', 'docs.install': 'Install', @@ -111,7 +104,6 @@ export const en: TranslationKeys = { 'docs.review': 'ocr review', 'docs.viewer': 'ocr viewer', 'docs.env': 'Claude Code Integration', - 'docs.overviewTitle': 'Overview', 'docs.overviewDesc': '(short ocr) is an AI-powered code review CLI tool.', 'docs.overviewFeatures': 'Core Features:', @@ -121,11 +113,9 @@ export const en: TranslationKeys = { 'docs.overviewFeat4': 'Output format supports text and json', 'docs.overviewFeat5': 'Zero config for Claude Code users', 'docs.overviewFeat6': 'WebUI viewer for visualizing review results', - 'docs.installTitle': 'Install', 'docs.installLabel': 'Install', 'docs.installVerifyLabel': 'Verify Installation', - 'docs.configTitle': 'Configuration & Verification', 'docs.configDesc': 'Manage CLI configuration settings, stored in ~/.opencodereview/config.json.', 'docs.configInteractive': 'Interactive Setup (Recommended)', @@ -147,7 +137,6 @@ export const en: TranslationKeys = { 'docs.configKeyTelemetry': 'Telemetry', 'docs.configVerify': 'Verify Configuration', 'docs.configVerifyDesc': 'Run this command after configuration to test the LLM connection and confirm settings are correct.', - 'docs.reviewTitle': 'ocr review', 'docs.reviewDesc': 'ocr review is the core command for initiating AI code review.', 'docs.reviewModes': 'Review Modes', @@ -183,19 +172,11 @@ export const en: TranslationKeys = { 'docs.reviewFlag10Default': 'Built-in', 'docs.reviewFlag11Desc': 'Max concurrent git subprocesses', 'docs.reviewNote': 'Note: --from/--to and --commit cannot be used together. When specifying --from, --to must also be specified.', - 'docs.viewerTitle': 'ocr viewer', 'docs.viewerDesc': 'Starts a WebUI session viewer for browsing review results in a web interface.', 'docs.viewerNote': 'After running, a local HTTP server starts providing a visual interface for browsing review results.', - 'docs.envTitle': 'Claude Code Integration', 'docs.envDesc': 'If you are already a Claude Code user with the following environment variables configured, Open Code Review will recognize them automatically — no extra configuration needed:', 'docs.envNote': 'You can also use ocr config to override or supplement these settings.', 'docs.copy': 'Copy', - - // QuickStart code comments - 'quickstart.commentReview': '# Review current changes', - 'quickstart.commentBranch': '# Review diff between branches', - 'quickstart.commentCommit': '# Review a specific commit', - 'quickstart.commentEnvAuto': '# Open Code Review auto-detects these variables', }; diff --git a/pages/src/i18n/ja.ts b/pages/src/i18n/ja.ts new file mode 100644 index 0000000..f81319c --- /dev/null +++ b/pages/src/i18n/ja.ts @@ -0,0 +1,184 @@ +import { TranslationKeys } from './types'; + +export const ja: TranslationKeys = { + // Navbar + 'navbar.features': '特徴', + 'navbar.benchmark': 'ベンチマーク', + 'navbar.quickstart': 'クイックスタート', + 'navbar.docs': 'ドキュメント', + 'navbar.blog': 'ブログ', + 'navbar.getStarted': '始める', + + // Hero + 'hero.title': 'AIコードレビュー\n数百万の実タスクで検証済み', + 'hero.description': 'Open Code Review は、アリババの実戦検証済みコードレビュー Agent をワークフローに統合します。任意の LLM に接続し、データを完全にプライベートに保ち、開発者が実際に採用するレビューコメントを取得できます。', + 'hero.quickStart': 'クイックスタート', + 'hero.learnMore': '詳しく見る', + 'hero.terminal': 'ターミナル', + + // Highlights + 'highlights.stat1Value': '20K+', + 'highlights.stat1Label': 'アクティブユーザー', + 'highlights.stat1Caption': 'Alibabaグループ内で実戦検証済み', + 'highlights.stat2Value': '> 30%', + 'highlights.stat2Label': '採用率', + 'highlights.stat2Caption': 'Alibabaグループ内で実戦検証済み', + 'highlights.stat3Value': '1M+', + 'highlights.stat3Label': '実タスク', + 'highlights.stat3Caption': '実行されたコードレビュータスク', + 'highlights.stat4Value': '1/9', + 'highlights.stat4Label': 'トークンコスト', + 'highlights.stat4Caption': 'Claude Code比較 · 1,000 PR', + 'highlights.stat5Value': '25.10%', + 'highlights.stat5Label': 'AACR-BENCH', + 'highlights.stat5Caption': 'SEM.F1 ベンチマークスコア', + + // Use Cases + 'usecases.sectionLabel': 'ユースケース', + 'usecases.title': 'Open Code Review は誰のためのもの?', + 'usecases.case1Title': '個人開発者', + 'usecases.case1Desc': 'Open Code Review をローカルの AI 支援開発ワークフローに組み込み、即座に高品質なコードレビューフィードバックを取得できます。', + 'usecases.case2Title': 'プラットフォームチーム', + 'usecases.case2Desc': '内部システムにシームレスに統合し、データフローとレビューポリシーを完全に制御できます。', + 'usecases.case3Title': 'ML研究者', + 'usecases.case3Desc': 'RL トレーニングパイプラインのコード品質検証器として使用し、コード生成モデルに信頼性の高い報酬信号を提供します。', + + // Features + 'features.sectionBadge': 'コア機能', + 'features.title': 'コードレビュー専用に構築された Agent システム', + 'features.subtitle': 'ファイルの読み取り、コードベースの検索、コンテキストの相互参照、きめ細かいルールの適用による深いレビューを行う AI Agent。', + 'features.feat1Title': 'ハイブリッドアーキテクチャ:確定性 + Agent', + 'features.feat1Desc': '確定性エンジニアリング(タスク分割、ファイルフィルタリング、行番号位置決め、ルールルーティング、非同期スケジューリング)と LLM Agent 推論(リスク検出、コンテキスト探索、問題分類)を分離し、品質・トークンコスト・速度を最適化します。', + 'features.feat2Title': '精密なコメント配置とリフレクション', + 'features.feat2Desc': '独立した行レベルのコメント配置モジュールと3段階の漸進的 LLM 戦略により、各コメントを正確な行番号に配置。独立したリフレクションモジュールが幻覚や知識のドリフトを早期に検出します。', + 'features.feat3Title': 'マルチモデルプロトコルサポート', + 'features.feat3Desc': 'Anthropic Messages API と OpenAI Chat Completions API の両方をサポート。Anthropic、OpenAI、DashScope、DeepSeek、Z.AI などのプリセットプロバイダーを内蔵し、カスタムモデルエンドポイントにも対応。', + 'features.feat4Title': '動的並行処理', + 'features.feat4Desc': '設定可能な goroutine ワーカー(デフォルト8)でサブタスクを動的に分割し並列レビュー。大規模な変更セットでも迅速に完了します。', + 'features.feat5Title': 'スマートメモリ圧縮', + 'features.feat5Desc': 'コードレビュー専用のメモリ圧縮方式。3段階パーティション(凍結/圧縮/アクティブ)コンテキスト管理により、トークン制限を突破して深いレビューを実現。', + 'features.feat6Title': '組み込みレビュールール', + 'features.feat6Desc': '大規模な実環境で検証されたレビュールール。Java、TypeScript、Go、Python、Kotlin、Rust、C++、C など10以上の言語をカバーし、NPE、スレッドセーフティ、XSS、SQLインジェクションなどの専門ルールを搭載。', + + // Benchmark + 'benchmark.sectionLabel': 'オープンベンチマーク', + 'benchmark.title': '80名以上のシニアエンジニアによるクロスバリデーション', + 'benchmark.subtitle': '50の人気オープンソースリポジトリから選定した200の実PullRequestを含む、10のプログラミング言語、多様な問題タイプ、様々な変更規模をカバーする実世界のCodeReviewベンチマーク。', + 'benchmark.colRank': 'ランク', + 'benchmark.colModel': 'モデル', + 'benchmark.colSource': 'ソース', + 'benchmark.colF1': 'F1', + 'benchmark.colPrecision': '精度', + 'benchmark.colRecall': '再現率', + 'benchmark.colAvgTime': '平均時間', + 'benchmark.colAvgToken': '平均トークン', + + // QuickStart + 'quickstart.sectionLabel': 'クイックスタート', + 'quickstart.title': '数分で使い始められます', + 'quickstart.subtitle': '複雑な設定は不要。選択した LLM プロバイダー以外のサードパーティアカウントは必要ありません。', + 'quickstart.step1Title': 'インストール', + 'quickstart.step1Desc': 'npm でワンコマンドグローバルインストール。', + 'quickstart.step1Label1': 'インストール', + 'quickstart.step1Label2': 'インストール確認', + 'quickstart.step2Title': '設定', + 'quickstart.step2Desc': 'LLM プロバイダーを対話的にセットアップ、または手動で設定。', + 'quickstart.step2Label1': '対話式セットアップ(推奨)', + 'quickstart.step2Label2': '手動設定(代替)', + 'quickstart.step2Label3': '設定確認', + 'quickstart.step3Title': 'レビュー実行', + 'quickstart.step3Desc': '最初のコードレビューを開始します。', + 'quickstart.step3Label1': 'レビュー実行', + 'quickstart.commentReview': '# 現在の変更をレビュー', + 'quickstart.commentBranch': '# ブランチ間の差分をレビュー', + 'quickstart.commentCommit': '# 特定のコミットをレビュー', + 'quickstart.copied': 'コピーしました!', + + // Footer + 'footer.brand': 'Open Code Review', + 'footer.copyright': '© Copyright 2026. All rights reserved.', + + // Docs Page + 'docs.toc': '目次', + 'docs.overview': '概要', + 'docs.install': 'インストール', + 'docs.config': 'ocr config', + 'docs.review': 'ocr review', + 'docs.viewer': 'ocr viewer', + 'docs.env': 'Claude Code 統合', + 'docs.overviewTitle': '概要', + 'docs.overviewDesc': '(略称 ocr)は AI 駆動のコードレビュー CLI ツールです。', + 'docs.overviewFeatures': 'コア機能:', + 'docs.overviewFeat1': 'ワークスペース変更、ブランチ差分、単一コミットレビューモードをサポート', + 'docs.overviewFeat2': 'Anthropic および OpenAI 互換プロトコルの LLM サービスをサポート', + 'docs.overviewFeat3': 'カスタマイズ可能なタイムアウト付き並行レビュー', + 'docs.overviewFeat4': '出力形式は text と json をサポート', + 'docs.overviewFeat5': 'Claude Code ユーザーはゼロ設定', + 'docs.overviewFeat6': 'WebUI ビューアーでレビュー結果を可視化', + 'docs.installTitle': 'インストール', + 'docs.installLabel': 'インストール', + 'docs.installVerifyLabel': 'インストール確認', + 'docs.configTitle': '設定と検証', + 'docs.configDesc': 'CLI 設定を管理します。~/.opencodereview/config.json に保存されます。', + 'docs.configInteractive': '対話式セットアップ(推奨)', + 'docs.configInteractiveDesc': '最も簡単な設定方法。内蔵プロバイダーから選択するか、カスタムプロバイダーを追加します。', + 'docs.configModelSelect': 'モデル選択', + 'docs.configModelSelectDesc': 'プロバイダーを選択した後、使用するモデルを選びます。', + 'docs.configListProviders': '内蔵プロバイダー一覧', + 'docs.configListProvidersDesc': '利用可能なすべての内蔵 LLM プロバイダーを表示します。', + 'docs.configManual': '手動設定', + 'docs.configCommand': 'コマンド', + 'docs.configExample': '例', + 'docs.configKeys': 'サポートされる設定キー', + 'docs.configKeyUrl': 'API アドレス', + 'docs.configKeyToken': 'API キー', + 'docs.configKeyModel': 'モデル名', + 'docs.configKeyAnthropic': 'Anthropic SDK を使用', + 'docs.configKeyExtraBody': 'ベンダー固有のリクエストボディフィールド (JSON)', + 'docs.configKeyLanguage': '出力言語', + 'docs.configKeyTelemetry': 'テレメトリ', + 'docs.configVerify': '設定確認', + 'docs.configVerifyDesc': '設定後にこのコマンドを実行して LLM 接続をテストし、設定が正しいことを確認します。', + 'docs.reviewTitle': 'ocr review', + 'docs.reviewDesc': 'ocr review は AI コードレビューを開始するためのコアコマンドです。', + 'docs.reviewModes': 'レビューモード', + 'docs.reviewWorkspace': 'ワークスペースモード(デフォルト)', + 'docs.reviewWorkspaceDesc': '現在のワークスペースのステージ済み + 未ステージ + 未追跡の変更をレビューします。', + 'docs.reviewBranch': 'ブランチ差分モード', + 'docs.reviewBranchDesc': '2つの参照間の差分をレビューします(merge-base ベース)。', + 'docs.reviewCommit': '単一コミットモード', + 'docs.reviewCommitDesc': '特定のコミットをレビューします(親コミットとの差分)。', + 'docs.reviewAdvanced': '高度な使い方', + 'docs.reviewBackground': '要件コンテキスト付きレビュー', + 'docs.reviewBackgroundDesc': '要件コンテキスト(重要な境界条件を含む)を提供してレビュー品質を向上させます。', + 'docs.reviewJson': 'JSON 出力', + 'docs.reviewJsonDesc': '構造化 JSON 形式で結果を出力し、プログラム処理に対応します。', + 'docs.reviewAgent': 'Agent モード', + 'docs.reviewAgentDesc': '実行プロセスを表示せず最終サマリーのみ出力。自動化パイプラインに適しています。', + 'docs.reviewFlags': 'フラグリファレンス', + 'docs.reviewFlagCol1': 'フラグ', + 'docs.reviewFlagCol2': '説明', + 'docs.reviewFlagCol3': 'デフォルト', + 'docs.reviewFlag1Desc': 'レビューするコミットハッシュを指定', + 'docs.reviewFlag2Desc': '開始参照(差分の起点)', + 'docs.reviewFlag3Desc': 'ターゲット参照(差分の終点)', + 'docs.reviewFlag4Desc': '出力形式:text または json', + 'docs.reviewFlag5Desc': 'Git リポジトリルート', + 'docs.reviewFlag5Default': '現在のディレクトリ', + 'docs.reviewFlag6Desc': 'レビュールール JSON ファイルパス', + 'docs.reviewFlag6Default': '内蔵', + 'docs.reviewFlag7Desc': '最大並行ファイルレビュー数', + 'docs.reviewFlag8Desc': '単一タスクタイムアウト(分)', + 'docs.reviewFlag9Desc': '出力対象:human(プロセス表示)または agent(サマリーのみ)', + 'docs.reviewFlag10Desc': 'ファイルあたりの最大ツール呼び出しラウンド数', + 'docs.reviewFlag10Default': '内蔵', + 'docs.reviewFlag11Desc': '最大並行 git サブプロセス数', + 'docs.reviewNote': '注意:--from/--to--commit は同時に使用できません。--from を指定する場合、--to も指定する必要があります。', + 'docs.viewerTitle': 'ocr viewer', + 'docs.viewerDesc': 'WebUI セッションビューアーを起動し、Web インターフェースでレビュー結果を閲覧します。', + 'docs.viewerNote': '実行後、ローカル HTTP サーバーが起動し、レビュー結果を閲覧するためのビジュアルインターフェースを提供します。', + 'docs.envTitle': 'Claude Code 統合', + 'docs.envDesc': 'すでに Claude Code ユーザーで以下の環境変数を設定済みの場合、Open Code Review は自動的に認識します。追加設定は不要です:', + 'docs.envNote': 'ocr config を使用してこれらの設定を上書きまたは補完することもできます。', + 'docs.copy': 'コピー', +}; diff --git a/pages/src/i18n/types.ts b/pages/src/i18n/types.ts index ca793fb..bacbff0 100644 --- a/pages/src/i18n/types.ts +++ b/pages/src/i18n/types.ts @@ -1,3 +1,3 @@ -export type Language = 'en' | 'zh'; +export type Language = 'en' | 'zh' | 'ja'; export type TranslationKeys = Record; diff --git a/pages/src/i18n/zh.ts b/pages/src/i18n/zh.ts index 065b44d..fc91c87 100644 --- a/pages/src/i18n/zh.ts +++ b/pages/src/i18n/zh.ts @@ -2,106 +2,101 @@ import { TranslationKeys } from './types'; export const zh: TranslationKeys = { // Navbar - 'navbar.features': '核心功能', - 'navbar.benchmark': '性能基准', + 'navbar.features': '核心特性', + 'navbar.benchmark': '排行榜', 'navbar.quickstart': '快速开始', - 'navbar.docs': '使用文档', - 'navbar.getStarted': '立即开始', + 'navbar.docs': '文档', + 'navbar.blog': '博客', + 'navbar.getStarted': '开始使用', // Hero - 'hero.title': 'AI 代码评审', - 'hero.titleHighlight': '数百万真实场景任务的验证', - 'hero.description': 'Open Code Review 将阿里巴巴经大规模生产验证的代码评审 Agent 带入你的工作流。接入任意 LLM,数据完全私有,获得开发者真正愿意采纳的评审意见。', - 'hero.pill1': '采纳率 > 30%', - 'hero.pill2': '数据本地闭环', - 'hero.pill3': 'Token 消耗仅 1/9', - 'hero.cta1': '快速开始', - 'hero.cta2': '查看基准测试', - 'hero.users': '2 万+ 活跃用户', - 'hero.openSource': '完全开源', - 'hero.terminal': 'ocr 终端', - 'hero.badgeLabel': '基准榜第 #1', + 'hero.title': 'AI 代码审查\n经百万真实任务验证', + 'hero.description': 'Open Code Review 将阿里巴巴经过实战检验的代码审查 Agent 引入您的工作流程。连接任意 LLM,数据完全私有,获得开发者真正采纳的审查意见。', + 'hero.quickStart': '快速开始', + 'hero.learnMore': '了解更多', + 'hero.terminal': '终端', // Highlights - 'highlights.stat1Label': '活跃用户', 'highlights.stat1Value': '20K+', - 'highlights.stat1Caption': '阿里集团内部生产验证', - 'highlights.stat2Label': '真实场景', - 'highlights.stat2Value': '1M+', - 'highlights.stat2Caption': '累计运行代码评审任务', - 'highlights.stat3Label': 'Token 效率', - 'highlights.stat3Value': '1/9', - 'highlights.stat3Caption': '相比 Claude Code · 基于 1,000 次 PR 评审', - 'highlights.stat4Label': 'AACR-Bench', - 'highlights.stat4Value': '25.1%', - 'highlights.stat4Caption': 'SEM.F1 基准测试得分', + 'highlights.stat1Label': '活跃用户', + 'highlights.stat1Caption': '经阿里巴巴集团内部实战验证', + 'highlights.stat2Value': '> 30%', + 'highlights.stat2Label': '采纳率', + 'highlights.stat2Caption': '经阿里巴巴集团内部实战验证', + 'highlights.stat3Value': '1M+', + 'highlights.stat3Label': '真实任务', + 'highlights.stat3Caption': '已执行的代码审查任务', + 'highlights.stat4Value': '1/9', + 'highlights.stat4Label': 'TOKEN 成本', + 'highlights.stat4Caption': '对比 Claude Code · 1,000 个 PR', + 'highlights.stat5Value': '25.10%', + 'highlights.stat5Label': 'AACR-BENCH', + 'highlights.stat5Caption': 'SEM.F1 基准得分', - // Why Section - 'why.sectionLabel': '适用场景', - 'why.title': 'Open Code Review 适合谁?', - 'why.case1Title': '个人开发者', - 'why.case1Desc': '将 Open Code Review 嵌入本地 AI 辅助研发工作流,获得即时、高质量的代码评审反馈。', - 'why.case2Title': '平台团队', - 'why.case2Desc': '无缝集成至您的内部系统,完全掌控数据流向与评审策略。', - 'why.case3Title': '模型训练研究者', - 'why.case3Desc': '作为强化学习训练流水线中的代码质量验证器,为代码生成模型提供可靠奖励信号。', + // Use Cases + 'usecases.sectionLabel': '使用场景', + 'usecases.title': 'Open Code Review 适合谁?', + 'usecases.case1Title': '独立开发者', + 'usecases.case1Desc': '将 Open Code Review 嵌入你的本地 AI 辅助开发工作流,获得即时、高质量的代码审查反馈。', + 'usecases.case2Title': '平台团队', + 'usecases.case2Desc': '无缝集成到内部系统中,完全掌控数据流和审查策略。', + 'usecases.case3Title': 'ML 研究者', + 'usecases.case3Desc': '作为 RL 训练流水线中的代码质量验证器,为代码生成模型提供可靠的奖励信号。', - // Features Section + // Features 'features.sectionBadge': '核心特性', - 'features.title': '针对代码评审场景深度定制的 Agent 系统', - 'features.subtitle': '一个能读取文件、搜索代码库、交叉引用上下文、细粒度配置规则的 AI Agent,提供深度评审。', - 'features.feat1Title': '确定性工程与 Agent 协同的混合架构', - 'features.feat1Desc': '将确定性工程流程(任务的拆解与分发、文件过滤、行号定位、规则路由、异步调度等)与 LLM Agent 自主决策能力(风险识别、上下文探索、问题定性等)进行分层解耦与协同——让可确定化的环节由工程模块精确处理,需要语义理解的环节由 Agent 动态推理,从而在审查质量、Token 消耗与响应速度三个维度上达到更优。', - 'features.feat2Title': '精确的评论定位与反思', - 'features.feat2Desc': '独立的行级评论定位模块,基于三级递进策略的 LLM 评论精确定位机制,将每条评审意见都精确到具体行号,结构化输出让修复更高效;独立的评论反思模块,提前拦截模型发生的知识遗忘或逻辑幻觉等。', + 'features.title': '专为代码审查打造的 Agent 系统', + 'features.subtitle': '一个能读取文件、搜索代码库、交叉引用上下文并应用细粒度规则进行深度审查的 AI Agent。', + 'features.feat1Title': '混合架构:确定性 + Agent', + 'features.feat1Desc': '将确定性工程(任务拆分、文件过滤、行号定位、规则路由、异步调度)与 LLM Agent 推理(风险检测、上下文探索、问题分类)解耦——工程模块处理确定性,Agent 处理语义,在质量、Token 成本和速度之间取得最优平衡。', + 'features.feat2Title': '精准评论定位与反思', + 'features.feat2Desc': '独立的行级评论定位模块,采用三级渐进式 LLM 策略精确定位到具体行号。独立的反思模块提前拦截幻觉和知识偏移。', 'features.feat3Title': '多模型协议支持', - 'features.feat3Desc': '同时支持 Anthropic Messages API 和 OpenAI Chat Completions API。内置 Anthropic、OpenAI、DashScope、DeepSeek、Z.AI 等预设供应商,开箱即用;同时支持接入任意自定义模型端点,灵活适配私有化部署场景。', + 'features.feat3Desc': '同时支持 Anthropic Messages API 和 OpenAI Chat Completions API。内置 Anthropic、OpenAI、DashScope、DeepSeek、Z.AI 等预设提供商,开箱即用,同时支持自定义模型端点用于私有化部署。', 'features.feat4Title': '动态并发处理', - 'features.feat4Desc': '动态拆分子任务并行评审,可配置 goroutine worker 数量(默认 8 个)。大变更集也能快速完成。', + 'features.feat4Desc': '动态拆分子任务进行并行审查,支持可配置的 goroutine 工作线程(默认 8 个)。即使是大型变更集也能快速完成。', 'features.feat5Title': '智能记忆压缩', - 'features.feat5Desc': '面向代码评审场景的智能记忆压缩,三层分区(frozen/compress/active)上下文管理,突破 token 限制,确保深度评审。', - 'features.feat6Title': '内置评审规则', - 'features.feat6Desc': '经过线上海量真实场景验证迭代而来的评审规则,覆盖 Java、TypeScript、Go、Python、Kotlin、Rust、C++、C 等 10+ 语言,专项规则涵盖 NPE、线程安全、XSS、SQL 注入等常见风险类型。', + 'features.feat5Desc': '专为代码审查构建的记忆压缩方案,采用三级分区(冻结/压缩/活跃)上下文管理,突破 Token 限制实现深度审查。', + 'features.feat6Title': '内置审查规则', + 'features.feat6Desc': '经海量真实场景验证的审查规则,覆盖 Java、TypeScript、Go、Python、Kotlin、Rust、C++、C 等 10+ 种语言,包含 NPE、线程安全、XSS、SQL 注入等专项规则。', - // Benchmark Section - 'benchmark.sectionLabel': '开放基准测试', - 'benchmark.title': '80+ 位资深工程师交叉标注验证', - 'benchmark.subtitlePreRepos': '基于真实场景的 CodeReview 基准测试,从 ', - 'benchmark.subtitlePrePRs': ' 个热门开源仓库中精选 ', - 'benchmark.subtitlePreLangs': ' 个真实的 PullRequest,覆盖 ', - 'benchmark.subtitleEnd': ' 种编程语言、多种问题类型与不同的变更规模。', - 'benchmark.legendOcr': 'Open Code Review', - 'benchmark.legendCc': 'Claude Code · /code-review', + // Benchmark + 'benchmark.sectionLabel': '公开基准测试', + 'benchmark.title': '80+ 位资深工程师交叉验证', + 'benchmark.subtitle': '一个真实世界的 CodeReview 基准测试,从 50 个热门开源仓库中精选,包含 200 个真实 Pull Request,覆盖 10 种编程语言、多样的问题类型和不同规模的变更集。', 'benchmark.colRank': '排名', 'benchmark.colModel': '模型', 'benchmark.colSource': '来源', - 'benchmark.colVersion': '版本', - 'benchmark.colPrecision': '准确率', + 'benchmark.colF1': 'F1', + 'benchmark.colPrecision': '精确率', 'benchmark.colRecall': '召回率', 'benchmark.colAvgTime': '平均耗时', 'benchmark.colAvgToken': '平均 Token', - 'benchmark.tooltipInput': '输入 Token', - 'benchmark.tooltipOutput': '输出 Token', - 'benchmark.footer': '', - // QuickStart Section + // QuickStart 'quickstart.sectionLabel': '快速开始', - 'quickstart.title': '几分钟内跑起来', - 'quickstart.subtitle': '无需复杂配置,除所选 LLM 服务商外无需任何第三方账号。', + 'quickstart.title': '几分钟即可上手', + 'quickstart.subtitle': '无需复杂配置,除了您选择的 LLM 提供商外不需要第三方账户。', 'quickstart.step1Title': '安装', - 'quickstart.step1Desc': '通过 npm 一键全局安装。', + 'quickstart.step1Desc': '通过 npm 一行命令全局安装。', 'quickstart.step1Label1': '安装', 'quickstart.step1Label2': '验证安装', 'quickstart.step2Title': '配置', - 'quickstart.step2Desc': '通过交互式向导设置 LLM 供应商,或手动配置。内置支持 Anthropic、OpenAI、DashScope、DeepSeek 和 Z.AI。', + 'quickstart.step2Desc': '交互式设置 LLM 提供商,或手动配置。', 'quickstart.step2Label1': '交互式设置(推荐)', 'quickstart.step2Label2': '手动配置(备选)', 'quickstart.step2Label3': '验证配置', - 'quickstart.step3Title': '运行评审', - 'quickstart.step3Desc': '开始你的第一次代码评审。', - 'quickstart.zeroCfgTitle': '已在使用 Claude Code?零额外配置', - 'quickstart.zeroCfgDesc': '如果你已经配置了 Claude Code 的环境变量(ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN / ANTHROPIC_MODEL),Open Code Review 会自动读取这些配置,无需任何额外设置即可直接使用。', - 'quickstart.copy': '复制', + 'quickstart.step3Title': '运行审查', + 'quickstart.step3Desc': '开始你的第一次代码审查。', + 'quickstart.step3Label1': '运行审查命令', + 'quickstart.commentReview': '# 审查当前变更', + 'quickstart.commentBranch': '# 审查分支间的差异', + 'quickstart.commentCommit': '# 审查特定提交', + 'quickstart.copied': '已复制!', + + // Footer + 'footer.brand': 'Open Code Review', + 'footer.copyright': '© 版权所有 2026。保留所有权利。', // Docs Page 'docs.toc': '目录', @@ -110,92 +105,80 @@ export const zh: TranslationKeys = { 'docs.config': 'ocr config', 'docs.review': 'ocr review', 'docs.viewer': 'ocr viewer', - 'docs.env': '适配 Claude Code', - + 'docs.env': 'Claude Code 集成', 'docs.overviewTitle': '概览', - 'docs.overviewDesc': '(简称 ocr)是一个 AI 驱动的代码评审 CLI 工具。', - 'docs.overviewFeatures': '核心特性:', - 'docs.overviewFeat1': '支持工作区变更、分支对比、单提交三种评审模式', + 'docs.overviewDesc': '(简称 ocr)是一款 AI 驱动的代码审查 CLI 工具。', + 'docs.overviewFeatures': '核心功能:', + 'docs.overviewFeat1': '支持工作区变更、分支差异和单次提交审查模式', 'docs.overviewFeat2': '支持 Anthropic 和 OpenAI 兼容协议的 LLM 服务', - 'docs.overviewFeat3': '并发评审,可自定义超时时间', + 'docs.overviewFeat3': '并发审查与可自定义超时', 'docs.overviewFeat4': '输出格式支持 text 和 json', - 'docs.overviewFeat5': 'Claude Code 用户零额外配置', - 'docs.overviewFeat6': 'WebUI 查看器可视化评审结果', - + 'docs.overviewFeat5': 'Claude Code 用户零配置', + 'docs.overviewFeat6': 'WebUI 查看器可视化审查结果', 'docs.installTitle': '安装', 'docs.installLabel': '安装', 'docs.installVerifyLabel': '验证安装', - 'docs.configTitle': '配置与验证', - 'docs.configDesc': '管理 CLI 的配置设置,存储在 ~/.opencodereview/config.json。', + 'docs.configDesc': '管理 CLI 配置,存储在 ~/.opencodereview/config.json。', 'docs.configInteractive': '交互式设置(推荐)', - 'docs.configInteractiveDesc': '最简单的配置方式。从内置供应商中选择,或添加自定义供应商。', + 'docs.configInteractiveDesc': '最简单的配置方式。从内置提供商中选择或添加自定义提供商。', 'docs.configModelSelect': '模型选择', - 'docs.configModelSelectDesc': '选择供应商后,选择要使用的模型。', - 'docs.configListProviders': '查看内置供应商', - 'docs.configListProvidersDesc': '查看所有可用的内置 LLM 供应商。', + 'docs.configModelSelectDesc': '选择提供商后,选择要使用的模型。', + 'docs.configListProviders': '列出内置提供商', + 'docs.configListProvidersDesc': '查看所有可用的内置 LLM 提供商。', 'docs.configManual': '手动配置', 'docs.configCommand': '命令', 'docs.configExample': '示例', - 'docs.configKeys': '支持的配置 Key', + 'docs.configKeys': '支持的配置键', 'docs.configKeyUrl': 'API 地址', 'docs.configKeyToken': 'API 密钥', 'docs.configKeyModel': '模型名称', - 'docs.configKeyAnthropic': '是否使用 Anthropic SDK', - 'docs.configKeyExtraBody': '厂商特定的请求体字段(JSON)', + 'docs.configKeyAnthropic': '使用 Anthropic SDK', + 'docs.configKeyExtraBody': '供应商特定的请求体字段 (JSON)', 'docs.configKeyLanguage': '输出语言', - 'docs.configKeyTelemetry': '数据上报', + 'docs.configKeyTelemetry': '遥测', 'docs.configVerify': '验证配置', - 'docs.configVerifyDesc': '完成配置后运行此命令,工具会根据当前配置尝试连接指定的 LLM 服务,帮助你确认配置是否正确。', - + 'docs.configVerifyDesc': '配置后运行此命令以测试 LLM 连接并确认设置正确。', 'docs.reviewTitle': 'ocr review', - 'docs.reviewDesc': 'ocr review 是核心命令,用于发起 AI 代码评审。', - 'docs.reviewModes': '评审模式', + 'docs.reviewDesc': 'ocr review 是发起 AI 代码审查的核心命令。', + 'docs.reviewModes': '审查模式', 'docs.reviewWorkspace': '工作区模式(默认)', - 'docs.reviewWorkspaceDesc': '评审当前工作区的暂存 + 未暂存 + 未跟踪的变更。', - 'docs.reviewBranch': '分支对比模式', - 'docs.reviewBranchDesc': '评审两个引用之间的差异(基于 merge-base)。', - 'docs.reviewCommit': '单提交模式', - 'docs.reviewCommitDesc': '评审指定的提交(与其父提交的差异)。', + 'docs.reviewWorkspaceDesc': '审查当前工作区中已暂存 + 未暂存 + 未跟踪的变更。', + 'docs.reviewBranch': '分支差异模式', + 'docs.reviewBranchDesc': '审查两个引用之间的差异(基于 merge-base)。', + 'docs.reviewCommit': '单次提交模式', + 'docs.reviewCommitDesc': '审查特定提交(与父提交的差异)。', 'docs.reviewAdvanced': '高级用法', - 'docs.reviewBackground': '结合需求背景进行评审', - 'docs.reviewBackgroundDesc': '传入本次代码改动的需求背景(包括重要边界),提高代码评审效果。', - 'docs.reviewJson': 'JSON 格式输出', - 'docs.reviewJsonDesc': '以结构化 JSON 格式输出评审结果,便于后续程序化处理。', + 'docs.reviewBackground': '带需求上下文的审查', + 'docs.reviewBackgroundDesc': '提供需求上下文(包括重要边界条件)以提高审查质量。', + 'docs.reviewJson': 'JSON 输出', + 'docs.reviewJsonDesc': '以结构化 JSON 格式输出结果,便于程序化处理。', 'docs.reviewAgent': 'Agent 模式', - 'docs.reviewAgentDesc': '仅输出最终摘要,不显示执行过程,适合作为自动化流程的一部分使用。', - 'docs.reviewFlags': 'Flag 参考', - 'docs.reviewFlagCol1': 'Flag', - 'docs.reviewFlagCol2': '说明', + 'docs.reviewAgentDesc': '仅输出最终摘要,不显示执行过程,适用于自动化流水线。', + 'docs.reviewFlags': '参数参考', + 'docs.reviewFlagCol1': '参数', + 'docs.reviewFlagCol2': '描述', 'docs.reviewFlagCol3': '默认值', - 'docs.reviewFlag1Desc': '指定提交哈希进行评审', - 'docs.reviewFlag2Desc': '起始引用(diff 起点)', - 'docs.reviewFlag3Desc': '目标引用(diff 终点)', + 'docs.reviewFlag1Desc': '指定要审查的提交哈希', + 'docs.reviewFlag2Desc': '起始引用(差异起点)', + 'docs.reviewFlag3Desc': '目标引用(差异终点)', 'docs.reviewFlag4Desc': '输出格式:text 或 json', 'docs.reviewFlag5Desc': 'Git 仓库根目录', 'docs.reviewFlag5Default': '当前目录', - 'docs.reviewFlag6Desc': '评审规则的 JSON 文件路径', + 'docs.reviewFlag6Desc': '审查规则 JSON 文件路径', 'docs.reviewFlag6Default': '内置', - 'docs.reviewFlag7Desc': '最大并发评审文件数', - 'docs.reviewFlag8Desc': '单个并发任务超时时间(分钟)', + 'docs.reviewFlag7Desc': '最大并发文件审查数', + 'docs.reviewFlag8Desc': '单任务超时时间(分钟)', 'docs.reviewFlag9Desc': '输出受众:human(显示过程)或 agent(仅摘要)', - 'docs.reviewFlag10Desc': '每个文件的最大工具调用轮次;仅在大于模板默认值时生效', + 'docs.reviewFlag10Desc': '每个文件的最大工具调用轮数;仅当大于模板默认值时生效', 'docs.reviewFlag10Default': '内置', 'docs.reviewFlag11Desc': '最大并发 git 子进程数', - 'docs.reviewNote': '注意:--from/--to--commit 不可同时使用。指定 --from 时必须同时指定 --to。', - + 'docs.reviewNote': '注意:--from/--to--commit 不能同时使用。指定 --from 时,--to 也必须指定。', 'docs.viewerTitle': 'ocr viewer', - 'docs.viewerDesc': '启动 WebUI 会话查看器,用于以网页形式浏览评审结果。', - 'docs.viewerNote': '运行后会在本地启动一个 HTTP 服务器,提供可视化的评审结果浏览界面。', - - 'docs.envTitle': '适配 Claude Code', - 'docs.envDesc': '如果你已经是 Claude Code 用户并配置了以下环境变量,Open Code Review 会自动识别,无需任何额外配置:', - 'docs.envNote': '此外,工具也支持通过 ocr config 命令来覆盖或补充这些配置。', + 'docs.viewerDesc': '启动 WebUI 会话查看器,在 Web 界面中浏览审查结果。', + 'docs.viewerNote': '运行后将启动一个本地 HTTP 服务器,提供可视化界面浏览审查结果。', + 'docs.envTitle': 'Claude Code 集成', + 'docs.envDesc': '如果您已经是 Claude Code 用户并配置了以下环境变量,Open Code Review 将自动识别——无需额外配置:', + 'docs.envNote': '您也可以使用 ocr config 覆盖或补充这些设置。', 'docs.copy': '复制', - - // QuickStart code comments - 'quickstart.commentReview': '# 评审当前变更', - 'quickstart.commentBranch': '# 评审分支间的差异', - 'quickstart.commentCommit': '# 评审指定提交', - 'quickstart.commentEnvAuto': '# Open Code Review 自动识别这些变量', }; diff --git a/pages/src/index.tsx b/pages/src/index.tsx index 3c0df58..70557bc 100644 --- a/pages/src/index.tsx +++ b/pages/src/index.tsx @@ -5,6 +5,11 @@ import App from './App'; import { LanguageProvider } from './i18n'; import './styles/index.css'; +if ('scrollRestoration' in history) { + history.scrollRestoration = 'manual'; +} +window.scrollTo(0, 0); + const rootElement = document.getElementById('root') as HTMLElement; const root = ReactDOM.createRoot(rootElement); diff --git a/pages/src/pages/BenchmarkPage.tsx b/pages/src/pages/BenchmarkPage.tsx new file mode 100644 index 0000000..12261b2 --- /dev/null +++ b/pages/src/pages/BenchmarkPage.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import BenchmarkSection from '../components/BenchmarkSection'; +import Footer from '../components/Footer'; +import FadeInSection from '../components/FadeInSection'; + +const BenchmarkPage: React.FC = () => { + return ( +
+ + + + +
+ +
+ ); +}; + +export default BenchmarkPage; diff --git a/pages/src/pages/DocsPage.tsx b/pages/src/pages/DocsPage.tsx index 150ee4d..9bae6c1 100644 --- a/pages/src/pages/DocsPage.tsx +++ b/pages/src/pages/DocsPage.tsx @@ -1,6 +1,41 @@ -import React, { useState, useEffect, useRef } from 'react'; -import Navbar from '../components/Navbar'; +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import ReactDOM from 'react-dom'; import { useTranslation } from '../i18n'; +import Footer from '../components/Footer'; +import { useResponsive } from '../hooks/useResponsive'; +import copyIcon from '../assets/icons/icon-copy.svg'; +import docDownloadIcon from '../assets/icons/doc-download.svg'; +import docCheckCircleIcon from '../assets/icons/doc-check-circle.svg'; +import docEditIcon from '../assets/icons/doc-edit.svg'; +import docContentsIcon from '../assets/icons/doc-contents.svg'; + +/* Toast - same as QuickStartSection */ +const Toast: React.FC<{ message: string; visible: boolean }> = ({ message, visible }) => + ReactDOM.createPortal( +
+ {message} +
, + document.body + ); interface Section { id: string; @@ -16,59 +51,81 @@ const sectionDefs: Section[] = [ { id: 'env', labelKey: 'docs.env' }, ]; -const CodeBlock: React.FC<{ code: string; copied?: boolean; onCopy?: () => void; copyLabel?: string }> = ({ code, copied, onCopy, copyLabel }) => ( -
-
-
{code}
-
+/* ─── Code block matching reference: black bg, 1px border, rounded 6px, copy icon right ─── */ +const CodeBlock: React.FC<{ code: string; onCopy?: () => void }> = ({ code, onCopy }) => ( +
+
+      {code}
+    
{onCopy && ( - + copy +
)}
); -const DocSection: React.FC<{ id: string; title: string; children: React.ReactNode }> = ({ id, title, children }) => ( -
-

{title}

- {children} -
+/* ─── Icon box (32x32, rgba(255,255,255,0.04) bg, rounded 6px) ─── */ +const IconBox: React.FC<{ icon: string }> = ({ icon }) => ( +
+ +
); const DocsPage: React.FC = () => { const [activeSection, setActiveSection] = useState('overview'); - const [mobileTocOpen, setMobileTocOpen] = useState(false); - const [copiedIndex, setCopiedIndex] = useState(null); + const [toastVisible, setToastVisible] = useState(false); const lockedRef = useRef(null); + const unlockTimerRef = useRef>(); const { t } = useTranslation(); + const { isMobile } = useResponsive(); const sections = sectionDefs.map(s => ({ ...s, label: t(s.labelKey) })); - const handleCopy = (code: string, key: string) => { - if (navigator.clipboard?.writeText) { - navigator.clipboard.writeText(code).then(() => { - setCopiedIndex(key); - setTimeout(() => setCopiedIndex(null), 2000); + const handleCopy = useCallback((text: string) => { + if (navigator.clipboard && window.isSecureContext) { + navigator.clipboard.writeText(text).then(() => { + setToastVisible(true); + }).catch(() => { + fallbackCopy(text); }); } else { - const textarea = document.createElement('textarea'); - textarea.value = code; - textarea.style.position = 'fixed'; - textarea.style.opacity = '0'; - document.body.appendChild(textarea); - textarea.select(); - document.execCommand('copy'); - document.body.removeChild(textarea); - setCopiedIndex(key); - setTimeout(() => setCopiedIndex(null), 2000); + fallbackCopy(text); } + }, []); + + const fallbackCopy = (text: string) => { + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.style.position = 'fixed'; + textarea.style.opacity = '0'; + document.body.appendChild(textarea); + textarea.select(); + const success = document.execCommand('copy'); + document.body.removeChild(textarea); + if (success) setToastVisible(true); }; + useEffect(() => { + if (!toastVisible) return; + const timer = setTimeout(() => setToastVisible(false), 1200); + return () => clearTimeout(timer); + }, [toastVisible]); + useEffect(() => { const THRESHOLD = 160; const handleScroll = () => { @@ -93,363 +150,312 @@ const DocsPage: React.FC = () => { }; }, []); - const unlockTimerRef = useRef>(); - const scrollToSection = (id: string) => { lockedRef.current = id; clearTimeout(unlockTimerRef.current); const el = document.getElementById(id); - if (el) { - el.scrollIntoView({ behavior: 'smooth' }); - window.history.pushState(null, '', `#${id}`); - } + if (el) el.scrollIntoView({ behavior: 'smooth' }); setActiveSection(id); - unlockTimerRef.current = setTimeout(() => { - lockedRef.current = null; - }, 800); + unlockTimerRef.current = setTimeout(() => { lockedRef.current = null; }, 800); }; + /* ─── Shared styles ─── */ + const fontFamily = 'PingFang SC, -apple-system, BlinkMacSystemFont, sans-serif'; + const sectionTitle: React.CSSProperties = { fontSize: 20, fontWeight: 600, color: '#FFFFFF', margin: '0 0 16px 0', lineHeight: '28px', fontFamily }; + const subTitle: React.CSSProperties = { fontSize: 15, fontWeight: 600, color: '#FFFFFF', margin: '24px 0 8px 0', lineHeight: '24px', fontFamily }; + const desc: React.CSSProperties = { fontSize: 14, color: 'rgba(255,255,255,0.6)', lineHeight: '24px', margin: '0 0 12px 0', fontFamily }; + const sectionSpacing: React.CSSProperties = { marginBottom: 56, display: 'flex', flexDirection: 'column' as const, alignItems: 'stretch' }; + return ( -
-
-
-
+
- + {/* Main layout: content + right sidebar */} +
+ {/* Main content area */} +
+
+ {/* Page title "Docs" */} +
+

{t('navbar.docs')}

+
- {/* Mobile TOC toggle */} -
- -
+ {/* ─── Overview ─── */} +
+

{t('docs.overviewTitle')}

+

+ Open Code Review {t('docs.overviewDesc').replace(/<\/?code>/g, '')} +

+

+ {t('docs.overviewFeatures')} +

+
+ + {'✔\n✔\n✔\n✔\n✔\n✔'.split('\n').map((c, i) => {c}
)} +
+ + {t('docs.overviewFeat1')}
+ {t('docs.overviewFeat2')}
+ {t('docs.overviewFeat3')}
+ {t('docs.overviewFeat4')}
+ {t('docs.overviewFeat5')}
+ {t('docs.overviewFeat6')} +
+
+
- {/* Mobile TOC dropdown */} - {mobileTocOpen && ( -
setMobileTocOpen(false)}> -
e.stopPropagation()} - > -
    - {sections.map((s) => ( -
  • - -
  • - ))} -
+ {/* ─── Install ─── */} +
+

{t('docs.installTitle')}

+ {/* Install item */} +
+
+ + {t('docs.installLabel')} +
+ handleCopy('npm i -g @alibaba-group/open-code-review')} /> +
+ {/* Verify item */} +
+
+ + {t('docs.installVerifyLabel')} +
+ handleCopy('ocr version')} /> +
+
+ + {/* ─── Configuration & Verification ─── */} +
+

{t('docs.configTitle')}

+

{t('docs.configDesc').replace(/<\/?code>/g, '')}

+ +

{t('docs.configInteractive')}

+

{t('docs.configInteractiveDesc')}

+ handleCopy('ocr config provider')} /> + +

{t('docs.configModelSelect')}

+

{t('docs.configModelSelectDesc')}

+ handleCopy('ocr config model')} /> + +

{t('docs.configListProviders')}

+

{t('docs.configListProvidersDesc')}

+ handleCopy('ocr llm providers')} /> + +

{t('docs.configManual')}

+

{t('docs.configCommand')}

+ '} /> + +

{t('docs.configExample')}

+ handleCopy(`ocr config set llm.url https://api.anthropic.com \\\n && ocr config set llm.auth_token {{your-api-key}} \\\n && ocr config set llm.model claude-opus-4-6 \\\n && ocr config set llm.use_anthropic true \\\n && ocr config set language Chinese`)} + /> + +

{t('docs.configKeys')}

+
+ {/* 2-column grid of config keys */} + {[ + [{ key: 'llm.url', desc: t('docs.configKeyUrl') }, { key: 'llm.auth_token', desc: t('docs.configKeyToken') }], + [{ key: 'llm.model', desc: t('docs.configKeyModel') }, { key: 'llm.use_anthropic', desc: t('docs.configKeyAnthropic') }], + [{ key: 'telemetry.enabled', desc: t('docs.configKeyTelemetry') }, { key: 'language', desc: t('docs.configKeyLanguage') }], + [{ key: 'llm.extra_body', desc: t('docs.configKeyExtraBody') }], + ].map((row, ri) => ( +
+ {row.map(({ key, desc: d }) => ( +
+

+ {key} + {d} +

+
+ ))} +
+ ))} +
+ +

{t('docs.configVerify')}

+ handleCopy('ocr llm test')} + /> +

{t('docs.configVerifyDesc')}

+
+ + {/* ─── ocr review ─── */} +
+

{t('docs.reviewTitle')}

+

{t('docs.reviewDesc').replace(/<\/?code>/g, '')}

+ +

{t('docs.reviewModes')}

+ {/* Workspace Mode */} +
+
+ +
+ {t('docs.reviewWorkspace')} +

{t('docs.reviewWorkspaceDesc')}

+
+
+ handleCopy('ocr review')} /> +
+ {/* Branch Diff Mode */} +
+
+ +
+ {t('docs.reviewBranch')} +

{t('docs.reviewBranchDesc')}

+
+
+ handleCopy('ocr review --from master --to dev-ref')} /> +
+ {/* Single Commit Mode */} +
+
+ +
+ {t('docs.reviewCommit')} +

{t('docs.reviewCommitDesc')}

+
+
+ handleCopy('ocr review -c abc123')} /> +
+ +

{t('docs.reviewAdvanced')}

+ {/* Review with Requirement Context */} +
+
+ +
+ {t('docs.reviewBackground')} +

{t('docs.reviewBackgroundDesc')}

+
+
+ handleCopy('ocr review --background "requirement context"')} /> +
+ {/* JSON Output */} +
+
+ +
+ {t('docs.reviewJson')} +

{t('docs.reviewJsonDesc')}

+
+
+ handleCopy('ocr review --format json')} /> +
+ {/* Agent Mode */} +
+
+ +
+ {t('docs.reviewAgent')} +

{t('docs.reviewAgentDesc')}

+
+
+ handleCopy('ocr review --audience agent')} /> +
+ +

{t('docs.reviewFlags')}

+ {/* Flags table */} +
+ {/* Header */} +
+
{t('docs.reviewFlagCol1')}
+
{t('docs.reviewFlagCol2')}
+
{t('docs.reviewFlagCol3')}
+
+ {/* Rows */} + {[ + ['-c, --commit', t('docs.reviewFlag1Desc'), '—'], + ['--from', t('docs.reviewFlag2Desc'), '—'], + ['--to', t('docs.reviewFlag3Desc'), '—'], + ['-f, --format', t('docs.reviewFlag4Desc'), 'text'], + ['--repo', t('docs.reviewFlag5Desc'), t('docs.reviewFlag5Default')], + ['--rule', t('docs.reviewFlag6Desc'), t('docs.reviewFlag6Default')], + ['--concurrency', t('docs.reviewFlag7Desc'), '8'], + ['--timeout', t('docs.reviewFlag8Desc'), '10'], + ['--audience', t('docs.reviewFlag9Desc'), 'human'], + ['--max-tools', t('docs.reviewFlag10Desc'), t('docs.reviewFlag10Default')], + ].map(([flag, d, def], idx, arr) => ( +
+
+ {flag} +
+
+ {d} +
+
+ {def} +
+
+ ))} +
+

+ {t('docs.reviewNote').replace(/<\/?code>/g, '')} +

+
+ + {/* ─── Viewer ─── */} +
+

{t('docs.viewerTitle')}

+

{t('docs.viewerDesc')}

+ handleCopy('ocr viewer')} /> +

{t('docs.viewerNote')}

+
+ + {/* ─── Claude Code Integration ─── */} +
+

{t('docs.envTitle')}

+

+ {t('docs.envDesc').replace(/<\/?code>/g, '')} +

+ handleCopy('export ANTHROPIC_BASE_URL=https://api.anthropic.com\nexport ANTHROPIC_AUTH_TOKEN=sk-ant-xxxxx\nexport ANTHROPIC_MODEL=claude-opus-4-6')} + /> +

+ {t('docs.envNote').replace(/<\/?code>/g, '')} +

+
- )} -
- {/* Sidebar TOC — desktop */} - - - {/* Main content */} -
- {/* Overview */} - -

- Open Code Review{' '} - -

-
-

{t('docs.overviewFeatures')}

-
    - {(['docs.overviewFeat1', 'docs.overviewFeat2', 'docs.overviewFeat3', 'docs.overviewFeat4', 'docs.overviewFeat5', 'docs.overviewFeat6'] as const).map((key) => ( -
  • {t(key)}
  • - ))} -
-
-
- - {/* Install */} - -
-
-

- - {t('docs.installLabel')} -

- handleCopy('npm i -g @alibaba-group/open-code-review', 'install')} - copyLabel={t('docs.copy')} - /> -
-
-

- - {t('docs.installVerifyLabel')} -

- handleCopy('ocr version', 'install-verify')} - copyLabel={t('docs.copy')} - /> -
-
-
- - {/* Config */} - -

- -

{t('docs.configInteractive')}

-

{t('docs.configInteractiveDesc')}

-
- handleCopy('ocr config provider', 'config-provider')} - copyLabel={t('docs.copy')} - /> -
-
-

{t('docs.configModelSelect')}

-

{t('docs.configModelSelectDesc')}

- handleCopy('ocr config model', 'config-model')} - copyLabel={t('docs.copy')} - /> -
-
-

{t('docs.configListProviders')}

-

{t('docs.configListProvidersDesc')}

- handleCopy('ocr llm providers', 'config-providers-list')} - copyLabel={t('docs.copy')} - /> -
- -

{t('docs.configManual')}

- -

{t('docs.configCommand')}

- - -

{t('docs.configExample')}

-
- handleCopy(`ocr config set llm.url https://api.anthropic.com \\\n && ocr config set llm.auth_token {{your-api-key}} \\\n && ocr config set llm.model claude-opus-4-6 \\\n && ocr config set llm.use_anthropic true \\\n && ocr config set language Chinese`, 'config-examples')} - copyLabel={t('docs.copy')} - /> -
- -

{t('docs.configKeys')}

-
- {[ - { key: 'llm.url', descKey: 'docs.configKeyUrl' }, - { key: 'llm.auth_token', descKey: 'docs.configKeyToken' }, - { key: 'llm.model', descKey: 'docs.configKeyModel' }, - { key: 'llm.use_anthropic', descKey: 'docs.configKeyAnthropic' }, - { key: 'llm.extra_body', descKey: 'docs.configKeyExtraBody' }, - { key: 'language', descKey: 'docs.configKeyLanguage' }, - { key: 'telemetry.enabled', descKey: 'docs.configKeyTelemetry' }, - ].map(({ key, descKey }) => ( -
- {key} - {t(descKey)} -
))}
- -

{t('docs.configVerify')}

- -

- {t('docs.configVerifyDesc')} -

-
- - {/* Review */} - -

- -

{t('docs.reviewModes')}

-
-
-

- - {t('docs.reviewWorkspace')} -

-

{t('docs.reviewWorkspaceDesc')}

- -
-
-

- - {t('docs.reviewBranch')} -

-

{t('docs.reviewBranchDesc')}

- handleCopy('ocr review --from master --to dev-ref', 'review-branch')} - copyLabel={t('docs.copy')} - /> -
-
-

- - {t('docs.reviewCommit')} -

-

{t('docs.reviewCommitDesc')}

- handleCopy('ocr review -c abc123', 'review-commit')} - copyLabel={t('docs.copy')} - /> -
-
- -

{t('docs.reviewAdvanced')}

-
-
-

- - {t('docs.reviewBackground')} -

-

{t('docs.reviewBackgroundDesc')}

- handleCopy('ocr review --background "requirement context"', 'review-background')} - copyLabel={t('docs.copy')} - /> -
-
-

- - {t('docs.reviewJson')} -

-

{t('docs.reviewJsonDesc')}

- handleCopy('ocr review --format json', 'review-json')} - copyLabel={t('docs.copy')} - /> -
-
-

- - {t('docs.reviewAgent')} -

-

{t('docs.reviewAgentDesc')}

- handleCopy('ocr review --audience agent', 'review-agent')} - copyLabel={t('docs.copy')} - /> -
-
- -

{t('docs.reviewFlags')}

-
- - - - - - - - - - {[ - ['-c, --commit', t('docs.reviewFlag1Desc'), ''], - ['--from', t('docs.reviewFlag2Desc'), ''], - ['--to', t('docs.reviewFlag3Desc'), ''], - ['-f, --format', t('docs.reviewFlag4Desc'), 'text'], - ['--repo', t('docs.reviewFlag5Desc'), t('docs.reviewFlag5Default')], - ['--rule', t('docs.reviewFlag6Desc'), t('docs.reviewFlag6Default')], - ['--concurrency', t('docs.reviewFlag7Desc'), '8'], - ['--timeout', t('docs.reviewFlag8Desc'), '10'], - ['--audience', t('docs.reviewFlag9Desc'), 'human'], - ['--max-tools', t('docs.reviewFlag10Desc'), t('docs.reviewFlag10Default')], - ['--max-git-procs', t('docs.reviewFlag11Desc'), t('docs.reviewFlag10Default')], - ].map(([flag, desc, def]) => ( - - - - - - ))} - -
{t('docs.reviewFlagCol1')}{t('docs.reviewFlagCol2')}{t('docs.reviewFlagCol3')}
{flag}{desc}{def || '—'}
-
-

- - - {/* Viewer */} - -

- {t('docs.viewerDesc')} -

- - -

- {t('docs.viewerNote')} -

-
- - {/* Environment variables */} - -

- -

- - - {/* Footer spacer */} -

-
+
+ )}
+
+
); }; diff --git a/pages/src/pages/FeaturesPage.tsx b/pages/src/pages/FeaturesPage.tsx new file mode 100644 index 0000000..74068b0 --- /dev/null +++ b/pages/src/pages/FeaturesPage.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import HeroSection from '../components/HeroSection'; +import HighlightsSection from '../components/HighlightsSection'; +import UseCasesSection from '../components/UseCasesSection'; +import FeaturesSection from '../components/FeaturesSection'; +import BenchmarkSection from '../components/BenchmarkSection'; +import QuickStartSection from '../components/QuickStartSection'; +import Footer from '../components/Footer'; +import FadeInSection from '../components/FadeInSection'; + +const FeaturesPage: React.FC = () => { + return ( + <> + + + + + + + + + + + + + + + + + + + +
+ {toastVisible && ReactDOM.createPortal( +
+ {toastMessage} +
, + document.body + )} + ); }; diff --git a/pages/src/i18n/en.ts b/pages/src/i18n/en.ts index 8d8457a..16bbdc2 100644 --- a/pages/src/i18n/en.ts +++ b/pages/src/i18n/en.ts @@ -15,6 +15,8 @@ export const en: TranslationKeys = { 'hero.quickStart': 'Quick Start', 'hero.learnMore': 'Learn More', 'hero.terminal': 'Terminal', + 'hero.copied': 'Copied!', + 'hero.copyFailed': 'Copy failed', // Highlights 'highlights.stat1Value': '20K+', diff --git a/pages/src/i18n/ja.ts b/pages/src/i18n/ja.ts index 7821ebd..1846f87 100644 --- a/pages/src/i18n/ja.ts +++ b/pages/src/i18n/ja.ts @@ -15,6 +15,8 @@ export const ja: TranslationKeys = { 'hero.quickStart': 'クイックスタート', 'hero.learnMore': '詳しく見る', 'hero.terminal': 'ターミナル', + 'hero.copied': 'コピーしました', + 'hero.copyFailed': 'コピー失敗', // Highlights 'highlights.stat1Value': '20K+', diff --git a/pages/src/i18n/zh.ts b/pages/src/i18n/zh.ts index 1f40802..f16619e 100644 --- a/pages/src/i18n/zh.ts +++ b/pages/src/i18n/zh.ts @@ -15,6 +15,8 @@ export const zh: TranslationKeys = { 'hero.quickStart': '快速开始', 'hero.learnMore': '了解更多', 'hero.terminal': '终端', + 'hero.copied': '已复制', + 'hero.copyFailed': '复制失败', // Highlights 'highlights.stat1Value': '20K+', diff --git a/pages/src/styles/index.css b/pages/src/styles/index.css index 93a127a..1b2a5aa 100644 --- a/pages/src/styles/index.css +++ b/pages/src/styles/index.css @@ -120,3 +120,37 @@ a { .usecase-card:hover::after { opacity: 1; } + +/* Install badge text shimmer */ +@keyframes text-shimmer { + 0% { + background-position: 200% center; + } + 100% { + background-position: -200% center; + } +} + +.install-text-shimmer { + background: linear-gradient( + 90deg, + rgba(255,255,255,0.6) 0%, + rgba(255,255,255,0.6) 40%, + #ffffff 50%, + rgba(255,255,255,0.6) 60%, + rgba(255,255,255,0.6) 100% + ); + background-size: 200% 100%; + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + animation: text-shimmer 5s ease-in-out infinite; +} + +@media (prefers-reduced-motion: reduce) { + .install-text-shimmer { + animation: none; + background: #ffffff; + -webkit-text-fill-color: #ffffff; + } +} From 2301ee00cc2b9be8442896624f52c364795c2136 Mon Sep 17 00:00:00 2001 From: Preetham Noel P <143152218+PreethamNoelP@users.noreply.github.com> Date: Wed, 1 Jul 2026 13:55:23 +0530 Subject: [PATCH 34/87] docs(pages): add ocr scan documentation page (#251) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(pages): add ocr scan documentation page Adds a dedicated Scan page covering when to use ocr scan vs ocr review, basic usage, --preview dry-run, batching strategies, --no-plan/--no-dedup/--no-summary toggles, --max-tokens-budget, and the full flag reference — cross-checked against cmd/opencodereview/scan_cmd.go. Wires the /scan route into App.tsx, adds a Scan nav link, and adds scan.* i18n keys for en/zh/ja. Closes #242 * refactor(pages): move ocr scan docs into the Docs page Folds the ocr scan documentation into a new section within DocsPage.tsx instead of a standalone /scan page, matching how ocr review is structured. Removes ScanPage.tsx, the /scan route, and the Scan nav link. Also addresses the automated review findings: reuses DocsPage's existing Toast/CodeBlock/IconBox (no more duplication), adds alt="" to IconBox, i18n-izes the 'ocr review'/'ocr scan' comparison labels, and adds a console.warn when clipboard copy fails. i18n keys renamed from scan.* to docs.scan* to match the existing docs.review* naming convention. --- pages/src/i18n/en.ts | 61 +++++++++++++ pages/src/i18n/ja.ts | 61 +++++++++++++ pages/src/i18n/zh.ts | 61 +++++++++++++ pages/src/pages/DocsPage.tsx | 163 ++++++++++++++++++++++++++++++++++- 4 files changed, 344 insertions(+), 2 deletions(-) diff --git a/pages/src/i18n/en.ts b/pages/src/i18n/en.ts index 16bbdc2..b726609 100644 --- a/pages/src/i18n/en.ts +++ b/pages/src/i18n/en.ts @@ -104,6 +104,7 @@ export const en: TranslationKeys = { 'docs.install': 'Install', 'docs.config': 'ocr config', 'docs.review': 'ocr review', + 'docs.scan': 'ocr scan', 'docs.viewer': 'ocr viewer', 'docs.env': 'Claude Code Integration', 'docs.overviewTitle': 'Overview', @@ -174,6 +175,66 @@ export const en: TranslationKeys = { 'docs.reviewFlag10Default': 'Built-in', 'docs.reviewFlag11Desc': 'Max concurrent git subprocesses', 'docs.reviewNote': 'Note: --from/--to and --commit cannot be used together. When specifying --from, --to must also be specified.', + 'docs.scanTitle': 'ocr scan', + 'docs.scanDesc': 'ocr scan reviews whole files instead of a diff - no Git history or meaningful diff required. Use it to audit an unfamiliar codebase, sweep a directory before a migration, or review any project where there is nothing to diff against.', + 'docs.scanVsTitle': 'ocr scan vs. ocr review', + 'docs.scanVsReviewLabel': 'ocr review', + 'docs.scanVsReview': 'ocr review is diff-based: it reviews Git changes - staged/unstaged/untracked in the workspace, a branch range, or a single commit. It requires Git history to compute a diff.', + 'docs.scanVsScanLabel': 'ocr scan', + 'docs.scanVsScan': 'ocr scan is full-file based: it reviews complete file contents regardless of Git history. It also works in non-git directories, falling back to a .gitignore-aware filesystem walk.', + 'docs.scanUsage': 'Basic Usage', + 'docs.scanUsageWhole': 'Scan the Entire Repository', + 'docs.scanUsageWholeDesc': 'Default behavior when no --path is given.', + 'docs.scanUsagePath': 'Scan a Specific Path', + 'docs.scanUsagePathDesc': 'Pass a repo-relative directory to narrow the scan.', + 'docs.scanUsageFile': 'Scan Specific Files', + 'docs.scanUsageFileDesc': 'Comma-separated file paths also work.', + 'docs.scanUsagePreviewLabel': 'Preview Without Calling the LLM', + 'docs.scanUsagePreviewDesc': 'List which files would be scanned first — no LLM calls, no cost.', + 'docs.scanPreview': 'Dry-Run with --preview', + 'docs.scanPreviewDesc': '--preview (-p) enumerates every file the scan would touch and applies the same include/exclude filtering as a real run, but stops before any LLM call. It reports the total file count, how many are reviewable vs. excluded (and why), and total line count - useful for checking scope before spending tokens on a large repository.', + 'docs.scanBatching': 'Batching Strategies', + 'docs.scanBatchingDesc': 'The --batch flag controls how files are grouped into review batches. The scan template defaults to by-language; an empty or unrecognized value falls back to none.', + 'docs.scanBatchingNone': 'none', + 'docs.scanBatchingNoneDesc': 'Every file is its own batch - no grouping.', + 'docs.scanBatchingLang': 'by-language', + 'docs.scanBatchingLangDesc': 'Files are grouped by their (lowercased) file extension. Template default.', + 'docs.scanBatchingDir': 'by-directory', + 'docs.scanBatchingDirDesc': 'Files are grouped by their first-level directory under the repo root; root-level files form their own batch.', + 'docs.scanToggles': 'Stage Toggles: --no-plan, --no-dedup, --no-summary', + 'docs.scanTogglesDesc': 'A scan run has three optional stages beyond the core per-file review. Each can be disabled independently to trade review quality for speed and lower token cost.', + 'docs.scanTogglesPlanDesc': 'Skips the per-file PLAN_TASK pre-pass that scopes focus areas before the main review. Saves one LLM call per file; may reduce review focus.', + 'docs.scanTogglesDedupDesc': 'Skips the per-batch DEDUP_TASK. Keeps all raw comments, including near-duplicates that dedup would normally collapse.', + 'docs.scanTogglesSummaryDesc': 'Skips the post-run PROJECT_SUMMARY_TASK - the repo-level markdown summary generated after all batches finish.', + 'docs.scanBudget': 'Token Budget: --max-tokens-budget', + 'docs.scanBudgetDesc': 'Before dispatching, ocr scan prints a rough order-of-magnitude token-cost estimate. --max-tokens-budget caps total input+output token usage as reported by the LLM API; once the running total exceeds the cap, dispatch stops before starting the next batch. 0 (default) means unlimited.', + 'docs.scanFlags': 'Flag Reference', + 'docs.scanFlagCol1': 'Flag', + 'docs.scanFlagCol2': 'Description', + 'docs.scanFlagCol3': 'Default', + 'docs.scanFlag1Desc': 'Comma-separated repo-relative dirs/files to scan', + 'docs.scanFlag1Default': 'Whole repo', + 'docs.scanFlag2Desc': 'Comma-separated gitignore-style patterns to exclude; merged with rule.json excludes', + 'docs.scanFlag3Desc': 'Preview which files would be scanned without running the LLM', + 'docs.scanFlag4Desc': 'Cap total token usage (input+output); dispatch stops once exceeded (0 = unlimited)', + 'docs.scanFlag5Desc': 'Skip the per-file PLAN_TASK pre-pass', + 'docs.scanFlag6Desc': 'Skip the per-batch DEDUP_TASK', + 'docs.scanFlag7Desc': 'Skip the post-run PROJECT_SUMMARY_TASK', + 'docs.scanFlag8Desc': 'Batching strategy: none | by-language | by-directory', + 'docs.scanFlag9Desc': 'Output format: text or json', + 'docs.scanFlag10Desc': 'Max concurrent file scans', + 'docs.scanFlag11Desc': 'Concurrent task timeout in minutes', + 'docs.scanFlag12Desc': 'Output audience: human (show progress) or agent (summary only)', + 'docs.scanFlag13Desc': 'Optional requirement/business context for the scan', + 'docs.scanFlag14Desc': 'Max tool call rounds per file; only takes effect when greater than template default', + 'docs.scanFlag14Default': 'Built-in', + 'docs.scanFlag15Desc': 'Max concurrent git subprocesses', + 'docs.scanFlag16Desc': 'Path to custom JSON review rules', + 'docs.scanFlag17Desc': 'Path to custom JSON tools config', + 'docs.scanFlag17Default': 'Built-in', + 'docs.scanFlag18Desc': 'Repository or directory root to scan', + 'docs.scanFlag18Default': 'Current dir', + 'docs.scanNote': 'Note: --max-tools only raises the scan template\'s per-file tool-call budget — it has no effect when set lower than the template default.', 'docs.viewerTitle': 'ocr viewer', 'docs.viewerDesc': 'Starts a WebUI session viewer for browsing review session logs in a web interface.', 'docs.viewerNote': 'After running, a local HTTP server starts providing a visual interface for browsing review session logs.', diff --git a/pages/src/i18n/ja.ts b/pages/src/i18n/ja.ts index 1846f87..e09c4b7 100644 --- a/pages/src/i18n/ja.ts +++ b/pages/src/i18n/ja.ts @@ -106,6 +106,7 @@ export const ja: TranslationKeys = { 'docs.install': 'インストール', 'docs.config': 'ocr config', 'docs.review': 'ocr review', + 'docs.scan': 'ocr scan', 'docs.viewer': 'ocr viewer', 'docs.env': 'Claude Code 統合', 'docs.overviewTitle': '概要', @@ -176,6 +177,66 @@ export const ja: TranslationKeys = { 'docs.reviewFlag10Default': '内蔵', 'docs.reviewFlag11Desc': '最大並行 git サブプロセス数', 'docs.reviewNote': '注意:--from/--to--commit は同時に使用できません。--from を指定する場合、--to も指定する必要があります。', + 'docs.scanTitle': 'ocr scan', + 'docs.scanDesc': 'ocr scan は diff の代わりにファイル全体をレビューします。Git 履歴や意味のある diff は不要です。未知のコードベースの調査、移行前のディレクトリ調査、diff の対象がないプロジェクトのレビューに使用します。', + 'docs.scanVsTitle': 'ocr scan と ocr review の違い', + 'docs.scanVsReviewLabel': 'ocr review', + 'docs.scanVsReview': 'ocr review は diff ベースです。ワークスペースのステージ済み/未ステージ/未追跡の変更、ブランチ範囲、または単一コミットなど、Git の変更をレビューします。diff を計算するために Git 履歴が必要です。', + 'docs.scanVsScanLabel': 'ocr scan', + 'docs.scanVsScan': 'ocr scan はファイル全体ベースです。Git 履歴の有無にかかわらず、ファイルの完全な内容をレビューします。非 git ディレクトリでも動作し、その場合は .gitignore に従ったファイルシステム走査にフォールバックします。', + 'docs.scanUsage': '基本的な使い方', + 'docs.scanUsageWhole': 'リポジトリ全体をスキャン', + 'docs.scanUsageWholeDesc': '--path を指定しない場合のデフォルトの動作。', + 'docs.scanUsagePath': '特定のパスをスキャン', + 'docs.scanUsagePathDesc': 'リポジトリ相対のディレクトリを指定してスキャン範囲を絞り込みます。', + 'docs.scanUsageFile': '特定のファイルをスキャン', + 'docs.scanUsageFileDesc': 'カンマ区切りの複数ファイルパスも指定できます。', + 'docs.scanUsagePreviewLabel': 'LLM を呼び出さずにプレビュー', + 'docs.scanUsagePreviewDesc': 'まずスキャン対象のファイル一覧を表示します —— LLM 呼び出しなし、コストなし。', + 'docs.scanPreview': '--preview による Dry-Run', + 'docs.scanPreviewDesc': '--preview-p)は、スキャン対象となるすべてのファイルを列挙し、実際の実行と同じ包含/除外フィルタリングを適用しますが、LLM 呼び出しの前に停止します。ファイルの総数、レビュー対象と除外対象の数(および理由)、総行数を報告します。大きなリポジトリでトークンを消費する前にスコープを確認するのに役立ちます。', + 'docs.scanBatching': 'バッチ処理戦略', + 'docs.scanBatchingDesc': '--batch フラグは、ファイルがどのようにレビューバッチへグループ化されるかを制御します。スキャンテンプレートのデフォルトは by-language です。空の値や認識できない値は none にフォールバックします。', + 'docs.scanBatchingNone': 'none', + 'docs.scanBatchingNoneDesc': '各ファイルが独立したバッチになります —— グループ化なし。', + 'docs.scanBatchingLang': 'by-language', + 'docs.scanBatchingLangDesc': 'ファイル拡張子(小文字化)でグループ化されます。テンプレートのデフォルト。', + 'docs.scanBatchingDir': 'by-directory', + 'docs.scanBatchingDirDesc': 'リポジトリルート直下の第一階層ディレクトリでグループ化されます。ルート直下のファイルはそれぞれ独立したバッチになります。', + 'docs.scanToggles': 'ステージの切り替え:--no-plan、--no-dedup、--no-summary', + 'docs.scanTogglesDesc': 'スキャン実行には、コアとなるファイル単位のレビューに加えて3つのオプションステージがあります。各ステージは個別に無効化でき、レビュー品質とスピード・トークンコストのバランスを調整できます。', + 'docs.scanTogglesPlanDesc': 'メインレビューの前に注力すべき範囲を決める、ファイル単位の PLAN_TASK 前処理をスキップします。ファイルごとに LLM 呼び出しを1回節約できますが、レビューの焦点が薄れる可能性があります。', + 'docs.scanTogglesDedupDesc': 'バッチ単位の DEDUP_TASK をスキップします。通常は重複除去でまとめられる類似コメントも含め、すべての生コメントを保持します。', + 'docs.scanTogglesSummaryDesc': '実行後の PROJECT_SUMMARY_TASK(すべてのバッチ完了後に生成されるリポジトリ単位の markdown サマリー)をスキップします。', + 'docs.scanBudget': 'トークン予算:--max-tokens-budget', + 'docs.scanBudgetDesc': 'ディスパッチ前に、ocr scan はおおまかなトークンコストの見積もりを表示します。--max-tokens-budget は LLM API から報告される入力+出力トークンの総使用量を制限します。累積使用量が上限を超えた時点で、次のバッチの開始前にディスパッチが停止します。デフォルトの 0 は無制限を意味します。', + 'docs.scanFlags': 'フラグリファレンス', + 'docs.scanFlagCol1': 'フラグ', + 'docs.scanFlagCol2': '説明', + 'docs.scanFlagCol3': 'デフォルト', + 'docs.scanFlag1Desc': 'カンマ区切りのリポジトリ相対ディレクトリ/ファイル', + 'docs.scanFlag1Default': 'リポジトリ全体', + 'docs.scanFlag2Desc': 'カンマ区切りの gitignore 形式の除外パターン。rule.json の excludes と統合されます', + 'docs.scanFlag3Desc': 'LLM を実行せずにスキャン対象ファイルをプレビュー', + 'docs.scanFlag4Desc': 'トークン使用量(入力+出力)の合計を制限。超過するとディスパッチを停止(0 = 無制限)', + 'docs.scanFlag5Desc': 'ファイル単位の PLAN_TASK 前処理をスキップ', + 'docs.scanFlag6Desc': 'バッチ単位の DEDUP_TASK をスキップ', + 'docs.scanFlag7Desc': '実行後の PROJECT_SUMMARY_TASK をスキップ', + 'docs.scanFlag8Desc': 'バッチ処理戦略:none | by-language | by-directory', + 'docs.scanFlag9Desc': '出力形式:text または json', + 'docs.scanFlag10Desc': '最大並行ファイルスキャン数', + 'docs.scanFlag11Desc': '並行タスクのタイムアウト(分)', + 'docs.scanFlag12Desc': '出力対象:human(進捗表示)または agent(サマリーのみ)', + 'docs.scanFlag13Desc': 'スキャンに関する任意の要件・業務背景情報', + 'docs.scanFlag14Desc': 'ファイルあたりの最大ツール呼び出しラウンド数。テンプレートのデフォルトより大きい場合のみ有効', + 'docs.scanFlag14Default': '内蔵', + 'docs.scanFlag15Desc': '最大並行 git サブプロセス数', + 'docs.scanFlag16Desc': 'カスタム JSON レビュールールのパス', + 'docs.scanFlag17Desc': 'カスタム JSON ツール設定のパス', + 'docs.scanFlag17Default': '内蔵', + 'docs.scanFlag18Desc': 'スキャンするリポジトリまたはディレクトリのルート', + 'docs.scanFlag18Default': '現在のディレクトリ', + 'docs.scanNote': '注:--max-tools はスキャンテンプレートのファイル単位ツール呼び出し予算を引き上げる場合にのみ効果があります —— テンプレートのデフォルトより低い値を設定しても効果はありません。', 'docs.viewerTitle': 'ocr viewer', 'docs.viewerDesc': 'WebUI セッションビューアーを起動し、Web インターフェースでレビューセッションログを閲覧します。', 'docs.viewerNote': '実行後、ローカル HTTP サーバーが起動し、レビューセッションログを閲覧するためのビジュアルインターフェースを提供します。', diff --git a/pages/src/i18n/zh.ts b/pages/src/i18n/zh.ts index f16619e..0a4b8a3 100644 --- a/pages/src/i18n/zh.ts +++ b/pages/src/i18n/zh.ts @@ -106,6 +106,7 @@ export const zh: TranslationKeys = { 'docs.install': '安装', 'docs.config': 'ocr config', 'docs.review': 'ocr review', + 'docs.scan': 'ocr scan', 'docs.viewer': 'ocr viewer', 'docs.env': 'Claude Code 集成', 'docs.overviewTitle': '概览', @@ -176,6 +177,66 @@ export const zh: TranslationKeys = { 'docs.reviewFlag10Default': '内置', 'docs.reviewFlag11Desc': '最大并发 git 子进程数', 'docs.reviewNote': '注意:--from/--to--commit 不能同时使用。指定 --from 时,--to 也必须指定。', + 'docs.scanTitle': 'ocr scan', + 'docs.scanDesc': 'ocr scan 审查整个文件而非 diff —— 无需 Git 历史或有意义的差异。适用于审计不熟悉的代码库、迁移前的目录扫描,或任何没有可比对内容的项目。', + 'docs.scanVsTitle': 'ocr scan 与 ocr review 的区别', + 'docs.scanVsReviewLabel': 'ocr review', + 'docs.scanVsReview': 'ocr review 基于 diff:审查 Git 变更 —— 工作区中已暂存/未暂存/未跟踪的改动、分支范围,或单次提交。需要 Git 历史来计算差异。', + 'docs.scanVsScanLabel': 'ocr scan', + 'docs.scanVsScan': 'ocr scan 基于完整文件:无论是否存在 Git 历史,都审查完整的文件内容。它也可以在非 git 目录中工作,回退到遵循 .gitignore 的文件系统遍历。', + 'docs.scanUsage': '基本用法', + 'docs.scanUsageWhole': '扫描整个仓库', + 'docs.scanUsageWholeDesc': '未指定 --path 时的默认行为。', + 'docs.scanUsagePath': '扫描指定路径', + 'docs.scanUsagePathDesc': '传入相对于仓库根目录的目录以缩小扫描范围。', + 'docs.scanUsageFile': '扫描指定文件', + 'docs.scanUsageFileDesc': '也支持以逗号分隔的多个文件路径。', + 'docs.scanUsagePreviewLabel': '预览而不调用 LLM', + 'docs.scanUsagePreviewDesc': '先列出将被扫描的文件 —— 不调用 LLM,不产生费用。', + 'docs.scanPreview': '使用 --preview 进行 Dry-Run', + 'docs.scanPreviewDesc': '--preview-p)会枚举扫描将涉及的每个文件,并应用与实际运行相同的包含/排除过滤逻辑,但会在调用 LLM 之前停止。它会报告文件总数、可审查与被排除的数量(及原因)以及总行数 —— 便于在大型仓库上花费 token 之前检查扫描范围。', + 'docs.scanBatching': '批处理策略', + 'docs.scanBatchingDesc': '--batch 参数控制文件如何被分组为审查批次。扫描模板默认使用 by-language;空值或无法识别的值会回退为 none。', + 'docs.scanBatchingNone': 'none', + 'docs.scanBatchingNoneDesc': '每个文件都是独立的批次 —— 不进行分组。', + 'docs.scanBatchingLang': 'by-language', + 'docs.scanBatchingLangDesc': '按文件扩展名(小写)分组。模板默认值。', + 'docs.scanBatchingDir': 'by-directory', + 'docs.scanBatchingDirDesc': '按仓库根目录下的第一级目录分组;根目录下的文件各自组成独立批次。', + 'docs.scanToggles': '阶段开关:--no-plan、--no-dedup、--no-summary', + 'docs.scanTogglesDesc': '除核心的按文件审查外,一次扫描运行还包含三个可选阶段。每个阶段都可以单独关闭,以在审查质量与速度、token 成本之间做取舍。', + 'docs.scanTogglesPlanDesc': '跳过按文件的 PLAN_TASK 预处理阶段(该阶段在主审查前确定关注重点)。每个文件可节省一次 LLM 调用;可能降低审查聚焦度。', + 'docs.scanTogglesDedupDesc': '跳过按批次的 DEDUP_TASK。保留所有原始评论,包括去重通常会合并的相似评论。', + 'docs.scanTogglesSummaryDesc': '跳过运行结束后的 PROJECT_SUMMARY_TASK —— 即所有批次完成后生成的仓库级 markdown 摘要。', + 'docs.scanBudget': 'Token 预算:--max-tokens-budget', + 'docs.scanBudgetDesc': '在分发任务前,ocr scan 会打印一个粗略的 token 费用估算。--max-tokens-budget 限制 LLM API 报告的输入+输出 token 总用量;一旦累计用量超过该上限,将在开始下一批次前停止分发。默认值 0 表示无限制。', + 'docs.scanFlags': '参数参考', + 'docs.scanFlagCol1': '参数', + 'docs.scanFlagCol2': '描述', + 'docs.scanFlagCol3': '默认值', + 'docs.scanFlag1Desc': '以逗号分隔的、相对于仓库的待扫描目录/文件', + 'docs.scanFlag1Default': '整个仓库', + 'docs.scanFlag2Desc': '以逗号分隔的 gitignore 风格排除模式;与 rule.json 中的 excludes 合并', + 'docs.scanFlag3Desc': '预览将被扫描的文件,不运行 LLM', + 'docs.scanFlag4Desc': '限制总 token 用量(输入+输出);超出后停止分发(0 = 无限制)', + 'docs.scanFlag5Desc': '跳过按文件的 PLAN_TASK 预处理', + 'docs.scanFlag6Desc': '跳过按批次的 DEDUP_TASK', + 'docs.scanFlag7Desc': '跳过运行结束后的 PROJECT_SUMMARY_TASK', + 'docs.scanFlag8Desc': '批处理策略:none | by-language | by-directory', + 'docs.scanFlag9Desc': '输出格式:text 或 json', + 'docs.scanFlag10Desc': '最大并发文件扫描数', + 'docs.scanFlag11Desc': '并发任务超时时间(分钟)', + 'docs.scanFlag12Desc': '输出受众:human(显示进度)或 agent(仅摘要)', + 'docs.scanFlag13Desc': '可选的需求/业务背景信息', + 'docs.scanFlag14Desc': '每个文件的最大工具调用轮数;仅当大于模板默认值时生效', + 'docs.scanFlag14Default': '内置', + 'docs.scanFlag15Desc': '最大并发 git 子进程数', + 'docs.scanFlag16Desc': '自定义 JSON 审查规则的路径', + 'docs.scanFlag17Desc': '自定义 JSON 工具配置的路径', + 'docs.scanFlag17Default': '内置', + 'docs.scanFlag18Desc': '要扫描的仓库或目录根路径', + 'docs.scanFlag18Default': '当前目录', + 'docs.scanNote': '注意:--max-tools 仅会提高扫描模板中按文件的工具调用预算 —— 当设置值低于模板默认值时不生效。', 'docs.viewerTitle': 'ocr viewer', 'docs.viewerDesc': '启动 WebUI 会话查看器,在 Web 界面中浏览审查会话日志。', 'docs.viewerNote': '运行后将启动一个本地 HTTP 服务器,提供可视化界面浏览审查会话日志。', diff --git a/pages/src/pages/DocsPage.tsx b/pages/src/pages/DocsPage.tsx index 9bae6c1..4105edb 100644 --- a/pages/src/pages/DocsPage.tsx +++ b/pages/src/pages/DocsPage.tsx @@ -47,6 +47,7 @@ const sectionDefs: Section[] = [ { id: 'install', labelKey: 'docs.install' }, { id: 'config', labelKey: 'docs.config' }, { id: 'review', labelKey: 'docs.review' }, + { id: 'scan', labelKey: 'docs.scan' }, { id: 'viewer', labelKey: 'docs.viewer' }, { id: 'env', labelKey: 'docs.env' }, ]; @@ -82,7 +83,7 @@ const CodeBlock: React.FC<{ code: string; onCopy?: () => void }> = ({ code, onCo /* ─── Icon box (32x32, rgba(255,255,255,0.04) bg, rounded 6px) ─── */ const IconBox: React.FC<{ icon: string }> = ({ icon }) => (
- +
); @@ -117,7 +118,11 @@ const DocsPage: React.FC = () => { textarea.select(); const success = document.execCommand('copy'); document.body.removeChild(textarea); - if (success) setToastVisible(true); + if (success) { + setToastVisible(true); + } else { + console.warn('[DocsPage] copy to clipboard failed'); + } }; useEffect(() => { @@ -396,6 +401,160 @@ const DocsPage: React.FC = () => {

+ {/* ─── ocr scan ─── */} +
+

{t('docs.scanTitle')}

+

{t('docs.scanDesc').replace(/<\/?code>/g, '')}

+ +

{t('docs.scanVsTitle')}

+
+
+ +
+ {t('docs.scanVsReviewLabel')} +

{t('docs.scanVsReview').replace(/<\/?code>/g, '')}

+
+
+
+
+
+ +
+ {t('docs.scanVsScanLabel')} +

{t('docs.scanVsScan').replace(/<\/?code>/g, '')}

+
+
+
+ +

{t('docs.scanUsage')}

+
+
+ +
+ {t('docs.scanUsageWhole')} +

{t('docs.scanUsageWholeDesc')}

+
+
+ handleCopy('ocr scan')} /> +
+
+
+ +
+ {t('docs.scanUsagePath')} +

{t('docs.scanUsagePathDesc')}

+
+
+ handleCopy('ocr scan --path internal/agent')} /> +
+
+
+ +
+ {t('docs.scanUsageFile')} +

{t('docs.scanUsageFileDesc')}

+
+
+ handleCopy('ocr scan --path internal/agent/agent.go,internal/diff/scan.go')} /> +
+
+
+ +
+ {t('docs.scanUsagePreviewLabel')} +

{t('docs.scanUsagePreviewDesc')}

+
+
+ handleCopy('ocr scan --preview')} /> +
+ +

{t('docs.scanPreview')}

+

{t('docs.scanPreviewDesc').replace(/<\/?code>/g, '')}

+ handleCopy('ocr scan --preview')} /> + +

{t('docs.scanBatching')}

+

{t('docs.scanBatchingDesc').replace(/<\/?code>/g, '')}

+
+ {[ + [t('docs.scanBatchingNone'), t('docs.scanBatchingNoneDesc')], + [t('docs.scanBatchingLang'), t('docs.scanBatchingLangDesc')], + [t('docs.scanBatchingDir'), t('docs.scanBatchingDirDesc')], + ].map(([name, d]) => ( +
+

+ {name} + {d} +

+
+ ))} +
+ handleCopy('ocr scan --batch by-directory')} /> + +

{t('docs.scanToggles')}

+

{t('docs.scanTogglesDesc')}

+
+ {[ + ['--no-plan', t('docs.scanTogglesPlanDesc')], + ['--no-dedup', t('docs.scanTogglesDedupDesc')], + ['--no-summary', t('docs.scanTogglesSummaryDesc')], + ].map(([flag, d]) => ( +
+ {flag} + {d} +
+ ))} +
+ handleCopy('ocr scan --no-plan --no-dedup --no-summary')} /> + +

{t('docs.scanBudget')}

+

{t('docs.scanBudgetDesc').replace(/<\/?code>/g, '')}

+ handleCopy('ocr scan --max-tokens-budget 500000')} /> + +

{t('docs.scanFlags')}

+
+
+
{t('docs.scanFlagCol1')}
+
{t('docs.scanFlagCol2')}
+
{t('docs.scanFlagCol3')}
+
+ {[ + ['--path', t('docs.scanFlag1Desc'), t('docs.scanFlag1Default')], + ['--exclude', t('docs.scanFlag2Desc'), '—'], + ['-p, --preview', t('docs.scanFlag3Desc'), 'false'], + ['--max-tokens-budget', t('docs.scanFlag4Desc'), '0'], + ['--no-plan', t('docs.scanFlag5Desc'), 'false'], + ['--no-dedup', t('docs.scanFlag6Desc'), 'false'], + ['--no-summary', t('docs.scanFlag7Desc'), 'false'], + ['--batch', t('docs.scanFlag8Desc'), 'by-language'], + ['-f, --format', t('docs.scanFlag9Desc'), 'text'], + ['--concurrency', t('docs.scanFlag10Desc'), '8'], + ['--timeout', t('docs.scanFlag11Desc'), '10'], + ['--audience', t('docs.scanFlag12Desc'), 'human'], + ['-b, --background', t('docs.scanFlag13Desc'), '—'], + ['--max-tools', t('docs.scanFlag14Desc'), t('docs.scanFlag14Default')], + ['--max-git-procs', t('docs.scanFlag15Desc'), '16'], + ['--rule', t('docs.scanFlag16Desc'), '—'], + ['--tools', t('docs.scanFlag17Desc'), t('docs.scanFlag17Default')], + ['--repo', t('docs.scanFlag18Desc'), t('docs.scanFlag18Default')], + ].map(([flag, d, def], idx, arr) => ( +
+
+ {flag} +
+
+ {d} +
+
+ {def} +
+
+ ))} +
+

+ {t('docs.scanNote').replace(/<\/?code>/g, '')} +

+
+ {/* ─── Viewer ─── */}

{t('docs.viewerTitle')}

From 2a5c8946351e5a64d461d673d587ea532b6930b5 Mon Sep 17 00:00:00 2001 From: kite Date: Wed, 1 Jul 2026 16:41:24 +0800 Subject: [PATCH 35/87] docs(pages): deduplicate scan preview section and add review preview card (#255) Merge the redundant scan --preview standalone section into its existing card with richer copy, and add a matching Dry-Run Preview card to the ocr review advanced usage section. Updates en/zh/ja i18n files. --- pages/src/i18n/en.ts | 8 ++++---- pages/src/i18n/ja.ts | 8 ++++---- pages/src/i18n/zh.ts | 8 ++++---- pages/src/pages/DocsPage.tsx | 15 +++++++++++---- 4 files changed, 23 insertions(+), 16 deletions(-) diff --git a/pages/src/i18n/en.ts b/pages/src/i18n/en.ts index b726609..560737c 100644 --- a/pages/src/i18n/en.ts +++ b/pages/src/i18n/en.ts @@ -156,6 +156,8 @@ export const en: TranslationKeys = { 'docs.reviewJsonDesc': 'Output results in structured JSON format for programmatic processing.', 'docs.reviewAgent': 'Agent Mode', 'docs.reviewAgentDesc': 'Output only the final summary without execution process, suitable for automation pipelines.', + 'docs.reviewPreviewLabel': 'Dry-Run Preview', + 'docs.reviewPreviewDesc': 'Lists the files that would be reviewed along with stats (file count, changed lines) — no LLM calls, no cost. Useful for checking scope before a full review.', 'docs.reviewFlags': 'Flag Reference', 'docs.reviewFlagCol1': 'Flag', 'docs.reviewFlagCol2': 'Description', @@ -189,10 +191,8 @@ export const en: TranslationKeys = { 'docs.scanUsagePathDesc': 'Pass a repo-relative directory to narrow the scan.', 'docs.scanUsageFile': 'Scan Specific Files', 'docs.scanUsageFileDesc': 'Comma-separated file paths also work.', - 'docs.scanUsagePreviewLabel': 'Preview Without Calling the LLM', - 'docs.scanUsagePreviewDesc': 'List which files would be scanned first — no LLM calls, no cost.', - 'docs.scanPreview': 'Dry-Run with --preview', - 'docs.scanPreviewDesc': '--preview (-p) enumerates every file the scan would touch and applies the same include/exclude filtering as a real run, but stops before any LLM call. It reports the total file count, how many are reviewable vs. excluded (and why), and total line count - useful for checking scope before spending tokens on a large repository.', + 'docs.scanUsagePreviewLabel': 'Dry-Run Preview', + 'docs.scanUsagePreviewDesc': 'Lists every file in scope and reports stats (total count, reviewable vs. excluded with reasons, total lines) — no LLM calls, no cost. Useful for checking scope before spending tokens on a large repository.', 'docs.scanBatching': 'Batching Strategies', 'docs.scanBatchingDesc': 'The --batch flag controls how files are grouped into review batches. The scan template defaults to by-language; an empty or unrecognized value falls back to none.', 'docs.scanBatchingNone': 'none', diff --git a/pages/src/i18n/ja.ts b/pages/src/i18n/ja.ts index e09c4b7..f167ccb 100644 --- a/pages/src/i18n/ja.ts +++ b/pages/src/i18n/ja.ts @@ -158,6 +158,8 @@ export const ja: TranslationKeys = { 'docs.reviewJsonDesc': '構造化 JSON 形式で結果を出力し、プログラム処理に対応します。', 'docs.reviewAgent': 'Agent モード', 'docs.reviewAgentDesc': '実行プロセスを表示せず最終サマリーのみ出力。自動化パイプラインに適しています。', + 'docs.reviewPreviewLabel': 'Dry-Run プレビュー', + 'docs.reviewPreviewDesc': 'レビュー対象のファイル一覧と統計情報(ファイル数、変更行数)を表示します。LLM 呼び出しなし、コストなし —— 本番レビュー前にスコープを確認するのに役立ちます。', 'docs.reviewFlags': 'フラグリファレンス', 'docs.reviewFlagCol1': 'フラグ', 'docs.reviewFlagCol2': '説明', @@ -191,10 +193,8 @@ export const ja: TranslationKeys = { 'docs.scanUsagePathDesc': 'リポジトリ相対のディレクトリを指定してスキャン範囲を絞り込みます。', 'docs.scanUsageFile': '特定のファイルをスキャン', 'docs.scanUsageFileDesc': 'カンマ区切りの複数ファイルパスも指定できます。', - 'docs.scanUsagePreviewLabel': 'LLM を呼び出さずにプレビュー', - 'docs.scanUsagePreviewDesc': 'まずスキャン対象のファイル一覧を表示します —— LLM 呼び出しなし、コストなし。', - 'docs.scanPreview': '--preview による Dry-Run', - 'docs.scanPreviewDesc': '--preview-p)は、スキャン対象となるすべてのファイルを列挙し、実際の実行と同じ包含/除外フィルタリングを適用しますが、LLM 呼び出しの前に停止します。ファイルの総数、レビュー対象と除外対象の数(および理由)、総行数を報告します。大きなリポジトリでトークンを消費する前にスコープを確認するのに役立ちます。', + 'docs.scanUsagePreviewLabel': 'Dry-Run プレビュー', + 'docs.scanUsagePreviewDesc': 'スキャン範囲内のすべてのファイルを列挙し、統計情報(ファイル総数、レビュー対象/除外数と理由、総行数)を報告します。LLM 呼び出しなし、コストなし —— 大きなリポジトリでトークンを消費する前にスコープを確認するのに役立ちます。', 'docs.scanBatching': 'バッチ処理戦略', 'docs.scanBatchingDesc': '--batch フラグは、ファイルがどのようにレビューバッチへグループ化されるかを制御します。スキャンテンプレートのデフォルトは by-language です。空の値や認識できない値は none にフォールバックします。', 'docs.scanBatchingNone': 'none', diff --git a/pages/src/i18n/zh.ts b/pages/src/i18n/zh.ts index 0a4b8a3..c65d657 100644 --- a/pages/src/i18n/zh.ts +++ b/pages/src/i18n/zh.ts @@ -158,6 +158,8 @@ export const zh: TranslationKeys = { 'docs.reviewJsonDesc': '以结构化 JSON 格式输出结果,便于程序化处理。', 'docs.reviewAgent': 'Agent 模式', 'docs.reviewAgentDesc': '仅输出最终摘要,不显示执行过程,适用于自动化流水线。', + 'docs.reviewPreviewLabel': 'Dry-Run 预览', + 'docs.reviewPreviewDesc': '列出将被审查的文件及统计信息(文件总数、变更行数),但不调用 LLM,不产生费用 —— 适合在正式审查前确认范围。', 'docs.reviewFlags': '参数参考', 'docs.reviewFlagCol1': '参数', 'docs.reviewFlagCol2': '描述', @@ -191,10 +193,8 @@ export const zh: TranslationKeys = { 'docs.scanUsagePathDesc': '传入相对于仓库根目录的目录以缩小扫描范围。', 'docs.scanUsageFile': '扫描指定文件', 'docs.scanUsageFileDesc': '也支持以逗号分隔的多个文件路径。', - 'docs.scanUsagePreviewLabel': '预览而不调用 LLM', - 'docs.scanUsagePreviewDesc': '先列出将被扫描的文件 —— 不调用 LLM,不产生费用。', - 'docs.scanPreview': '使用 --preview 进行 Dry-Run', - 'docs.scanPreviewDesc': '--preview-p)会枚举扫描将涉及的每个文件,并应用与实际运行相同的包含/排除过滤逻辑,但会在调用 LLM 之前停止。它会报告文件总数、可审查与被排除的数量(及原因)以及总行数 —— 便于在大型仓库上花费 token 之前检查扫描范围。', + 'docs.scanUsagePreviewLabel': 'Dry-Run 预览', + 'docs.scanUsagePreviewDesc': '列出扫描范围内的所有文件并报告统计信息(文件总数、可审查/被排除数量及原因、总行数),但不调用 LLM,不产生费用 —— 适合在大型仓库上正式扫描前确认范围。', 'docs.scanBatching': '批处理策略', 'docs.scanBatchingDesc': '--batch 参数控制文件如何被分组为审查批次。扫描模板默认使用 by-language;空值或无法识别的值会回退为 none。', 'docs.scanBatchingNone': 'none', diff --git a/pages/src/pages/DocsPage.tsx b/pages/src/pages/DocsPage.tsx index 4105edb..3029323 100644 --- a/pages/src/pages/DocsPage.tsx +++ b/pages/src/pages/DocsPage.tsx @@ -360,6 +360,17 @@ const DocsPage: React.FC = () => {
handleCopy('ocr review --audience agent')} />
+ {/* Dry-Run Preview */} +
+
+ +
+ {t('docs.reviewPreviewLabel')} +

{t('docs.reviewPreviewDesc')}

+
+
+ handleCopy('ocr review --preview')} /> +

{t('docs.reviewFlags')}

{/* Flags table */} @@ -468,10 +479,6 @@ const DocsPage: React.FC = () => { handleCopy('ocr scan --preview')} />
-

{t('docs.scanPreview')}

-

{t('docs.scanPreviewDesc').replace(/<\/?code>/g, '')}

- handleCopy('ocr scan --preview')} /> -

{t('docs.scanBatching')}

{t('docs.scanBatchingDesc').replace(/<\/?code>/g, '')}

From b2a6f9bf2b22ca305dd26c3d36509f6742889203 Mon Sep 17 00:00:00 2001 From: "hezheng.lsw" Date: Wed, 1 Jul 2026 17:05:54 +0800 Subject: [PATCH 36/87] fix(pages): restore doc-download icon color for Docs page (#256) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(pages): redesign landing page UI with i18n support - Redesign all sections based on design file: Navbar, Hero, Highlights, UseCases, Features, Benchmark, QuickStart, Footer - Add scroll fade-in animation with IntersectionObserver - Implement full i18n (English/Chinese) with language toggle button - Add SVG icons and image assets - Style refinements: table borders, step containers, button styles, spacing * feat(pages): redesign Docs page with i18n, responsive sidebar, and aligned copy interaction - Redesign DocsPage UI based on reference HTML design - Add i18n support for all Docs content (en/zh/ja) - Fix right sidebar CONTENTS nav with fixed positioning - Add container backgrounds (4% opacity, 16% border) - Hide CONTENTS sidebar on mobile instead of alternative UI - Align copy icon and toast interaction with QuickStart section - Add new SVG icon assets for docs page - Add Japanese language support - Add ColorBends animated background component - Add responsive hooks and FadeInSection animation - Add Benchmark, Features, QuickStart standalone pages - Update Navbar, Footer, and LandingPage components * refactor: cleanup design drafts, rename Chinese assets, UI improvements - Remove unused design draft folders (html-files, html-files (1), inpt, doc) - Rename Chinese-named image files (容器-4_*.svg → provider-*.svg) - Add language switcher icon to Navbar with dynamic character display - Align Footer padding with Navbar (32px desktop, 16px mobile) - Fix CONTENTS sidebar position on wide screens - Add i18n support for Docs CONTENTS title - Fix clipboard copy fallback for HTTP/LAN environments - Adjust Toast padding, benchmark medal sizes - Swap highlight stats positions (Adoption Rate ↔ Active Users) - Update stat4 value to 25.10% and stat5 caption to Battle-tested - Unify Hero buttons size with Navbar Get Started button * refactor: rename icon assets with semantic English names and cleanup unused files - Rename all svg_*.svg icons to semantic names based on usage context: - icon-feature-*.svg for feature section icons - icon-usecase-*.svg for use case section icons - icon-chevron-*.svg, icon-copy.svg, icon-play.svg for UI controls - icon-github.svg, icon-language.svg, icon-sort.svg, icon-terminal-prompt.svg - Remove unused assets: 5 unreferenced svg icons, image_9e7821.png, provider-8.svg - Remove debug screenshots: debug-screenshot.png, debug-wide.png - Remove .vsix build artifact and add *.vsix to .gitignore - Update all import paths in components accordingly * fix: address PR review feedback - bugs and improvements - Fix copy-paste bug: step3Label1 had wrong text in all 3 languages - en: 'Interactive Setup (Recommended)' → 'Review Commands' - zh: '交互式设置(推荐)' → '运行审查命令' - ja: '対話式セットアップ(推奨)' → 'レビュー実行' - Fix inconsistent indentation (4 spaces → 2 spaces) in i18n files - Add .catch() handler for navigator.clipboard.writeText() in QuickStartSection - Check document.execCommand('copy') return value before showing toast - Extract fallbackCopy helper to deduplicate clipboard logic - Remove unused useCallback import from HighlightsSection - Move @types/three from dependencies to devDependencies - Add i18n key 'hero.terminal' for hardcoded Terminal label * fix: revert vscode .gitignore change and remove duplicate root SVGs - Revert extensions/vscode/.gitignore to upstream state (remove *.vsix rule) - Remove root-level brandicon.svg, claude code icon.svg, codex icon.svg (identical copies already exist in pages/src/assets/images/ with proper names) * fix: reorder highlights stats to match design reference - Reorder stats: 20K+ Active Users → > 30% Adoption Rate → 1M+ Tasks → 1/9 Token Cost → 25.10% AACR-BENCH - Update stat1 value from 30K+ to 20K+ - Sync all three languages (en/zh/ja) * chore: remove unused debug screenshots * chore: remove unused image_9e7821.png * feat(pages): consolidate icons, add scroll-to-top, responsive titles - Merge provider-1~7.svg into single icon-ocr-source.svg - Add ScrollToTop component for tab navigation reset - Add tablet breakpoint (36px) to section titles for responsive scaling * refactor(pages): extract shared responsive title hook, fix indentation - Create useSectionTitleStyle hook to eliminate nested ternaries - Replace duplicated responsive logic in 4 section components - Fix fragment children indentation in App.tsx Addresses code review feedback from PR #241 * feat(pages): add install badge with shimmer effect and copy functionality - Add install badge above hero title with download/copy icons - Implement text shimmer animation (left-to-right, 5s cycle) - Add clipboard copy with secure context check and fallback - Set download icon color to #2bde5e - Set badge background to rgba(0,0,0,0.8) - Fix mobile responsive width for badge * fix(pages): address PR #254 code review feedback - Add prefers-reduced-motion media query for shimmer animation - Extract install command to INSTALL_CMD constant (DRY) - Use t() i18n function for toast messages (hero.copied/hero.copyFailed) - Show error feedback when copy operation fails - Refactor handleCopy to use async/await instead of .then/.catch * fix(pages): align hero install badge toast style with QuickStart section - Use consistent toast visual: rgba(255,255,255,0.1) bg, 0.2 border, 0.85 text - Match padding (5px 14px), borderRadius (6px), fontSize (12px) - Position at top: 88px (below navbar) * fix(pages): separate green download icon for hero badge, restore docs icon - Restore doc-download.svg to original white (#FFFFFF, opacity 0.8) for Docs page - Create doc-download-green.svg (#2bde5e) for Hero install badge - Update HeroSection import to use green variant * fix(pages): restore doc-download.svg to white for Docs page - Restore doc-download.svg fill to #FFFFFF with opacity 0.8 - Hero badge uses separate doc-download-green.svg (#2bde5e) --- pages/src/assets/icons/doc-download-green.svg | 1 + pages/src/assets/icons/doc-download.svg | 2 +- pages/src/components/HeroSection.tsx | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) create mode 100644 pages/src/assets/icons/doc-download-green.svg diff --git a/pages/src/assets/icons/doc-download-green.svg b/pages/src/assets/icons/doc-download-green.svg new file mode 100644 index 0000000..da4fa1e --- /dev/null +++ b/pages/src/assets/icons/doc-download-green.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pages/src/assets/icons/doc-download.svg b/pages/src/assets/icons/doc-download.svg index da4fa1e..b06a6f3 100644 --- a/pages/src/assets/icons/doc-download.svg +++ b/pages/src/assets/icons/doc-download.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/pages/src/components/HeroSection.tsx b/pages/src/components/HeroSection.tsx index b1d3466..d5405d3 100644 --- a/pages/src/components/HeroSection.tsx +++ b/pages/src/components/HeroSection.tsx @@ -3,7 +3,7 @@ import ReactDOM from 'react-dom'; import { useTranslation } from '../i18n'; import { useResponsive } from '../hooks/useResponsive'; import ColorBends from './ColorBends'; -import docDownloadIcon from '../assets/icons/doc-download.svg'; +import docDownloadIcon from '../assets/icons/doc-download-green.svg'; import copyIcon from '../assets/icons/icon-copy.svg'; From 8e049189f7b11697be50997656603f2978c02604 Mon Sep 17 00:00:00 2001 From: "hezheng.lsw" Date: Wed, 1 Jul 2026 19:09:42 +0800 Subject: [PATCH 37/87] feat: update brand assets (#257) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(pages): redesign landing page UI with i18n support - Redesign all sections based on design file: Navbar, Hero, Highlights, UseCases, Features, Benchmark, QuickStart, Footer - Add scroll fade-in animation with IntersectionObserver - Implement full i18n (English/Chinese) with language toggle button - Add SVG icons and image assets - Style refinements: table borders, step containers, button styles, spacing * feat(pages): redesign Docs page with i18n, responsive sidebar, and aligned copy interaction - Redesign DocsPage UI based on reference HTML design - Add i18n support for all Docs content (en/zh/ja) - Fix right sidebar CONTENTS nav with fixed positioning - Add container backgrounds (4% opacity, 16% border) - Hide CONTENTS sidebar on mobile instead of alternative UI - Align copy icon and toast interaction with QuickStart section - Add new SVG icon assets for docs page - Add Japanese language support - Add ColorBends animated background component - Add responsive hooks and FadeInSection animation - Add Benchmark, Features, QuickStart standalone pages - Update Navbar, Footer, and LandingPage components * refactor: cleanup design drafts, rename Chinese assets, UI improvements - Remove unused design draft folders (html-files, html-files (1), inpt, doc) - Rename Chinese-named image files (容器-4_*.svg → provider-*.svg) - Add language switcher icon to Navbar with dynamic character display - Align Footer padding with Navbar (32px desktop, 16px mobile) - Fix CONTENTS sidebar position on wide screens - Add i18n support for Docs CONTENTS title - Fix clipboard copy fallback for HTTP/LAN environments - Adjust Toast padding, benchmark medal sizes - Swap highlight stats positions (Adoption Rate ↔ Active Users) - Update stat4 value to 25.10% and stat5 caption to Battle-tested - Unify Hero buttons size with Navbar Get Started button * refactor: rename icon assets with semantic English names and cleanup unused files - Rename all svg_*.svg icons to semantic names based on usage context: - icon-feature-*.svg for feature section icons - icon-usecase-*.svg for use case section icons - icon-chevron-*.svg, icon-copy.svg, icon-play.svg for UI controls - icon-github.svg, icon-language.svg, icon-sort.svg, icon-terminal-prompt.svg - Remove unused assets: 5 unreferenced svg icons, image_9e7821.png, provider-8.svg - Remove debug screenshots: debug-screenshot.png, debug-wide.png - Remove .vsix build artifact and add *.vsix to .gitignore - Update all import paths in components accordingly * fix: address PR review feedback - bugs and improvements - Fix copy-paste bug: step3Label1 had wrong text in all 3 languages - en: 'Interactive Setup (Recommended)' → 'Review Commands' - zh: '交互式设置(推荐)' → '运行审查命令' - ja: '対話式セットアップ(推奨)' → 'レビュー実行' - Fix inconsistent indentation (4 spaces → 2 spaces) in i18n files - Add .catch() handler for navigator.clipboard.writeText() in QuickStartSection - Check document.execCommand('copy') return value before showing toast - Extract fallbackCopy helper to deduplicate clipboard logic - Remove unused useCallback import from HighlightsSection - Move @types/three from dependencies to devDependencies - Add i18n key 'hero.terminal' for hardcoded Terminal label * fix: revert vscode .gitignore change and remove duplicate root SVGs - Revert extensions/vscode/.gitignore to upstream state (remove *.vsix rule) - Remove root-level brandicon.svg, claude code icon.svg, codex icon.svg (identical copies already exist in pages/src/assets/images/ with proper names) * fix: reorder highlights stats to match design reference - Reorder stats: 20K+ Active Users → > 30% Adoption Rate → 1M+ Tasks → 1/9 Token Cost → 25.10% AACR-BENCH - Update stat1 value from 30K+ to 20K+ - Sync all three languages (en/zh/ja) * chore: remove unused debug screenshots * chore: remove unused image_9e7821.png * feat(pages): consolidate icons, add scroll-to-top, responsive titles - Merge provider-1~7.svg into single icon-ocr-source.svg - Add ScrollToTop component for tab navigation reset - Add tablet breakpoint (36px) to section titles for responsive scaling * refactor(pages): extract shared responsive title hook, fix indentation - Create useSectionTitleStyle hook to eliminate nested ternaries - Replace duplicated responsive logic in 4 section components - Fix fragment children indentation in App.tsx Addresses code review feedback from PR #241 * feat(pages): add install badge with shimmer effect and copy functionality - Add install badge above hero title with download/copy icons - Implement text shimmer animation (left-to-right, 5s cycle) - Add clipboard copy with secure context check and fallback - Set download icon color to #2bde5e - Set badge background to rgba(0,0,0,0.8) - Fix mobile responsive width for badge * fix(pages): address PR #254 code review feedback - Add prefers-reduced-motion media query for shimmer animation - Extract install command to INSTALL_CMD constant (DRY) - Use t() i18n function for toast messages (hero.copied/hero.copyFailed) - Show error feedback when copy operation fails - Refactor handleCopy to use async/await instead of .then/.catch * fix(pages): align hero install badge toast style with QuickStart section - Use consistent toast visual: rgba(255,255,255,0.1) bg, 0.2 border, 0.85 text - Match padding (5px 14px), borderRadius (6px), fontSize (12px) - Position at top: 88px (below navbar) * fix(pages): separate green download icon for hero badge, restore docs icon - Restore doc-download.svg to original white (#FFFFFF, opacity 0.8) for Docs page - Create doc-download-green.svg (#2bde5e) for Hero install badge - Update HeroSection import to use green variant * fix(pages): restore doc-download.svg to white for Docs page - Restore doc-download.svg fill to #FFFFFF with opacity 0.8 - Hero badge uses separate doc-download-green.svg (#2bde5e) * feat: update brand assets - replace navbar logo, favicon and logo-core.svg * feat(docs): replace icons with semantic @agentscope-ai/icons components - Branch Diff → SparkContrastView2Line (comparison) - Single Commit → SparkHistoryLine (history) - Requirement Context → SparkDocumentLine (document) - JSON Output → SparkCode02Line (code format) - Agent Mode → SparkAgentLine (agent) - Preview → SparkVisibleLine (visibility) - Scan → SparkScanLine (scan) - Scan Path → SparkTargetLine (target) - Scan File → SparkFileCodeLine (code file) * refactor: replace logo.svg content directly instead of adding favicon.svg Remove redundant favicon.svg, update logo.svg with new brand icon content. No reference changes needed in index.html. * fix: simplify logo-core.svg for GitHub README rendering Remove mask, clipPath and mix-blend-mode that are not supported by GitHub's SVG sanitizer, causing the icon to be invisible. --- imgs/logo-core.svg | 2 +- pages/logo.svg | 2 +- pages/package.json | 1 + pages/src/assets/images/brandicon.svg | 2 +- pages/src/pages/DocsPage.tsx | 42 +++++++++++++++++++-------- 5 files changed, 34 insertions(+), 15 deletions(-) diff --git a/imgs/logo-core.svg b/imgs/logo-core.svg index 85db915..52b0b6b 100644 --- a/imgs/logo-core.svg +++ b/imgs/logo-core.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/pages/logo.svg b/pages/logo.svg index 689d9fb..fc5087b 100644 --- a/pages/logo.svg +++ b/pages/logo.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/pages/package.json b/pages/package.json index f6a0775..c2ce253 100644 --- a/pages/package.json +++ b/pages/package.json @@ -8,6 +8,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@agentscope-ai/icons": "^1.0.68", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.8.0", diff --git a/pages/src/assets/images/brandicon.svg b/pages/src/assets/images/brandicon.svg index d4fe70e..fd20f4c 100644 --- a/pages/src/assets/images/brandicon.svg +++ b/pages/src/assets/images/brandicon.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/pages/src/pages/DocsPage.tsx b/pages/src/pages/DocsPage.tsx index 3029323..c7cd1e9 100644 --- a/pages/src/pages/DocsPage.tsx +++ b/pages/src/pages/DocsPage.tsx @@ -8,6 +8,17 @@ import docDownloadIcon from '../assets/icons/doc-download.svg'; import docCheckCircleIcon from '../assets/icons/doc-check-circle.svg'; import docEditIcon from '../assets/icons/doc-edit.svg'; import docContentsIcon from '../assets/icons/doc-contents.svg'; +import { + SparkContrastView2Line, + SparkHistoryLine, + SparkDocumentLine, + SparkCode02Line, + SparkAgentLine, + SparkVisibleLine, + SparkScanLine, + SparkTargetLine, + SparkFileCodeLine, +} from '@agentscope-ai/icons'; /* Toast - same as QuickStartSection */ const Toast: React.FC<{ message: string; visible: boolean }> = ({ message, visible }) => @@ -87,6 +98,13 @@ const IconBox: React.FC<{ icon: string }> = ({ icon }) => (
); +/* ─── Spark Icon box (same style, wraps React icon component) ─── */ +const SparkIconBox: React.FC<{ children: React.ReactNode }> = ({ children }) => ( +
+ {children} +
+); + const DocsPage: React.FC = () => { const [activeSection, setActiveSection] = useState('overview'); const [toastVisible, setToastVisible] = useState(false); @@ -306,7 +324,7 @@ const DocsPage: React.FC = () => { {/* Branch Diff Mode */}
- +
{t('docs.reviewBranch')}

{t('docs.reviewBranchDesc')}

@@ -317,7 +335,7 @@ const DocsPage: React.FC = () => { {/* Single Commit Mode */}
- +
{t('docs.reviewCommit')}

{t('docs.reviewCommitDesc')}

@@ -330,7 +348,7 @@ const DocsPage: React.FC = () => { {/* Review with Requirement Context */}
- +
{t('docs.reviewBackground')}

{t('docs.reviewBackgroundDesc')}

@@ -341,7 +359,7 @@ const DocsPage: React.FC = () => { {/* JSON Output */}
- +
{t('docs.reviewJson')}

{t('docs.reviewJsonDesc')}

@@ -352,7 +370,7 @@ const DocsPage: React.FC = () => { {/* Agent Mode */}
- +
{t('docs.reviewAgent')}

{t('docs.reviewAgentDesc')}

@@ -363,7 +381,7 @@ const DocsPage: React.FC = () => { {/* Dry-Run Preview */}
- +
{t('docs.reviewPreviewLabel')}

{t('docs.reviewPreviewDesc')}

@@ -420,7 +438,7 @@ const DocsPage: React.FC = () => {

{t('docs.scanVsTitle')}

- +
{t('docs.scanVsReviewLabel')}

{t('docs.scanVsReview').replace(/<\/?code>/g, '')}

@@ -429,7 +447,7 @@ const DocsPage: React.FC = () => {
- +
{t('docs.scanVsScanLabel')}

{t('docs.scanVsScan').replace(/<\/?code>/g, '')}

@@ -440,7 +458,7 @@ const DocsPage: React.FC = () => {

{t('docs.scanUsage')}

- +
{t('docs.scanUsageWhole')}

{t('docs.scanUsageWholeDesc')}

@@ -450,7 +468,7 @@ const DocsPage: React.FC = () => {
- +
{t('docs.scanUsagePath')}

{t('docs.scanUsagePathDesc')}

@@ -460,7 +478,7 @@ const DocsPage: React.FC = () => {
- +
{t('docs.scanUsageFile')}

{t('docs.scanUsageFileDesc')}

@@ -470,7 +488,7 @@ const DocsPage: React.FC = () => {
- +
{t('docs.scanUsagePreviewLabel')}

{t('docs.scanUsagePreviewDesc')}

From 6adcb1ecd4289c33923a6770b7f3ced87b2ffe26 Mon Sep 17 00:00:00 2001 From: kite Date: Wed, 1 Jul 2026 19:10:16 +0800 Subject: [PATCH 38/87] feat: add standard MCP tool support (#212) * feat(mcp): add Model Context Protocol server support Add MCP client and provider packages that allow integrating external MCP tool servers into the review loop. Includes config commands for managing MCP servers, stdio subprocess integration tests, and comprehensive test coverage. * refactor(mcp): rename loop variable in contentToText to avoid shadowing Client receiver * fix(mcp): use platform-specific shell for setup command The MCP server setup command was hardcoded to use `sh -c`, which fails on Windows. Extract a `shellCommand` helper behind build tags to use `cmd /c` on Windows and `sh -c` elsewhere. * docs(mcp): add MCP server documentation to all README locales --- README.ja-JP.md | 42 +++++ README.ko-KR.md | 42 +++++ README.md | 42 +++++ README.ru-RU.md | 42 +++++ README.zh-CN.md | 42 +++++ cmd/opencodereview/config_cmd.go | 133 ++++++++++++-- cmd/opencodereview/config_cmd_test.go | 230 +++++++++++++++++++++++ cmd/opencodereview/flags.go | 14 +- cmd/opencodereview/procattr_unix.go | 18 ++ cmd/opencodereview/procattr_windows.go | 12 ++ cmd/opencodereview/review_cmd.go | 67 +++++++ cmd/opencodereview/shell_unix.go | 12 ++ cmd/opencodereview/shell_windows.go | 12 ++ go.mod | 10 +- go.sum | 20 +- internal/llmloop/loop.go | 26 ++- internal/mcp/client.go | 103 +++++++++++ internal/mcp/client_test.go | 230 +++++++++++++++++++++++ internal/mcp/provider.go | 115 ++++++++++++ internal/mcp/provider_test.go | 242 +++++++++++++++++++++++++ internal/mcp/stdio_test.go | 118 ++++++++++++ internal/tool/definitions.go | 22 +++ internal/tool/definitions_test.go | 41 +++++ 23 files changed, 1616 insertions(+), 19 deletions(-) create mode 100644 cmd/opencodereview/procattr_unix.go create mode 100644 cmd/opencodereview/procattr_windows.go create mode 100644 cmd/opencodereview/shell_unix.go create mode 100644 cmd/opencodereview/shell_windows.go create mode 100644 internal/mcp/client.go create mode 100644 internal/mcp/client_test.go create mode 100644 internal/mcp/provider.go create mode 100644 internal/mcp/provider_test.go create mode 100644 internal/mcp/stdio_test.go diff --git a/README.ja-JP.md b/README.ja-JP.md index 8c67db0..2e9d8c3 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -673,6 +673,11 @@ OCRは4層の優先度チェーンを使ってレビュールールを解決し | `llm.extra_headers` | string | カンマ区切りの `key=value` HTTPヘッダー | | `llm.model` | string | `claude-opus-4-6` | | `llm.use_anthropic` | boolean | `true` \| `false` | +| `mcp_servers..command` | string | MCPサーバーを起動するコマンド | +| `mcp_servers..args` | array | MCPサーバーのコマンドライン引数 | +| `mcp_servers..env` | array | 環境変数(`KEY=VALUE`形式) | +| `mcp_servers..tools` | array | 許可するツール名(空の場合はすべてのツール) | +| `mcp_servers..setup` | string | サーバー起動前に実行するセットアップコマンド | | `language` | string | 任意の言語名、例:`English`、`Chinese`(デフォルト:`English`) | | `telemetry.enabled` | boolean | `true` \| `false` | | `telemetry.exporter` | string | `console` \| `otlp` | @@ -681,6 +686,43 @@ OCRは4層の優先度チェーンを使ってレビュールールを解決し 環境変数は設定ファイルより優先されます。 +### MCPサーバー + +Open Code Reviewは[Model Context Protocol (MCP)](https://modelcontextprotocol.io/)サーバーをサポートしており、レビューエージェントがstdioトランスポートを介してコードレビュー中に外部ツールを使用できます。 + +CLIからMCPサーバーを設定します: + +```bash +# MCPサーバーを追加 +ocr config set mcp_servers..command +ocr config set mcp_servers..args '["arg1","arg2"]' +ocr config set mcp_servers..env '["KEY=VALUE"]' +ocr config set mcp_servers..tools '["tool_name"]' +ocr config set mcp_servers..setup '' + +# MCPサーバーを削除 +ocr config unset mcp_servers. +``` + +| フィールド | 必須 | 説明 | +|-----------|------|------| +| `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' +``` + ### 環境変数 | 変数 | 用途 | diff --git a/README.ko-KR.md b/README.ko-KR.md index 90b7225..06302f1 100644 --- a/README.ko-KR.md +++ b/README.ko-KR.md @@ -631,6 +631,11 @@ Config file: `~/.opencodereview/config.json` | `llm.extra_headers` | string | 쉼표로 구분된 `key=value` HTTP 헤더 | | `llm.model` | string | `claude-opus-4-6` | | `llm.use_anthropic` | boolean | `true` \| `false` | +| `mcp_servers..command` | string | MCP 서버를 시작하는 명령어 | +| `mcp_servers..args` | array | MCP 서버의 커맨드라인 인수 | +| `mcp_servers..env` | array | 환경 변수 (`KEY=VALUE` 형식) | +| `mcp_servers..tools` | array | 허용할 도구 이름 (비어 있으면 모든 도구 허용) | +| `mcp_servers..setup` | string | 서버 시작 전에 실행할 설정 명령어 | | `language` | string | 임의의 언어 이름, 예: `English`, `Chinese` (기본값: `English`) | | `telemetry.enabled` | boolean | `true` \| `false` | | `telemetry.exporter` | string | `console` \| `otlp` | @@ -639,6 +644,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..command +ocr config set mcp_servers..args '["arg1","arg2"]' +ocr config set mcp_servers..env '["KEY=VALUE"]' +ocr config set mcp_servers..tools '["tool_name"]' +ocr config set mcp_servers..setup '' + +# MCP 서버 삭제 +ocr config unset mcp_servers. +``` + +| 필드 | 필수 | 설명 | +|------|------|------| +| `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 | diff --git a/README.md b/README.md index 4186fe0..8910699 100644 --- a/README.md +++ b/README.md @@ -678,6 +678,11 @@ Config file: `~/.opencodereview/config.json` | `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..command` | string | Command to start the MCP server | +| `mcp_servers..args` | array | Command-line arguments for the MCP server | +| `mcp_servers..env` | array | Environment variables in `KEY=VALUE` format | +| `mcp_servers..tools` | array | Allowed tool names (empty = all tools) | +| `mcp_servers..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` | @@ -686,6 +691,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..command +ocr config set mcp_servers..args '["arg1","arg2"]' +ocr config set mcp_servers..env '["KEY=VALUE"]' +ocr config set mcp_servers..tools '["tool_name"]' +ocr config set mcp_servers..setup '' + +# Delete an MCP server +ocr config unset mcp_servers. +``` + +| 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 | diff --git a/README.ru-RU.md b/README.ru-RU.md index c22b8e0..7034883 100644 --- a/README.ru-RU.md +++ b/README.ru-RU.md @@ -675,6 +675,11 @@ OCR разрешает правила ревью по цепочке приор | `llm.extra_headers` | string | HTTP-заголовки `key=value` через запятую | | `llm.model` | string | `claude-opus-4-6` | | `llm.use_anthropic` | boolean | `true` \| `false` | +| `mcp_servers..command` | string | Команда для запуска MCP-сервера | +| `mcp_servers..args` | array | Аргументы командной строки для MCP-сервера | +| `mcp_servers..env` | array | Переменные окружения в формате `KEY=VALUE` | +| `mcp_servers..tools` | array | Разрешённые имена инструментов (пусто = все инструменты) | +| `mcp_servers..setup` | string | Команда настройки перед запуском сервера | | `language` | string | Любое название языка, например `English`, `Chinese` (по умолчанию: `English`) | | `telemetry.enabled` | boolean | `true` \| `false` | | `telemetry.exporter` | string | `console` \| `otlp` | @@ -683,6 +688,43 @@ OCR разрешает правила ревью по цепочке приор Переменные окружения имеют приоритет над файлом конфигурации. +### MCP-сервер + +Open Code Review поддерживает серверы [Model Context Protocol (MCP)](https://modelcontextprotocol.io/), позволяя агенту ревью использовать внешние инструменты во время проверки кода через stdio-транспорт. + +Настройка MCP-серверов через CLI: + +```bash +# Добавить MCP-сервер +ocr config set mcp_servers..command +ocr config set mcp_servers..args '["arg1","arg2"]' +ocr config set mcp_servers..env '["KEY=VALUE"]' +ocr config set mcp_servers..tools '["tool_name"]' +ocr config set mcp_servers..setup '' + +# Удалить MCP-сервер +ocr config unset mcp_servers. +``` + +| Поле | Обязательно | Описание | +|------|-------------|----------| +| `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' +``` + ### Переменные окружения | Переменная | Назначение | diff --git a/README.zh-CN.md b/README.zh-CN.md index 01df8a1..83524ea 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -663,6 +663,11 @@ OCR 通过四层优先级链解析评审规则。每层采用首次匹配原则 | `llm.extra_headers` | string | 逗号分隔的 `key=value` HTTP 头 | | `llm.model` | string | `claude-opus-4-6` | | `llm.use_anthropic` | boolean | `true` \| `false` | +| `mcp_servers..command` | string | 启动 MCP 服务器的命令 | +| `mcp_servers..args` | array | MCP 服务器的命令行参数 | +| `mcp_servers..env` | array | 环境变量,`KEY=VALUE` 格式 | +| `mcp_servers..tools` | array | 允许使用的工具名称(为空则允许所有工具) | +| `mcp_servers..setup` | string | 启动服务器前运行的初始化命令 | | `language` | string | 任意语言名称,例如 `English`、`Chinese`(默认:`English`) | | `telemetry.enabled` | boolean | `true` \| `false` | | `telemetry.exporter` | string | `console` \| `otlp` | @@ -671,6 +676,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..command +ocr config set mcp_servers..args '["arg1","arg2"]' +ocr config set mcp_servers..env '["KEY=VALUE"]' +ocr config set mcp_servers..tools '["tool_name"]' +ocr config set mcp_servers..setup '' + +# 删除 MCP 服务器 +ocr config unset mcp_servers. +``` + +| 字段 | 必填 | 说明 | +|------|------|------| +| `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' +``` + ### 环境变量 | 变量 | 用途 | diff --git a/cmd/opencodereview/config_cmd.go b/cmd/opencodereview/config_cmd.go index 41cac15..64b511b 100644 --- a/cmd/opencodereview/config_cmd.go +++ b/cmd/opencodereview/config_cmd.go @@ -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.") + if len(parts) != 2 || parts[1] == "" { + return fmt.Errorf("unset supports custom_providers. and mcp_servers.") } - 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. and mcp_servers.") + } } 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.., custom_providers.., 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.., custom_providers.., mcp_servers.., 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 } @@ -491,6 +536,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..", 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{} diff --git a/cmd/opencodereview/config_cmd_test.go b/cmd/opencodereview/config_cmd_test.go index af1e43a..8c7de98 100644 --- a/cmd/opencodereview/config_cmd_test.go +++ b/cmd/opencodereview/config_cmd_test.go @@ -445,6 +445,236 @@ func TestMergeModelLists(t *testing.T) { } } +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 { diff --git a/cmd/opencodereview/flags.go b/cmd/opencodereview/flags.go index 4432ceb..ccda515 100644 --- a/cmd/opencodereview/flags.go +++ b/cmd/opencodereview/flags.go @@ -275,6 +275,7 @@ func printConfigUsage() { Usage: ocr config set ocr config unset custom_providers. Delete a custom provider + ocr config unset mcp_servers. Delete an MCP server ocr config provider Interactive provider setup ocr config model Interactive model selection @@ -301,6 +302,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 +319,7 @@ Examples: ocr config set language English ocr config set telemetry.enabled true -Supported keys: provider, model, providers.., custom_providers.., 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.., custom_providers.., mcp_servers.., 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`) } diff --git a/cmd/opencodereview/procattr_unix.go b/cmd/opencodereview/procattr_unix.go new file mode 100644 index 0000000..6861ead --- /dev/null +++ b/cmd/opencodereview/procattr_unix.go @@ -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) + } +} diff --git a/cmd/opencodereview/procattr_windows.go b/cmd/opencodereview/procattr_windows.go new file mode 100644 index 0000000..4b947be --- /dev/null +++ b/cmd/opencodereview/procattr_windows.go @@ -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. +} diff --git a/cmd/opencodereview/review_cmd.go b/cmd/opencodereview/review_cmd.go index 91a7491..d76951b 100644 --- a/cmd/opencodereview/review_cmd.go +++ b/cmd/opencodereview/review_cmd.go @@ -5,10 +5,12 @@ 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/telemetry" "github.com/open-code-review/open-code-review/internal/tool" ) @@ -61,6 +63,19 @@ 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, @@ -180,6 +195,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)) diff --git a/cmd/opencodereview/shell_unix.go b/cmd/opencodereview/shell_unix.go new file mode 100644 index 0000000..a66ce86 --- /dev/null +++ b/cmd/opencodereview/shell_unix.go @@ -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) +} diff --git a/cmd/opencodereview/shell_windows.go b/cmd/opencodereview/shell_windows.go new file mode 100644 index 0000000..bfb0311 --- /dev/null +++ b/cmd/opencodereview/shell_windows.go @@ -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) +} diff --git a/go.mod b/go.mod index 3f48112..84dc3a3 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ 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 @@ -8,6 +8,7 @@ require ( charm.land/lipgloss/v2 v2.0.4 github.com/anthropics/anthropic-sdk-go v1.52.0 github.com/bmatcuk/doublestar/v4 v4.10.0 + github.com/modelcontextprotocol/go-sdk v1.6.1 github.com/openai/openai-go/v3 v3.41.0 github.com/pkoukk/tiktoken-go v0.1.8 go.opentelemetry.io/otel v1.44.0 @@ -35,9 +36,10 @@ 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.29.0 // indirect github.com/invopop/jsonschema v0.14.0 // indirect @@ -46,17 +48,21 @@ require ( 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.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.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.37.0 // indirect diff --git a/go.sum b/go.sum index 5085065..bb44b6b 100644 --- a/go.sum +++ b/go.sum @@ -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,10 +49,14 @@ 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.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= @@ -63,6 +67,8 @@ github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW 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.41.0 h1:9GkxcN02U5NG0WGdQjZ0cTSu/pMXEyzL2LfF0ruZCck= @@ -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,6 +101,8 @@ 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.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= @@ -125,12 +137,16 @@ golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= 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.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-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= diff --git a/internal/llmloop/loop.go b/internal/llmloop/loop.go index 5239a04..d206a78 100644 --- a/internal/llmloop/loop.go +++ b/internal/llmloop/loop.go @@ -260,8 +260,32 @@ 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) + startTime := time.Now() + result, err := p.Execute(ctx, dynArgs) + dur := time.Since(startTime) + if err != nil { + 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.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 { diff --git a/internal/mcp/client.go b/internal/mcp/client.go new file mode 100644 index 0000000..b06d7cd --- /dev/null +++ b/internal/mcp/client.go @@ -0,0 +1,103 @@ +package mcp + +import ( + "context" + "fmt" + "os" + "os/exec" + "strings" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// Client wraps a single MCP server connection via stdio transport. +type Client struct { + name string + session *mcp.ClientSession + tools []*mcp.Tool +} + +// NewClient starts an MCP server subprocess, initializes the connection, +// and caches the list of available tools. The context governs the +// initialization timeout (Connect + ListTools), NOT the subprocess +// lifetime — the subprocess stays alive until Close is called. +// When dir is non-empty, the subprocess runs with that working directory. +func NewClient(ctx context.Context, name, command string, args, env []string, dir, version string) (*Client, error) { + cmd := exec.Command(command, args...) + cmd.Env = append(os.Environ(), env...) + if dir != "" { + cmd.Dir = dir + } + + client := mcp.NewClient( + &mcp.Implementation{Name: "open-code-review", Version: version}, + nil, + ) + + transport := &mcp.CommandTransport{Command: cmd} + session, err := client.Connect(ctx, transport, nil) + if err != nil { + return nil, fmt.Errorf("connect to MCP server %q: %w", name, err) + } + + var success bool + defer func() { + if !success { + session.Close() + } + }() + + toolsResult, err := session.ListTools(ctx, nil) + if err != nil { + return nil, fmt.Errorf("list tools from MCP server %q: %w", name, err) + } + + success = true + return &Client{ + name: name, + session: session, + tools: toolsResult.Tools, + }, nil +} + +func (c *Client) Name() string { return c.name } +func (c *Client) Tools() []*mcp.Tool { return c.tools } + +// CallTool invokes a tool on the MCP server and returns the text result. +func (c *Client) CallTool(ctx context.Context, name string, args map[string]any) (string, error) { + params := &mcp.CallToolParams{ + Name: name, + Arguments: args, + } + + result, err := c.session.CallTool(ctx, params) + if err != nil { + return "", fmt.Errorf("call MCP tool %q: %w", name, err) + } + + if result.IsError { + return fmt.Sprintf("MCP tool %q returned an error: %s", name, contentToText(result.Content)), nil + } + + return contentToText(result.Content), nil +} + +func (c *Client) Close() error { + return c.session.Close() +} + +func contentToText(contents []mcp.Content) string { + var parts []string + for _, item := range contents { + switch v := item.(type) { + case *mcp.TextContent: + parts = append(parts, v.Text) + default: + parts = append(parts, fmt.Sprintf("[unsupported content type: %T]", item)) + } + } + if len(parts) == 0 { + return "" + } + return strings.Join(parts, "\n") +} diff --git a/internal/mcp/client_test.go b/internal/mcp/client_test.go new file mode 100644 index 0000000..07aee15 --- /dev/null +++ b/internal/mcp/client_test.go @@ -0,0 +1,230 @@ +package mcp + +import ( + "context" + "encoding/json" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func TestContentToText_SingleText(t *testing.T) { + contents := []mcp.Content{ + &mcp.TextContent{Text: "hello"}, + } + got := contentToText(contents) + if got != "hello" { + t.Errorf("contentToText() = %q, want %q", got, "hello") + } +} + +func TestContentToText_MultipleText(t *testing.T) { + contents := []mcp.Content{ + &mcp.TextContent{Text: "line1"}, + &mcp.TextContent{Text: "line2"}, + } + got := contentToText(contents) + want := "line1\nline2" + if got != want { + t.Errorf("contentToText() = %q, want %q", got, want) + } +} + +func TestContentToText_Empty(t *testing.T) { + got := contentToText(nil) + if got != "" { + t.Errorf("contentToText(nil) = %q, want empty", got) + } + + got = contentToText([]mcp.Content{}) + if got != "" { + t.Errorf("contentToText([]) = %q, want empty", got) + } +} + +func TestContentToText_UnsupportedType(t *testing.T) { + contents := []mcp.Content{ + &mcp.ImageContent{MIMEType: "image/png", Data: []byte("data")}, + } + got := contentToText(contents) + if got == "" { + t.Error("expected non-empty for unsupported type") + } +} + +func TestClient_NameAndTools(t *testing.T) { + tools := []*mcp.Tool{{Name: "t1"}, {Name: "t2"}} + c := &Client{name: "test-srv", tools: tools} + + if c.Name() != "test-srv" { + t.Errorf("Name() = %q, want %q", c.Name(), "test-srv") + } + if len(c.Tools()) != 2 { + t.Errorf("Tools() len = %d, want 2", len(c.Tools())) + } +} + +func TestProvider_Execute_Integration(t *testing.T) { + server := mcp.NewServer( + &mcp.Implementation{Name: "test-server", Version: "v0.0.1"}, + nil, + ) + server.AddTool( + &mcp.Tool{ + Name: "greet", + Description: "Greets", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "name": map[string]any{"type": "string"}, + }, + }, + }, + func(_ context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + var args struct { + Name string `json:"name"` + } + if err := json.Unmarshal(req.Params.Arguments, &args); err != nil { + return nil, err + } + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: "hello " + args.Name}}, + }, nil + }, + ) + + ctx := context.Background() + st, ct := mcp.NewInMemoryTransports() + if _, err := server.Connect(ctx, st, nil); err != nil { + t.Fatalf("server connect: %v", err) + } + sdkClient := mcp.NewClient( + &mcp.Implementation{Name: "test-client", Version: "v0.0.1"}, + nil, + ) + session, err := sdkClient.Connect(ctx, ct, nil) + if err != nil { + t.Fatalf("client connect: %v", err) + } + + toolsResult, err := session.ListTools(ctx, nil) + if err != nil { + t.Fatalf("list tools: %v", err) + } + + c := &Client{name: "test-srv", session: session, tools: toolsResult.Tools} + defer c.Close() + + p := &Provider{toolName: "greet", client: c} + result, err := p.Execute(ctx, map[string]any{"name": "world"}) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if result != "hello world" { + t.Errorf("Execute result = %q, want %q", result, "hello world") + } +} + +func TestNewClient_Integration(t *testing.T) { + server := mcp.NewServer( + &mcp.Implementation{Name: "test-server", Version: "v0.0.1"}, + nil, + ) + server.AddTool( + &mcp.Tool{ + Name: "echo", + Description: "Echoes input", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "message": map[string]any{"type": "string"}, + }, + }, + }, + func(_ context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + var args struct { + Message string `json:"message"` + } + if err := json.Unmarshal(req.Params.Arguments, &args); err != nil { + return nil, err + } + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: "echo: " + args.Message}}, + }, nil + }, + ) + + server.AddTool( + &mcp.Tool{ + Name: "fail", + Description: "Always fails", + InputSchema: map[string]any{"type": "object"}, + }, + func(_ context.Context, _ *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{&mcp.TextContent{Text: "something went wrong"}}, + }, nil + }, + ) + + ctx := context.Background() + st, ct := mcp.NewInMemoryTransports() + if _, err := server.Connect(ctx, st, nil); err != nil { + t.Fatalf("server connect: %v", err) + } + + sdkClient := mcp.NewClient( + &mcp.Implementation{Name: "test-client", Version: "v0.0.1"}, + nil, + ) + session, err := sdkClient.Connect(ctx, ct, nil) + if err != nil { + t.Fatalf("client connect: %v", err) + } + + toolsResult, err := session.ListTools(ctx, nil) + if err != nil { + t.Fatalf("list tools: %v", err) + } + + c := &Client{ + name: "test-srv", + session: session, + tools: toolsResult.Tools, + } + defer c.Close() + + if c.Name() != "test-srv" { + t.Errorf("Name() = %q", c.Name()) + } + if len(c.Tools()) != 2 { + t.Errorf("Tools() len = %d, want 2", len(c.Tools())) + } + + t.Run("CallTool_success", func(t *testing.T) { + result, err := c.CallTool(ctx, "echo", map[string]any{"message": "hi"}) + if err != nil { + t.Fatalf("CallTool: %v", err) + } + if result != "echo: hi" { + t.Errorf("CallTool result = %q, want %q", result, "echo: hi") + } + }) + + t.Run("CallTool_error_result", func(t *testing.T) { + result, err := c.CallTool(ctx, "fail", nil) + if err != nil { + t.Fatalf("CallTool: %v", err) + } + if result == "" { + t.Error("expected non-empty error message") + } + }) + + t.Run("Close", func(t *testing.T) { + if err := c.Close(); err != nil { + t.Errorf("Close: %v", err) + } + }) +} diff --git a/internal/mcp/provider.go b/internal/mcp/provider.go new file mode 100644 index 0000000..8fe0f0d --- /dev/null +++ b/internal/mcp/provider.go @@ -0,0 +1,115 @@ +package mcp + +import ( + "context" + "fmt" + "os" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/open-code-review/open-code-review/internal/llm" + "github.com/open-code-review/open-code-review/internal/tool" +) + +// Provider adapts a single MCP tool to the tool.Provider interface. +type Provider struct { + toolName string + client *Client +} + +func (p *Provider) Tool() tool.Tool { + return tool.Dynamic(p.toolName) +} + +func (p *Provider) Execute(ctx context.Context, args map[string]any) (string, error) { + return p.client.CallTool(ctx, p.toolName, args) +} + +// RegisterAll registers tools from the MCP client into the tool registry. +// When allowedTools is non-empty, only tools whose names appear in the list are registered. +// Tools whose names conflict with built-in or already-registered tools are skipped with a warning. +func RegisterAll(reg *tool.Registry, c *Client, allowedTools []string) { + allowed := make(map[string]struct{}, len(allowedTools)) + for _, name := range allowedTools { + allowed[name] = struct{}{} + } + filtering := len(allowed) > 0 + + matched := make(map[string]struct{}) + for _, t := range c.Tools() { + if filtering { + if _, ok := allowed[t.Name]; !ok { + continue + } + matched[t.Name] = struct{}{} + } + if tool.IsReserved(t.Name) { + fmt.Fprintf(os.Stderr, "[ocr] WARNING: MCP server %q tool %q conflicts with built-in tool, skipping\n", c.Name(), t.Name) + continue + } + if _, exists := reg.Get(t.Name); exists { + fmt.Fprintf(os.Stderr, "[ocr] WARNING: MCP server %q tool %q conflicts with already-registered tool, skipping\n", c.Name(), t.Name) + continue + } + reg.Register(&Provider{ + toolName: t.Name, + client: c, + }) + } + + for name := range allowed { + if _, ok := matched[name]; !ok { + fmt.Fprintf(os.Stderr, "[ocr] WARNING: MCP server %q allowed tool %q not found in server's tool list\n", c.Name(), name) + } + } +} + +// ToToolDef converts an MCP tool definition to an llm.ToolDef. +func ToToolDef(t *mcp.Tool) llm.ToolDef { + params := map[string]any{"type": "object"} + + switch schema := t.InputSchema.(type) { + case map[string]any: + for k, v := range schema { + params[k] = v + } + if _, ok := params["type"]; !ok { + params["type"] = "object" + } + case nil: + // No schema — keep the default {"type": "object"}. + default: + fmt.Fprintf(os.Stderr, "[ocr] WARNING: MCP tool %q has unexpected InputSchema type %T, using empty object schema\n", t.Name, t.InputSchema) + } + + return llm.ToolDef{ + Type: "function", + Function: llm.FunctionDef{ + Name: t.Name, + Description: t.Description, + Parameters: params, + }, + } +} + +// CollectToolDefs gathers tool definitions from MCP clients, filtering out +// tools that conflict with built-in tools or were not successfully registered. +func CollectToolDefs(clients []*Client, reg *tool.Registry) []llm.ToolDef { + var defs []llm.ToolDef + seen := make(map[string]struct{}) + for _, c := range clients { + for _, t := range c.Tools() { + if tool.IsReserved(t.Name) { + continue + } + if _, exists := reg.Get(t.Name); !exists { + continue + } + if _, dup := seen[t.Name]; dup { + continue + } + seen[t.Name] = struct{}{} + defs = append(defs, ToToolDef(t)) + } + } + return defs +} diff --git a/internal/mcp/provider_test.go b/internal/mcp/provider_test.go new file mode 100644 index 0000000..1a5cc48 --- /dev/null +++ b/internal/mcp/provider_test.go @@ -0,0 +1,242 @@ +package mcp + +import ( + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/open-code-review/open-code-review/internal/llm" + "github.com/open-code-review/open-code-review/internal/tool" +) + +func newTestClient(name string, tools []*mcp.Tool) *Client { + return &Client{name: name, tools: tools} +} + +func TestProvider_Tool(t *testing.T) { + c := newTestClient("srv", nil) + p := &Provider{toolName: "my_tool", client: c} + got := p.Tool() + if got.Name() != "my_tool" { + t.Errorf("Tool().Name() = %q, want %q", got.Name(), "my_tool") + } +} + +func TestRegisterAll_Basic(t *testing.T) { + tools := []*mcp.Tool{ + {Name: "alpha"}, + {Name: "beta"}, + } + c := newTestClient("srv", tools) + reg := tool.NewRegistry() + + RegisterAll(reg, c, nil) + + for _, name := range []string{"alpha", "beta"} { + if _, ok := reg.Get(name); !ok { + t.Errorf("expected tool %q to be registered", name) + } + } +} + +func TestRegisterAll_AllowedFilter(t *testing.T) { + tools := []*mcp.Tool{ + {Name: "alpha"}, + {Name: "beta"}, + {Name: "gamma"}, + } + c := newTestClient("srv", tools) + reg := tool.NewRegistry() + + RegisterAll(reg, c, []string{"alpha", "gamma"}) + + if _, ok := reg.Get("alpha"); !ok { + t.Error("expected alpha to be registered") + } + if _, ok := reg.Get("beta"); ok { + t.Error("expected beta to be filtered out") + } + if _, ok := reg.Get("gamma"); !ok { + t.Error("expected gamma to be registered") + } +} + +func TestRegisterAll_SkipsReservedTools(t *testing.T) { + tools := []*mcp.Tool{ + {Name: "file_read"}, + {Name: "custom_tool"}, + } + c := newTestClient("srv", tools) + reg := tool.NewRegistry() + + RegisterAll(reg, c, nil) + + if _, ok := reg.Get("file_read"); ok { + t.Error("reserved tool file_read should not be registered") + } + if _, ok := reg.Get("custom_tool"); !ok { + t.Error("expected custom_tool to be registered") + } +} + +func TestRegisterAll_SkipsDuplicateTools(t *testing.T) { + tools := []*mcp.Tool{{Name: "dup_tool"}} + c := newTestClient("srv", tools) + reg := tool.NewRegistry() + + stub := tool.NewStub(tool.Dynamic("dup_tool")) + reg.Register(stub) + + RegisterAll(reg, c, nil) + + got, ok := reg.Get("dup_tool") + if !ok { + t.Fatal("expected dup_tool to be registered") + } + if _, isProvider := got.(*Provider); isProvider { + t.Error("expected dup_tool to remain the original stub, not MCP provider") + } +} + +func TestRegisterAll_WarnsUnmatchedAllowed(t *testing.T) { + tools := []*mcp.Tool{{Name: "alpha"}} + c := newTestClient("srv", tools) + reg := tool.NewRegistry() + + RegisterAll(reg, c, []string{"alpha", "nonexistent"}) + + if _, ok := reg.Get("alpha"); !ok { + t.Error("expected alpha to be registered") + } +} + +func TestToToolDef_MapSchema(t *testing.T) { + mt := &mcp.Tool{ + Name: "search", + Description: "Search things", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "query": map[string]any{"type": "string"}, + }, + }, + } + + got := ToToolDef(mt) + + want := llm.ToolDef{ + Type: "function", + Function: llm.FunctionDef{ + Name: "search", + Description: "Search things", + }, + } + if got.Type != want.Type || got.Function.Name != want.Function.Name || got.Function.Description != want.Function.Description { + t.Errorf("ToToolDef() basic fields mismatch: got %+v", got) + } + if got.Function.Parameters["type"] != "object" { + t.Errorf("expected type=object, got %v", got.Function.Parameters["type"]) + } + if got.Function.Parameters["properties"] == nil { + t.Error("expected properties to be set") + } +} + +func TestToToolDef_NilSchema(t *testing.T) { + mt := &mcp.Tool{ + Name: "noop", + InputSchema: nil, + } + + got := ToToolDef(mt) + + if got.Function.Parameters["type"] != "object" { + t.Errorf("expected default type=object, got %v", got.Function.Parameters["type"]) + } +} + +func TestToToolDef_UnexpectedSchemaType(t *testing.T) { + mt := &mcp.Tool{ + Name: "weird", + InputSchema: "not-a-map", + } + + got := ToToolDef(mt) + + if got.Function.Parameters["type"] != "object" { + t.Errorf("expected fallback type=object, got %v", got.Function.Parameters["type"]) + } +} + +func TestCollectToolDefs_Basic(t *testing.T) { + tools := []*mcp.Tool{ + {Name: "alpha", Description: "A"}, + {Name: "beta", Description: "B"}, + } + c := newTestClient("srv", tools) + reg := tool.NewRegistry() + RegisterAll(reg, c, nil) + + defs := CollectToolDefs([]*Client{c}, reg) + + if len(defs) != 2 { + t.Fatalf("expected 2 defs, got %d", len(defs)) + } + names := map[string]bool{} + for _, d := range defs { + names[d.Function.Name] = true + } + if !names["alpha"] || !names["beta"] { + t.Errorf("expected alpha and beta in defs, got %v", names) + } +} + +func TestCollectToolDefs_FiltersReserved(t *testing.T) { + tools := []*mcp.Tool{ + {Name: "file_read"}, + {Name: "custom"}, + } + c := newTestClient("srv", tools) + reg := tool.NewRegistry() + RegisterAll(reg, c, nil) + + defs := CollectToolDefs([]*Client{c}, reg) + + for _, d := range defs { + if d.Function.Name == "file_read" { + t.Error("reserved tool file_read should not appear in defs") + } + } +} + +func TestCollectToolDefs_FiltersUnregistered(t *testing.T) { + tools := []*mcp.Tool{ + {Name: "registered_tool"}, + {Name: "unregistered_tool"}, + } + c := newTestClient("srv", tools) + reg := tool.NewRegistry() + reg.Register(&Provider{toolName: "registered_tool", client: c}) + + defs := CollectToolDefs([]*Client{c}, reg) + + if len(defs) != 1 { + t.Fatalf("expected 1 def, got %d", len(defs)) + } + if defs[0].Function.Name != "registered_tool" { + t.Errorf("expected registered_tool, got %s", defs[0].Function.Name) + } +} + +func TestCollectToolDefs_Dedup(t *testing.T) { + tools := []*mcp.Tool{{Name: "shared", Description: "A"}} + c1 := newTestClient("srv1", tools) + c2 := newTestClient("srv2", tools) + reg := tool.NewRegistry() + reg.Register(&Provider{toolName: "shared", client: c1}) + + defs := CollectToolDefs([]*Client{c1, c2}, reg) + + if len(defs) != 1 { + t.Errorf("expected 1 deduped def, got %d", len(defs)) + } +} diff --git a/internal/mcp/stdio_test.go b/internal/mcp/stdio_test.go new file mode 100644 index 0000000..ea9f3d0 --- /dev/null +++ b/internal/mcp/stdio_test.go @@ -0,0 +1,118 @@ +package mcp + +import ( + "context" + "encoding/json" + "log" + "os" + "runtime" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +const runAsServerEnv = "_OCR_MCP_TEST_SERVER" + +func TestMain(m *testing.M) { + if os.Getenv(runAsServerEnv) != "" { + runTestMCPServer() + return + } + os.Exit(m.Run()) +} + +func runTestMCPServer() { + server := mcp.NewServer( + &mcp.Implementation{Name: "test-server", Version: "v0.0.1"}, + nil, + ) + server.AddTool( + &mcp.Tool{ + Name: "echo", + Description: "Echoes input", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "message": map[string]any{"type": "string"}, + }, + }, + }, + func(_ context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + var args struct { + Message string `json:"message"` + } + if err := json.Unmarshal(req.Params.Arguments, &args); err != nil { + return nil, err + } + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: "echo: " + args.Message}}, + }, nil + }, + ) + + if err := server.Run(context.Background(), &mcp.StdioTransport{}); err != nil { + log.Fatal(err) + } +} + +func TestNewClient_Stdio(t *testing.T) { + requireExec(t) + + exe, err := os.Executable() + if err != nil { + t.Fatal(err) + } + + ctx := context.Background() + c, err := NewClient(ctx, "test-srv", exe, nil, []string{runAsServerEnv + "=1"}, "", "v0.0.1") + if err != nil { + t.Fatalf("NewClient: %v", err) + } + defer c.Close() + + if c.Name() != "test-srv" { + t.Errorf("Name() = %q, want %q", c.Name(), "test-srv") + } + + found := false + for _, tool := range c.Tools() { + if tool.Name == "echo" { + found = true + break + } + } + if !found { + t.Error("expected echo tool in Tools()") + } + + result, err := c.CallTool(ctx, "echo", map[string]any{"message": "hello"}) + if err != nil { + t.Fatalf("CallTool: %v", err) + } + if result != "echo: hello" { + t.Errorf("CallTool result = %q, want %q", result, "echo: hello") + } + + if err := c.Close(); err != nil { + t.Errorf("Close: %v", err) + } +} + +func TestNewClient_Stdio_BadCommand(t *testing.T) { + requireExec(t) + + ctx := context.Background() + _, err := NewClient(ctx, "bad", "/nonexistent/mcp-server-binary", nil, nil, "", "v0.0.1") + if err == nil { + t.Fatal("expected error for bad command, got nil") + } +} + +func requireExec(t *testing.T) { + t.Helper() + switch runtime.GOOS { + case "darwin", "linux", "windows": + default: + t.Skip("unsupported OS for subprocess test") + } +} diff --git a/internal/tool/definitions.go b/internal/tool/definitions.go index cf24f99..6cbab77 100644 --- a/internal/tool/definitions.go +++ b/internal/tool/definitions.go @@ -3,6 +3,7 @@ package tool import ( "context" "errors" + "fmt" ) // Tool represents a single review tool. @@ -33,6 +34,27 @@ func allTools() []Tool { return []Tool{Unknown, TaskDone, CodeComment, FileRead, FileFind, FileReadDiff, CodeSearch} } +// IsReserved reports whether name matches any built-in tool name (including Unknown). +func IsReserved(name string) bool { + for _, t := range allTools() { + if t.name == name { + return true + } + } + return false +} + +// Dynamic creates a Tool with the given name for dynamically discovered tools (e.g. MCP). +func Dynamic(name string) Tool { + if name == "" { + panic("tool: Dynamic called with empty name") + } + if IsReserved(name) { + panic(fmt.Sprintf("tool: Dynamic called with reserved tool name %q", name)) + } + return Tool{name: name} +} + // Name returns the tool's identifier name. func (t Tool) Name() string { return t.name } diff --git a/internal/tool/definitions_test.go b/internal/tool/definitions_test.go index a1f1af3..ae3cf96 100644 --- a/internal/tool/definitions_test.go +++ b/internal/tool/definitions_test.go @@ -86,6 +86,47 @@ func TestRegistry_GetAfterFreeze(t *testing.T) { } } +func TestIsReserved(t *testing.T) { + reserved := []string{"unknown", "task_done", "code_comment", "file_read", "file_find", "file_read_diff", "code_search"} + for _, name := range reserved { + if !IsReserved(name) { + t.Errorf("IsReserved(%q) = false, want true", name) + } + } + + nonReserved := []string{"custom_tool", "my_tool", "search", ""} + for _, name := range nonReserved { + if IsReserved(name) { + t.Errorf("IsReserved(%q) = true, want false", name) + } + } +} + +func TestDynamic(t *testing.T) { + tool := Dynamic("my_custom_tool") + if tool.Name() != "my_custom_tool" { + t.Errorf("Dynamic().Name() = %q, want %q", tool.Name(), "my_custom_tool") + } +} + +func TestDynamic_PanicsOnEmpty(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Error("expected panic for empty name") + } + }() + Dynamic("") +} + +func TestDynamic_PanicsOnReserved(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Error("expected panic for reserved name") + } + }() + Dynamic("file_read") +} + type dummyProvider struct{} func (d *dummyProvider) Tool() Tool { return CodeSearch } From 0dac8ac3766e71b189470ed9eea0bee12e3aedcf Mon Sep 17 00:00:00 2001 From: Lei Zhang Date: Wed, 1 Jul 2026 19:38:10 +0800 Subject: [PATCH 39/87] fix(ci): prevent duplicate review posts on retry in ocr-review workflow (#250) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ci): add idempotency check to prevent duplicate review posts on retry When the batch createReview fails with a 5xx/408/network error, the request may still have landed on the server. Before retrying per-comment, the workflow now: - Tags each review/comment/summary with a per-run HTML comment ID derived from runId + runAttempt + content hash. - Queries existing reviews and review comments to detect whether the batch actually landed, and only retries the comments that are missing. - Before retrying an individual comment whose request may have reached GitHub, cools down (honoring rate-limit headers) then checks whether the comment already exists, treating it as success instead of posting a duplicate. - Skips posting the summary comment when one with the same run tag already exists. - Adds read-API retry/pacing helpers (withRetry/readWithPacing/readAllPages) with shorter spacing than writes (OCR_READ_SUCCESS_DELAY / OCR_READ_LOW_REMAINING_SPACING) since reads are cheaper but still consume the primary rate limit. Degrades gracefully to the original fallback (accepting duplicate risk) when the idempotency read calls themselves fail. * fix(ci): harden idempotency checks in ocr-review workflow Address code review findings on the GitHub Actions PR auto-review workflow (applied to both .github/workflows and examples copies): - readAllPages: cap pagination at maxPages=50 (default) to prevent unbounded loops, and validate the argument is a positive integer. - getPostedCommentIds: anchor the ID regex to the HTML comment wrapper () with a capture group to avoid false positives from user-generated content. - isCommentAlreadyPosted: return null (unknown) instead of false when the read API fails, so callers do not silently risk duplicates; accept a postedIdsCache to reuse a single paginated walk across retries. - hasIssueCommentWithId: return null (unknown) on read API failure, and match the summary tag with an anchored regex for consistency. - Call sites: handle null by skipping retry/posting to avoid duplicates while surfacing the failure in the summary. * fix(ci): validate env config and document intentional behaviors Address code review findings on the ocr-review workflow (applied to both .github/workflows and examples copies): - parseNonNegInt: add a validation helper for env-var parsing so negative or non-numeric values (e.g. OCR_MAX_RETRIES=-5) fall back to defaults instead of bypassing the `|| default` guard (a negative parseInt result is truthy). All seven retry/pacing config values now use it. - readAllPages: document that the 50-page cap is an intentional safety valve against unbounded loops, not a normal mode; callers that depend on completeness already degrade safely to null (unknown), so a truncated walk does not silently produce duplicates. - commentId: document that the 12-hex-char (48-bit) hash collision scope is a single PR (listReviewComments is PR-scoped) and a single run produces only tens to hundreds of comments, making the birthday-bound collision probability negligible (~1e-7 at 10k). * docs(github_actions): sync README with retry/idempotency features in ocr-review.yml - Add OCR_READ_SUCCESS_DELAY and OCR_READ_LOW_REMAINING_SPACING variables for read API pacing used by the idempotency check - Document the three GitHub rate-limit retry strategies (primary reset, retry-after header, secondary no-header backoff) - Add 'Idempotency: avoiding duplicate review comments' section describing how the workflow detects already-landed comments via per-run HTML tags and skips retrying when the read API is unavailable * fix(ci): use full sha256 hash for review comment idempotency IDs Drop the .slice(0, 12) truncation in commentId() and use the full 64-char (256-bit) sha256 hex digest. The truncated 12-char hash carried a tiny but nonzero collision risk whose failure mode was a silently dropped inline comment (the idempotency check would mistake two distinct comments for duplicates). The full hash makes the collision probability effectively zero with no meaningful downside; the ID regex already used [a-f0-9]+ so it accepts the longer IDs unchanged. * fix(ci): use random per-comment IDs and defer body assembly in review workflow Replace the content-derived commentId() (sha256 of path/line/content) with a random per-comment ID (crypto.randomBytes) and restructure the inline- comment flow around an item struct that carries { comment, id, lines }. This fixes two issues in the idempotency check: 1. ID was recomputed on every failure check. Each inline comment is now assigned one random ID up front and carried on the item struct, so the retry/idempotency logic reads item.id directly. The comment body (which embeds the ID) is assembled only at API-call time in toReviewPayload(), eliminating repeated hash computation. 2. Content-derived IDs collided for distinct comments sharing the same path/line/content. A random ID guarantees two such comments get different IDs, so the idempotency check no longer mistakes the second for a duplicate of the first and silently drops it on retry. formatComment/commentId are removed (no callers remain) and replaced with newCommentId/resolveLines/toReviewPayload/buildBody. The matching regex already used [a-f0-9]+ so it accepts the new random tokens unchanged. README ID-format placeholder updated from to . * docs(ci): correct misleading readAllPages truncation comment The comment claimed 'a truncated walk does not silently produce duplicates' because callers 'degrade safely by returning null on read failures.' That reasoning only holds when the read API THROWS (rate limit, 5xx): isCommentAlreadyPosted/hasIssueCommentWithId then return null (unknown) and the caller skips retrying. A truncated walk does not throw — it returns a partial set silently, so isCommentAlreadyPosted returns false (definitively 'not posted') for comments beyond the cap, and the retry loop reposts them, producing duplicates. Rewrite the comment to state the cap is an intentional safety valve and to explicitly distinguish truncation (partial data, can duplicate) from thrown read failures (null/unknown, safe). No behavior change. * fix(ci): drop stale postedIdsCache to prevent duplicate inline comments isCommentAlreadyPosted reused a single listReviewComments snapshot (postedIdsCache) across all per-comment retries. As comments landed during the loop, the snapshot went stale; a 5xx-landed comment checked against the stale snapshot would be reported as 'not posted' and retried, posting a duplicate. Remove the cache and walk fresh on every check. The extra reads are paced via readAllPages/readWithPacing (with retry honoring retry-after and x-ratelimit-reset) and degrade to null — skip retry — if the read API ultimately fails, so they cannot produce duplicates. The cache provided no real benefit in this path: checked comments are either genuine misses (correctly false) or just-landed (a fresh walk catches them), so hits essentially never occurred. --- .github/workflows/ocr-review.yml | 480 ++++++++++++++++++++++--- examples/github_actions/README.md | 18 +- examples/github_actions/ocr-review.yml | 480 ++++++++++++++++++++++--- 3 files changed, 869 insertions(+), 109 deletions(-) diff --git a/.github/workflows/ocr-review.yml b/.github/workflows/ocr-review.yml index e6bee99..23daa9d 100644 --- a/.github/workflows/ocr-review.yml +++ b/.github/workflows/ocr-review.yml @@ -37,6 +37,18 @@ # (default: 3; GitHub best practice is to watch the header and slow down). # OCR_LOW_REMAINING_SPACING - Request spacing (ms) used when remaining quota is low # (default: 10000 = 10s). +# OCR_READ_SUCCESS_DELAY - Delay (ms) after a successful read API call (listReviews / +# listReviewComments / listIssueComments) used for the +# idempotency check. Reads are cheaper than writes, so the +# default is shorter (default: 500). +# OCR_READ_LOW_REMAINING_SPACING - Request spacing (ms) for read calls when remaining +# quota is low (default: 5000 = 5s). +# +# Idempotency: +# When the batch createReview fails with a 5xx, the request may still have landed on +# the server. Before retrying per-comment, the workflow queries existing reviews and +# review comments (tagged with a per-run HTML comment) and only retries the comments +# that are actually missing. This prevents duplicate review posts. # # Note: GITHUB_TOKEN is automatically provided by GitHub Actions. # Note: The workflow also configures llm.extra_body to '{"thinking": {"type": "disabled"}}' @@ -116,8 +128,22 @@ jobs: github-token: ${{ secrets.GITHUB_TOKEN }} script: | const fs = require('fs'); + const crypto = require('crypto'); const path = '/tmp/ocr-result.json'; + // Unique tag for this workflow run + attempt. Embedded in review/comment + // bodies as an HTML comment so the idempotency check can detect whether + // a batch createReview actually landed on the server before retrying. + // context.runId / context.runAttempt are numbers from @actions/github's + // Context (parsed from GITHUB_RUN_ID / GITHUB_RUN_ATTEMPT). Use + // Number.isFinite to guard against NaN when the env vars are missing, + // falling back to safe defaults. + const runId = Number.isFinite(context.runId) ? context.runId : 0; + const runAttempt = Number.isFinite(context.runAttempt) ? context.runAttempt : 1; + const RUN_TAG = `${runId}-${runAttempt}`; + const REVIEW_TAG = ``; + const SUMMARY_TAG = ``; + // Read OCR output let result; try { @@ -163,35 +189,23 @@ jobs: 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 }); + commentsWithoutLine.push({ comment }); 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 }); + // Each inline comment becomes an item carrying a random ID + // (assigned once) and its resolved line targeting. The body is + // built from item.id only at API-call time (see toReviewPayload), + // so retry/idempotency logic reads item.id directly instead of + // recomputing it, and distinct comments never share an ID. + reviewComments.push({ + comment, + id: newCommentId(), + lines: resolveLines(comment) + }); } // Submit as a single PR review with all comments @@ -203,11 +217,33 @@ jobs: // Add comments without line info to summary body summaryBody += formatSummaryComments(commentsWithoutLine); + // Prepend the run tag so the idempotency check can detect whether the + // batch review actually landed on the server before retrying. + summaryBody = REVIEW_TAG + '\n' + summaryBody; + // Statistics tracking let successCount = 0; let failedCount = 0; const failedComments = []; + // Retry/pacing configuration (shared by write and read API calls). + // parseNonNegInt guards against nonsensical env values (negative, + // NaN, non-numeric) that `parseInt(...) || default` would let + // through for negative numbers, since a negative parseInt result + // is truthy and would bypass the `|| default` fallback. + function parseNonNegInt(val, defaultVal) { + const n = parseInt(val, 10); + return Number.isFinite(n) && n >= 0 ? n : defaultVal; + } + const MAX_RETRIES = parseNonNegInt(process.env.OCR_MAX_RETRIES, 3); + const SUCCESS_DELAY = parseNonNegInt(process.env.OCR_SUCCESS_DELAY, 2000); // delay after successful write + const FAILURE_DELAY = parseNonNegInt(process.env.OCR_FAILURE_DELAY, 1000); // delay after non-retryable failure + const LOW_REMAINING_THRESHOLD = parseNonNegInt(process.env.OCR_LOW_REMAINING_THRESHOLD, 3); + const LOW_REMAINING_SPACING = parseNonNegInt(process.env.OCR_LOW_REMAINING_SPACING, 10000); + // Read APIs are cheaper and have higher thresholds; use shorter pacing. + const READ_SUCCESS_DELAY = parseNonNegInt(process.env.OCR_READ_SUCCESS_DELAY, 500); + const READ_LOW_REMAINING_SPACING = parseNonNegInt(process.env.OCR_READ_LOW_REMAINING_SPACING, 5000); + try { const batchRes = await github.rest.pulls.createReview({ owner: context.repo.owner, @@ -216,24 +252,51 @@ jobs: commit_id: commitSha, body: summaryBody, event: 'COMMENT', - comments: reviewComments.map(({ reviewComment }) => reviewComment) + comments: reviewComments.map(toReviewPayload) }); successCount = reviewComments.length; console.log(`Successfully posted review with ${successCount} inline comments (${commentsWithoutLine.length} in summary)`); logRateLimitQuota(batchRes, 'after batch createReview'); } catch (e) { console.log('Failed to post review with inline comments:', e.message); - console.log('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; + console.log('Checking whether the batch review actually landed on the server before retrying...'); + + // Idempotency check: the batch createReview may have succeeded on the + // server even though we got a 5xx. Query existing reviews to find out, + // so we only retry the comments that are actually missing. + let existingReview = null; + try { + existingReview = await findExistingBatchReview({ + owner: context.repo.owner, + repo: context.repo.repo, + prNumber, + tag: REVIEW_TAG + }); + } catch (checkErr) { + console.log(`Idempotency check failed (${checkErr.message}). ` + + `Degrading to original fallback (accepting duplicate risk).`); + } + + // Compute the list of inline comments that still need to be posted. + // If the batch review landed, only retry the missing ones; otherwise + // retry all of them. + let toRetry = reviewComments; + if (existingReview && existingReview.found) { + const postedIds = await getPostedCommentIds({ + owner: context.repo.owner, + repo: context.repo.repo, + prNumber + }); + toRetry = reviewComments.filter((item) => + !postedIds.has(item.id) + ); + successCount = reviewComments.length - toRetry.length; + console.log(`Batch review already exists (review_id=${existingReview.review.id}). ` + + `${successCount}/${reviewComments.length} inline comments already posted. ` + + `${toRetry.length} missing, will retry only those.`); + } else { + console.log('Batch review not found on server. Falling back to per-comment posting...'); + } // If the batch itself was rate-limited, honor its rate-limit headers // (retry-after / x-ratelimit-reset) before retrying per-comment, @@ -248,7 +311,8 @@ jobs: await sleep(batchRetry.delayMs); } - for (const { comment, reviewComment } of reviewComments) { + for (const item of toRetry) { + const { comment, id } = item; let posted = false; for (let attempt = 0; attempt <= MAX_RETRIES && !posted; attempt++) { try { @@ -259,14 +323,14 @@ jobs: commit_id: commitSha, body: '', event: 'COMMENT', - comments: [reviewComment] + comments: [toReviewPayload(item)] }); successCount++; posted = true; - console.log(`Successfully posted comment for ${reviewComment.path}`); + console.log(`Successfully posted comment for ${comment.path}`); // Proactive throttle: if remaining quota is low, slow down to // avoid hitting the limit (GitHub best practice: watch the header). - const remaining = logRateLimitQuota(res, `after ${reviewComment.path}`); + const remaining = logRateLimitQuota(res, `after ${comment.path}`); const lowQuota = remaining != null && remaining <= LOW_REMAINING_THRESHOLD; if (lowQuota) { console.log(`[rate-limit] quota low (remaining=${remaining} <= ${LOW_REMAINING_THRESHOLD}); increasing spacing to ${LOW_REMAINING_SPACING}ms.`); @@ -279,23 +343,94 @@ jobs: // rate-limit documentation (retry-after / x-ratelimit-* headers). const retryInfo = computeRetryDelayMs(innerE, attempt); const willRetry = retryInfo != null && attempt < MAX_RETRIES; - if (willRetry) { + // Any error whose request may have reached GitHub (5xx server + // errors, 408 timeout, or network-layer errors with no status) + // can mean the comment was actually created but the response was + // lost. Before retrying (which would post a duplicate) or before + // giving up (which would wrongly list it as failed in the summary), + // we must check whether it already landed. + // + // IMPORTANT: do the check AFTER cooling down, not immediately. + // If the error is rate-limit-related (5xx under load, or a + // network blip), firing read requests right away further + // pressures the already-struggling API. Honor the computed + // retry delay first, then query. + const status = innerE.status; + const maybeReachedServer = + (typeof status === 'number' && (status >= 500 || status === 408)) || + status == null; // network errors (ECONNRESET, ETIMEDOUT, ...) + if (maybeReachedServer) { + // Cool down first: even read requests count against rate + // limits, and querying during an ongoing 5xx/rate-limit + // episode can worsen the situation. Use the retry delay when + // available; for non-retryable errors (retryInfo == null) + // there is no header-derived wait, so use a short fixed cool + // down before the read. + const coolDownMs = retryInfo != null ? retryInfo.delayMs : FAILURE_DELAY; + if (coolDownMs > 0) { + const secs = (coolDownMs / 1000).toFixed(1); + console.log( + `Cooling down ${secs}s before idempotency check for ${comment.path} ` + + `(HTTP ${innerE.status || 'n/a'}, attempt ${attempt + 1}/${MAX_RETRIES + 1}).` + ); + await sleep(coolDownMs); + } + const alreadyPosted = await isCommentAlreadyPosted({ + owner: context.repo.owner, + repo: context.repo.repo, + prNumber, + id + }); + if (alreadyPosted === true) { + successCount++; + posted = true; + console.log(`Comment for ${comment.path} already posted (id=${id}); treating as success.`); + await sleep(SUCCESS_DELAY); + continue; + } + // Unknown (null): the read API is unavailable, so we + // cannot tell whether the comment landed. To avoid a + // duplicate, do NOT retry posting; record as failed so + // the summary surfaces the uncertainty rather than + // silently risking a duplicate. + if (alreadyPosted === null) { + failedCount++; + const reason = 'idempotency check unavailable (read API failed)'; + failedComments.push({ comment, error: `${innerE.message} [${reason}]` }); + console.log(`Cannot verify whether comment for ${comment.path} was posted (${reason}, HTTP ${innerE.status || 'n/a'}); skipping retry to avoid duplicate.`); + await sleep(SUCCESS_DELAY); + break; + } + // Not found on server. If retries are exhausted or the + // error is non-retryable, this is a real failure. + if (!willRetry) { + failedCount++; + failedComments.push({ comment, error: innerE.message }); + const reason = retryInfo == null ? 'non-retryable error' : 'rate-limit retries exhausted'; + console.log(`Failed to post comment for ${comment.path} (${reason}, HTTP ${innerE.status || 'n/a'}): ${innerE.message}`); + await sleep(SUCCESS_DELAY); + break; + } + // willRetry: cool down already consumed above, loop back. + } else if (willRetry) { + // Pure 429/403 rate-limit: the request never reached the + // server, so no duplicate is possible and the idempotency + // check can be skipped. Just honor the retry delay. const secs = (retryInfo.delayMs / 1000).toFixed(1); console.log( - `Rate-limited/transient error on ${reviewComment.path} ` + + `Rate-limited on ${comment.path} ` + `(HTTP ${innerE.status}, attempt ${attempt + 1}/${MAX_RETRIES}). ` + `Waiting ${secs}s via '${retryInfo.source}' (${retryInfo.detail}). ` + `Error: ${innerE.message}` ); await sleep(retryInfo.delayMs); } else { + // Non-retryable error that definitely did not reach the + // server (e.g. 4xx validation error): record as failed. failedCount++; failedComments.push({ comment, error: innerE.message }); - 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); + console.log(`Failed to post comment for ${comment.path} (non-retryable error, HTTP ${innerE.status || 'n/a'}): ${innerE.message}`); + await sleep(FAILURE_DELAY); break; } } @@ -319,19 +454,216 @@ jobs: finalBody += formatCommentMarkdown(comment, error); } } - - await github.rest.issues.createComment({ + + // Prepend the summary tag and post only if no summary with this tag + // already exists (idempotency: the batch review may have carried the + // same summary body, in which case we must not duplicate it). + finalBody = SUMMARY_TAG + '\n' + finalBody; + const summaryAlreadyPosted = await hasIssueCommentWithId({ owner: context.repo.owner, repo: context.repo.repo, - issue_number: prNumber, - body: finalBody + issueNumber: prNumber, + id: SUMMARY_TAG }); + if (summaryAlreadyPosted === true) { + console.log('Summary comment with this run tag already exists; skipping.'); + } else if (summaryAlreadyPosted === null) { + // Read API unavailable: cannot tell whether the summary already + // landed. Skip posting to avoid a duplicate; the review content + // is still available via inline comments / batch review. + console.log('Cannot verify whether summary comment already exists (read API failed); skipping to avoid duplicate.'); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: finalBody + }); + } } function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } + // Retry wrapper shared by write and read API calls. Reuses + // computeRetryDelayMs so rate-limit headers (retry-after / + // x-ratelimit-*) are honored uniformly. Throws on final failure + // so the caller can decide how to degrade. + async function withRetry(tag, fn) { + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + try { + return await fn(); + } catch (e) { + const retryInfo = computeRetryDelayMs(e, attempt); + const willRetry = retryInfo != null && attempt < MAX_RETRIES; + if (willRetry) { + const secs = (retryInfo.delayMs / 1000).toFixed(1); + console.log( + `[${tag}] transient/rate-limited (HTTP ${e.status}, attempt ${attempt + 1}/${MAX_RETRIES}). ` + + `Waiting ${secs}s via '${retryInfo.source}' (${retryInfo.detail}). ${e.message}` + ); + await sleep(retryInfo.delayMs); + } else { + console.log(`[${tag}] failed after ${attempt + 1} attempts: ${e.message}`); + throw e; + } + } + } + } + + // Read API wrapper with retry + proactive pacing. Read requests are + // cheaper than writes but still consume the primary rate limit and can + // trigger the secondary limit when issued in a tight loop. Use shorter + // delays than writes (READ_SUCCESS_DELAY / READ_LOW_REMAINING_SPACING). + async function readWithPacing(tag, fn) { + const res = await withRetry(tag, fn); + const remaining = logRateLimitQuota(res, tag); + const lowQuota = remaining != null && remaining <= LOW_REMAINING_THRESHOLD; + if (lowQuota) { + console.log(`[rate-limit] quota low after read (${remaining} <= ${LOW_REMAINING_THRESHOLD}); spacing ${READ_LOW_REMAINING_SPACING}ms.`); + await sleep(READ_LOW_REMAINING_SPACING); + } else { + await sleep(READ_SUCCESS_DELAY); + } + return res; + } + + // Paginated helper that walks all pages of a list endpoint with retry + // and pacing. Returns the concatenated array of items. + async function readAllPages(tag, pageFn, maxPages = 50) { + if (!Number.isFinite(maxPages) || maxPages < 1) { + throw new Error(`readAllPages: maxPages must be a positive integer, got ${maxPages}`); + } + const all = []; + let page = 1; + const PER_PAGE = 100; + while (page <= maxPages) { + const res = await readWithPacing(`${tag} (page ${page})`, () => pageFn(page, PER_PAGE)); + const items = res.data || []; + all.push(...items); + if (items.length < PER_PAGE) break; + page++; + } + // NOTE: Truncation here is intentional and acts as a safety + // valve against unbounded loops (e.g. a bug or malicious + // activity), not as a normal operating mode. A PR accumulating + // >5000 review comments is far outside expected usage; in that + // rare case we log a warning and proceed with partial data + // rather than failing the whole review. + // + // Caveat: this is NOT the same as a read failure. When the read + // API throws (rate limit, 5xx), isCommentAlreadyPosted and + // hasIssueCommentWithId catch it and return null (unknown), so + // the caller skips retrying and creates no duplicate. A + // truncated walk does not throw; it returns a partial set + // silently, so isCommentAlreadyPosted returns false (definitively + // "not posted") for any comment beyond the cap, and the retry + // loop will repost it, producing a duplicate. This tradeoff is + // accepted because the trigger is far outside expected usage; if + // that ceiling ever needs to rise, make maxPages configurable. + if (page > maxPages) { + console.log(`[${tag}] reached max page limit (${maxPages}); results may be incomplete.`); + } + return all; + } + + // Idempotency check: find whether a batch review with this run tag + // already exists on the PR. Returns { found, review } or throws on + // final failure (caller degrades to original fallback). + async function findExistingBatchReview({ owner, repo, prNumber, tag }) { + const reviews = await readAllPages('listReviews', (page, per_page) => + github.rest.pulls.listReviews({ owner, repo, pull_number: prNumber, per_page, page }) + ); + for (const r of reviews) { + if ((r.body || '').includes(tag)) { + return { found: true, review: r }; + } + } + return { found: false }; + } + + // Collect the set of comment-level IDs already posted on the PR + // (across all reviews). Uses listReviewComments (PR-level, cross-review) + // so a single paginated walk covers everything, avoiding the O(missing) + // amplification of per-comment lookups. + async function getPostedCommentIds({ owner, repo, prNumber }) { + const comments = await readAllPages('listReviewComments', (page, per_page) => + github.rest.pulls.listReviewComments({ owner, repo, pull_number: prNumber, per_page, page }) + ); + const ids = new Set(); + // Anchor the regex to the HTML comment wrapper () + // so user-generated content or code suggestions cannot trigger + // false positives in the idempotency check. The ID format is + // `ocr--` where RUN_TAG is `-` + // and is a per-comment random hex token. Capture group 1 + // holds the bare ID (ocr--), so we can add it + // directly without stripping comment markers. + const ID_RE = //g; + for (const c of comments) { + const body = c.body || ''; + let m; + while ((m = ID_RE.exec(body)) !== null) { + ids.add(m[1]); + } + } + return ids; + } + + // Check whether a specific comment-level ID has already landed on the + // server. Used by the per-comment retry loop: when a createReview call + // fails with a transient 5xx/408, the request may have reached GitHub + // and succeeded even though the response was lost. Querying before + // retrying prevents posting a duplicate inline comment. + // Returns true/false when the check succeeds, or null when the + // read API is unavailable (rate limit, 5xx, etc.). Returning null + // (rather than defaulting to false) prevents the caller from + // assuming the comment was not posted and risking a duplicate on + // retry. + // + // Each call walks listReviewComments fresh — no cached snapshot. + // A snapshot reused across retries would go stale as comments land + // during the loop, and a stale miss for a 5xx-landed comment would + // trigger a retry that posts a duplicate. Read calls are paced via + // readAllPages/readWithPacing and degrade to null (skip retry) if the + // read API itself fails, so the extra walks cannot produce duplicates. + async function isCommentAlreadyPosted({ owner, repo, prNumber, id }) { + try { + const posted = await getPostedCommentIds({ owner, repo, prNumber }); + return posted.has(id); + } catch (e) { + console.log(`[isCommentAlreadyPosted] check failed for ${id} (${e.message}); treating as unknown to avoid duplicates.`); + return null; + } + } + + // Check whether an issue comment with the given tag already exists. + // Used to avoid posting a duplicate summary comment when the batch + // review already carried the same summary body. + // Returns true/false when the check succeeds, or null when the + // read API is unavailable. Returning null (rather than defaulting + // to false) lets the caller decide whether to skip posting or + // degrade gracefully, instead of silently risking a duplicate + // summary comment. + async function hasIssueCommentWithId({ owner, repo, issueNumber, id }) { + try { + const comments = await readAllPages('listIssueComments', (page, per_page) => + github.rest.issues.listComments({ owner, repo, issue_number: issueNumber, per_page, page }) + ); + // Match the tag anchored to its HTML comment wrapper for + // consistency with getPostedCommentIds and to defend against + // user content that happens to contain the bare tag string. + // `id` is an opaque tag like ``, + // so escape any regex metacharacters before embedding it. + const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const tagRe = new RegExp(''); + return comments.some(c => tagRe.test(c.body || '')); + } catch (e) { + console.log(`[listIssueComments] check failed (${e.message}); treating as unknown to avoid duplicates.`); + return null; + } + } + // Case-insensitive header lookup. Octokit normalizes response headers to // lowercase, but this defensive check also handles original casing so that // quota logging and retry delay computation never silently miss a header. @@ -437,15 +769,55 @@ jobs: } catch (_) { return null; } } - function formatComment(comment) { - let body = comment.content || ''; + // Random per-comment ID, assigned once when the inline-comment item + // is built and carried on the item struct. Random (rather than + // content-derived) so two distinct comments that share the same + // path/line/content still get different IDs and the idempotency + // check never mistakes one for the other (which would silently drop + // the second). Embedded in the comment body as an HTML comment so + // getPostedCommentIds can match it back on retry. + function newCommentId() { + return `ocr-${RUN_TAG}-${crypto.randomBytes(8).toString('hex')}`; + } - // Add code suggestion if available + // Resolve the line-targeting fields for a createReview comment + // payload (start_line/line/start_side/side) from the comment's line + // range. Returned object is spread into the payload in toReviewPayload. + function resolveLines(comment) { + const start = comment.start_line; + const end = comment.end_line; + if (start >= 1 && end >= 1 && start !== end) { + return { start_line: start, line: end, start_side: 'RIGHT', side: 'RIGHT' }; + } else if (end >= 1) { + return { line: end, side: 'RIGHT' }; + } else if (start >= 1) { + return { line: start, side: 'RIGHT' }; + } + return {}; + } + + // Build the createReview payload for an inline-comment item. The + // body is assembled here (at call time) from the item's precomputed + // ID, so retry/idempotency logic works directly off item.id instead + // of recomputing an ID each time it needs to check posting status. + function toReviewPayload(item) { + return { + path: item.comment.path, + body: buildBody(item.comment, item.id), + ...item.lines + }; + } + + // Assemble the visible comment body: the per-comment ID tag (HTML + // comment, invisible when rendered) prepended for idempotency + // matching, plus the code suggestion block if present. + function buildBody(comment, id) { + let body = `\n`; + body += comment.content || ''; if (comment.suggestion_code && comment.existing_code) { body += '\n\n**Suggestion:**\n'; body += fencedBlock(comment.suggestion_code, 'suggestion'); } - return body; } diff --git a/examples/github_actions/README.md b/examples/github_actions/README.md index dccf654..468c601 100644 --- a/examples/github_actions/README.md +++ b/examples/github_actions/README.md @@ -93,7 +93,13 @@ Use the `--rule` flag to pass a custom rules JSON file: ### 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 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: + +- **Primary rate limit exhausted** (`x-ratelimit-remaining=0`): wait until `x-ratelimit-reset`. +- **Secondary rate limit with a `retry-after` header**: wait exactly that long. +- **Secondary rate limit with no header**: wait at least one minute, then use exponential backoff on continued failures. + +You can configure the retry and delay behavior via **repository variables** (Settings → Secrets and variables → Actions → Variables): | Variable | Default | Description | |----------|---------|-------------| @@ -104,9 +110,19 @@ When posting review comments individually (fallback mode), the workflow includes | `OCR_FAILURE_DELAY` | `1000` | Delay (ms) after a non-rate-limit failure to pace subsequent requests | | `OCR_LOW_REMAINING_THRESHOLD` | `3` | When x-ratelimit-remaining is at or below this value, proactively increase request spacing to avoid hitting the limit | | `OCR_LOW_REMAINING_SPACING` | `10000` | Request spacing (ms) used when remaining quota is low | +| `OCR_READ_SUCCESS_DELAY` | `500` | Delay (ms) after a successful read API call (`listReviews` / `listReviewComments` / `listIssueComments`) used for the idempotency check. Reads are cheaper than writes, so the default is shorter | +| `OCR_READ_LOW_REMAINING_SPACING` | `5000` | Request spacing (ms) for read calls when remaining quota is low | 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. +#### Idempotency: avoiding duplicate review comments + +When the batch `createReview` call fails with a `5xx` error, the request may still have landed on the GitHub server (the response was simply lost). Before retrying per-comment, the workflow queries existing reviews and review comments — each tagged with a per-run HTML comment (e.g. ``) — and only retries the comments that are actually missing. This prevents duplicate review posts. + +The same idempotency check is applied to the summary comment: before posting, the workflow verifies whether a summary with the same run tag already exists, and skips posting if so. + +If the read API itself is unavailable (rate-limited or `5xx`), the check returns *unknown* rather than assuming the comment was not posted. In that case the workflow **skips retrying** to avoid risking a duplicate, and surfaces the uncertainty in the summary instead of silently producing duplicates. + ### Limit concurrency Adjust the `--concurrency` flag for large PRs to control the number of concurrent LLM requests: diff --git a/examples/github_actions/ocr-review.yml b/examples/github_actions/ocr-review.yml index 360eb2e..2073ae1 100644 --- a/examples/github_actions/ocr-review.yml +++ b/examples/github_actions/ocr-review.yml @@ -37,6 +37,18 @@ # (default: 3; GitHub best practice is to watch the header and slow down). # OCR_LOW_REMAINING_SPACING - Request spacing (ms) used when remaining quota is low # (default: 10000 = 10s). +# OCR_READ_SUCCESS_DELAY - Delay (ms) after a successful read API call (listReviews / +# listReviewComments / listIssueComments) used for the +# idempotency check. Reads are cheaper than writes, so the +# default is shorter (default: 500). +# OCR_READ_LOW_REMAINING_SPACING - Request spacing (ms) for read calls when remaining +# quota is low (default: 5000 = 5s). +# +# Idempotency: +# When the batch createReview fails with a 5xx, the request may still have landed on +# the server. Before retrying per-comment, the workflow queries existing reviews and +# review comments (tagged with a per-run HTML comment) and only retries the comments +# that are actually missing. This prevents duplicate review posts. # # Note: GITHUB_TOKEN is automatically provided by GitHub Actions. # Note: The workflow also configures llm.extra_body to '{"thinking": {"type": "disabled"}}' @@ -149,8 +161,22 @@ jobs: github-token: ${{ secrets.GITHUB_TOKEN }} script: | const fs = require('fs'); + const crypto = require('crypto'); const path = '/tmp/ocr-result.json'; + // Unique tag for this workflow run + attempt. Embedded in review/comment + // bodies as an HTML comment so the idempotency check can detect whether + // a batch createReview actually landed on the server before retrying. + // context.runId / context.runAttempt are numbers from @actions/github's + // Context (parsed from GITHUB_RUN_ID / GITHUB_RUN_ATTEMPT). Use + // Number.isFinite to guard against NaN when the env vars are missing, + // falling back to safe defaults. + const runId = Number.isFinite(context.runId) ? context.runId : 0; + const runAttempt = Number.isFinite(context.runAttempt) ? context.runAttempt : 1; + const RUN_TAG = `${runId}-${runAttempt}`; + const REVIEW_TAG = ``; + const SUMMARY_TAG = ``; + // Read OCR output let result; try { @@ -209,35 +235,23 @@ jobs: 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 }); + commentsWithoutLine.push({ comment }); 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 }); + // Each inline comment becomes an item carrying a random ID + // (assigned once) and its resolved line targeting. The body is + // built from item.id only at API-call time (see toReviewPayload), + // so retry/idempotency logic reads item.id directly instead of + // recomputing it, and distinct comments never share an ID. + reviewComments.push({ + comment, + id: newCommentId(), + lines: resolveLines(comment) + }); } // Submit as a single PR review with all comments @@ -249,11 +263,33 @@ jobs: // Add comments without line info to summary body summaryBody += formatSummaryComments(commentsWithoutLine); + // Prepend the run tag so the idempotency check can detect whether the + // batch review actually landed on the server before retrying. + summaryBody = REVIEW_TAG + '\n' + summaryBody; + // Statistics tracking let successCount = 0; let failedCount = 0; const failedComments = []; + // Retry/pacing configuration (shared by write and read API calls). + // parseNonNegInt guards against nonsensical env values (negative, + // NaN, non-numeric) that `parseInt(...) || default` would let + // through for negative numbers, since a negative parseInt result + // is truthy and would bypass the `|| default` fallback. + function parseNonNegInt(val, defaultVal) { + const n = parseInt(val, 10); + return Number.isFinite(n) && n >= 0 ? n : defaultVal; + } + const MAX_RETRIES = parseNonNegInt(process.env.OCR_MAX_RETRIES, 3); + const SUCCESS_DELAY = parseNonNegInt(process.env.OCR_SUCCESS_DELAY, 2000); // delay after successful write + const FAILURE_DELAY = parseNonNegInt(process.env.OCR_FAILURE_DELAY, 1000); // delay after non-retryable failure + const LOW_REMAINING_THRESHOLD = parseNonNegInt(process.env.OCR_LOW_REMAINING_THRESHOLD, 3); + const LOW_REMAINING_SPACING = parseNonNegInt(process.env.OCR_LOW_REMAINING_SPACING, 10000); + // Read APIs are cheaper and have higher thresholds; use shorter pacing. + const READ_SUCCESS_DELAY = parseNonNegInt(process.env.OCR_READ_SUCCESS_DELAY, 500); + const READ_LOW_REMAINING_SPACING = parseNonNegInt(process.env.OCR_READ_LOW_REMAINING_SPACING, 5000); + try { const batchRes = await github.rest.pulls.createReview({ owner: context.repo.owner, @@ -262,24 +298,51 @@ jobs: commit_id: commitSha, body: summaryBody, event: 'COMMENT', - comments: reviewComments.map(({ reviewComment }) => reviewComment) + comments: reviewComments.map(toReviewPayload) }); successCount = reviewComments.length; console.log(`Successfully posted review with ${successCount} inline comments (${commentsWithoutLine.length} in summary)`); logRateLimitQuota(batchRes, 'after batch createReview'); } catch (e) { console.log('Failed to post review with inline comments:', e.message); - console.log('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; + console.log('Checking whether the batch review actually landed on the server before retrying...'); + + // Idempotency check: the batch createReview may have succeeded on the + // server even though we got a 5xx. Query existing reviews to find out, + // so we only retry the comments that are actually missing. + let existingReview = null; + try { + existingReview = await findExistingBatchReview({ + owner: context.repo.owner, + repo: context.repo.repo, + prNumber, + tag: REVIEW_TAG + }); + } catch (checkErr) { + console.log(`Idempotency check failed (${checkErr.message}). ` + + `Degrading to original fallback (accepting duplicate risk).`); + } + + // Compute the list of inline comments that still need to be posted. + // If the batch review landed, only retry the missing ones; otherwise + // retry all of them. + let toRetry = reviewComments; + if (existingReview && existingReview.found) { + const postedIds = await getPostedCommentIds({ + owner: context.repo.owner, + repo: context.repo.repo, + prNumber + }); + toRetry = reviewComments.filter((item) => + !postedIds.has(item.id) + ); + successCount = reviewComments.length - toRetry.length; + console.log(`Batch review already exists (review_id=${existingReview.review.id}). ` + + `${successCount}/${reviewComments.length} inline comments already posted. ` + + `${toRetry.length} missing, will retry only those.`); + } else { + console.log('Batch review not found on server. Falling back to per-comment posting...'); + } // If the batch itself was rate-limited, honor its rate-limit headers // (retry-after / x-ratelimit-reset) before retrying per-comment, @@ -294,7 +357,8 @@ jobs: await sleep(batchRetry.delayMs); } - for (const { comment, reviewComment } of reviewComments) { + for (const item of toRetry) { + const { comment, id } = item; let posted = false; for (let attempt = 0; attempt <= MAX_RETRIES && !posted; attempt++) { try { @@ -305,14 +369,14 @@ jobs: commit_id: commitSha, body: '', event: 'COMMENT', - comments: [reviewComment] + comments: [toReviewPayload(item)] }); successCount++; posted = true; - console.log(`Successfully posted comment for ${reviewComment.path}`); + console.log(`Successfully posted comment for ${comment.path}`); // Proactive throttle: if remaining quota is low, slow down to // avoid hitting the limit (GitHub best practice: watch the header). - const remaining = logRateLimitQuota(res, `after ${reviewComment.path}`); + const remaining = logRateLimitQuota(res, `after ${comment.path}`); const lowQuota = remaining != null && remaining <= LOW_REMAINING_THRESHOLD; if (lowQuota) { console.log(`[rate-limit] quota low (remaining=${remaining} <= ${LOW_REMAINING_THRESHOLD}); increasing spacing to ${LOW_REMAINING_SPACING}ms.`); @@ -325,23 +389,94 @@ jobs: // rate-limit documentation (retry-after / x-ratelimit-* headers). const retryInfo = computeRetryDelayMs(innerE, attempt); const willRetry = retryInfo != null && attempt < MAX_RETRIES; - if (willRetry) { + // Any error whose request may have reached GitHub (5xx server + // errors, 408 timeout, or network-layer errors with no status) + // can mean the comment was actually created but the response was + // lost. Before retrying (which would post a duplicate) or before + // giving up (which would wrongly list it as failed in the summary), + // we must check whether it already landed. + // + // IMPORTANT: do the check AFTER cooling down, not immediately. + // If the error is rate-limit-related (5xx under load, or a + // network blip), firing read requests right away further + // pressures the already-struggling API. Honor the computed + // retry delay first, then query. + const status = innerE.status; + const maybeReachedServer = + (typeof status === 'number' && (status >= 500 || status === 408)) || + status == null; // network errors (ECONNRESET, ETIMEDOUT, ...) + if (maybeReachedServer) { + // Cool down first: even read requests count against rate + // limits, and querying during an ongoing 5xx/rate-limit + // episode can worsen the situation. Use the retry delay when + // available; for non-retryable errors (retryInfo == null) + // there is no header-derived wait, so use a short fixed cool + // down before the read. + const coolDownMs = retryInfo != null ? retryInfo.delayMs : FAILURE_DELAY; + if (coolDownMs > 0) { + const secs = (coolDownMs / 1000).toFixed(1); + console.log( + `Cooling down ${secs}s before idempotency check for ${comment.path} ` + + `(HTTP ${innerE.status || 'n/a'}, attempt ${attempt + 1}/${MAX_RETRIES + 1}).` + ); + await sleep(coolDownMs); + } + const alreadyPosted = await isCommentAlreadyPosted({ + owner: context.repo.owner, + repo: context.repo.repo, + prNumber, + id + }); + if (alreadyPosted === true) { + successCount++; + posted = true; + console.log(`Comment for ${comment.path} already posted (id=${id}); treating as success.`); + await sleep(SUCCESS_DELAY); + continue; + } + // Unknown (null): the read API is unavailable, so we + // cannot tell whether the comment landed. To avoid a + // duplicate, do NOT retry posting; record as failed so + // the summary surfaces the uncertainty rather than + // silently risking a duplicate. + if (alreadyPosted === null) { + failedCount++; + const reason = 'idempotency check unavailable (read API failed)'; + failedComments.push({ comment, error: `${innerE.message} [${reason}]` }); + console.log(`Cannot verify whether comment for ${comment.path} was posted (${reason}, HTTP ${innerE.status || 'n/a'}); skipping retry to avoid duplicate.`); + await sleep(SUCCESS_DELAY); + break; + } + // Not found on server. If retries are exhausted or the + // error is non-retryable, this is a real failure. + if (!willRetry) { + failedCount++; + failedComments.push({ comment, error: innerE.message }); + const reason = retryInfo == null ? 'non-retryable error' : 'rate-limit retries exhausted'; + console.log(`Failed to post comment for ${comment.path} (${reason}, HTTP ${innerE.status || 'n/a'}): ${innerE.message}`); + await sleep(SUCCESS_DELAY); + break; + } + // willRetry: cool down already consumed above, loop back. + } else if (willRetry) { + // Pure 429/403 rate-limit: the request never reached the + // server, so no duplicate is possible and the idempotency + // check can be skipped. Just honor the retry delay. const secs = (retryInfo.delayMs / 1000).toFixed(1); console.log( - `Rate-limited/transient error on ${reviewComment.path} ` + + `Rate-limited on ${comment.path} ` + `(HTTP ${innerE.status}, attempt ${attempt + 1}/${MAX_RETRIES}). ` + `Waiting ${secs}s via '${retryInfo.source}' (${retryInfo.detail}). ` + `Error: ${innerE.message}` ); await sleep(retryInfo.delayMs); } else { + // Non-retryable error that definitely did not reach the + // server (e.g. 4xx validation error): record as failed. failedCount++; failedComments.push({ comment, error: innerE.message }); - 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); + console.log(`Failed to post comment for ${comment.path} (non-retryable error, HTTP ${innerE.status || 'n/a'}): ${innerE.message}`); + await sleep(FAILURE_DELAY); break; } } @@ -365,19 +500,216 @@ jobs: finalBody += formatCommentMarkdown(comment, error); } } - - await github.rest.issues.createComment({ + + // Prepend the summary tag and post only if no summary with this tag + // already exists (idempotency: the batch review may have carried the + // same summary body, in which case we must not duplicate it). + finalBody = SUMMARY_TAG + '\n' + finalBody; + const summaryAlreadyPosted = await hasIssueCommentWithId({ owner: context.repo.owner, repo: context.repo.repo, - issue_number: prNumber, - body: finalBody + issueNumber: prNumber, + id: SUMMARY_TAG }); + if (summaryAlreadyPosted === true) { + console.log('Summary comment with this run tag already exists; skipping.'); + } else if (summaryAlreadyPosted === null) { + // Read API unavailable: cannot tell whether the summary already + // landed. Skip posting to avoid a duplicate; the review content + // is still available via inline comments / batch review. + console.log('Cannot verify whether summary comment already exists (read API failed); skipping to avoid duplicate.'); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: finalBody + }); + } } function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } + // Retry wrapper shared by write and read API calls. Reuses + // computeRetryDelayMs so rate-limit headers (retry-after / + // x-ratelimit-*) are honored uniformly. Throws on final failure + // so the caller can decide how to degrade. + async function withRetry(tag, fn) { + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + try { + return await fn(); + } catch (e) { + const retryInfo = computeRetryDelayMs(e, attempt); + const willRetry = retryInfo != null && attempt < MAX_RETRIES; + if (willRetry) { + const secs = (retryInfo.delayMs / 1000).toFixed(1); + console.log( + `[${tag}] transient/rate-limited (HTTP ${e.status}, attempt ${attempt + 1}/${MAX_RETRIES}). ` + + `Waiting ${secs}s via '${retryInfo.source}' (${retryInfo.detail}). ${e.message}` + ); + await sleep(retryInfo.delayMs); + } else { + console.log(`[${tag}] failed after ${attempt + 1} attempts: ${e.message}`); + throw e; + } + } + } + } + + // Read API wrapper with retry + proactive pacing. Read requests are + // cheaper than writes but still consume the primary rate limit and can + // trigger the secondary limit when issued in a tight loop. Use shorter + // delays than writes (READ_SUCCESS_DELAY / READ_LOW_REMAINING_SPACING). + async function readWithPacing(tag, fn) { + const res = await withRetry(tag, fn); + const remaining = logRateLimitQuota(res, tag); + const lowQuota = remaining != null && remaining <= LOW_REMAINING_THRESHOLD; + if (lowQuota) { + console.log(`[rate-limit] quota low after read (${remaining} <= ${LOW_REMAINING_THRESHOLD}); spacing ${READ_LOW_REMAINING_SPACING}ms.`); + await sleep(READ_LOW_REMAINING_SPACING); + } else { + await sleep(READ_SUCCESS_DELAY); + } + return res; + } + + // Paginated helper that walks all pages of a list endpoint with retry + // and pacing. Returns the concatenated array of items. + async function readAllPages(tag, pageFn, maxPages = 50) { + if (!Number.isFinite(maxPages) || maxPages < 1) { + throw new Error(`readAllPages: maxPages must be a positive integer, got ${maxPages}`); + } + const all = []; + let page = 1; + const PER_PAGE = 100; + while (page <= maxPages) { + const res = await readWithPacing(`${tag} (page ${page})`, () => pageFn(page, PER_PAGE)); + const items = res.data || []; + all.push(...items); + if (items.length < PER_PAGE) break; + page++; + } + // NOTE: Truncation here is intentional and acts as a safety + // valve against unbounded loops (e.g. a bug or malicious + // activity), not as a normal operating mode. A PR accumulating + // >5000 review comments is far outside expected usage; in that + // rare case we log a warning and proceed with partial data + // rather than failing the whole review. + // + // Caveat: this is NOT the same as a read failure. When the read + // API throws (rate limit, 5xx), isCommentAlreadyPosted and + // hasIssueCommentWithId catch it and return null (unknown), so + // the caller skips retrying and creates no duplicate. A + // truncated walk does not throw; it returns a partial set + // silently, so isCommentAlreadyPosted returns false (definitively + // "not posted") for any comment beyond the cap, and the retry + // loop will repost it, producing a duplicate. This tradeoff is + // accepted because the trigger is far outside expected usage; if + // that ceiling ever needs to rise, make maxPages configurable. + if (page > maxPages) { + console.log(`[${tag}] reached max page limit (${maxPages}); results may be incomplete.`); + } + return all; + } + + // Idempotency check: find whether a batch review with this run tag + // already exists on the PR. Returns { found, review } or throws on + // final failure (caller degrades to original fallback). + async function findExistingBatchReview({ owner, repo, prNumber, tag }) { + const reviews = await readAllPages('listReviews', (page, per_page) => + github.rest.pulls.listReviews({ owner, repo, pull_number: prNumber, per_page, page }) + ); + for (const r of reviews) { + if ((r.body || '').includes(tag)) { + return { found: true, review: r }; + } + } + return { found: false }; + } + + // Collect the set of comment-level IDs already posted on the PR + // (across all reviews). Uses listReviewComments (PR-level, cross-review) + // so a single paginated walk covers everything, avoiding the O(missing) + // amplification of per-comment lookups. + async function getPostedCommentIds({ owner, repo, prNumber }) { + const comments = await readAllPages('listReviewComments', (page, per_page) => + github.rest.pulls.listReviewComments({ owner, repo, pull_number: prNumber, per_page, page }) + ); + const ids = new Set(); + // Anchor the regex to the HTML comment wrapper () + // so user-generated content or code suggestions cannot trigger + // false positives in the idempotency check. The ID format is + // `ocr--` where RUN_TAG is `-` + // and is a per-comment random hex token. Capture group 1 + // holds the bare ID (ocr--), so we can add it + // directly without stripping comment markers. + const ID_RE = //g; + for (const c of comments) { + const body = c.body || ''; + let m; + while ((m = ID_RE.exec(body)) !== null) { + ids.add(m[1]); + } + } + return ids; + } + + // Check whether a specific comment-level ID has already landed on the + // server. Used by the per-comment retry loop: when a createReview call + // fails with a transient 5xx/408, the request may have reached GitHub + // and succeeded even though the response was lost. Querying before + // retrying prevents posting a duplicate inline comment. + // Returns true/false when the check succeeds, or null when the + // read API is unavailable (rate limit, 5xx, etc.). Returning null + // (rather than defaulting to false) prevents the caller from + // assuming the comment was not posted and risking a duplicate on + // retry. + // + // Each call walks listReviewComments fresh — no cached snapshot. + // A snapshot reused across retries would go stale as comments land + // during the loop, and a stale miss for a 5xx-landed comment would + // trigger a retry that posts a duplicate. Read calls are paced via + // readAllPages/readWithPacing and degrade to null (skip retry) if the + // read API itself fails, so the extra walks cannot produce duplicates. + async function isCommentAlreadyPosted({ owner, repo, prNumber, id }) { + try { + const posted = await getPostedCommentIds({ owner, repo, prNumber }); + return posted.has(id); + } catch (e) { + console.log(`[isCommentAlreadyPosted] check failed for ${id} (${e.message}); treating as unknown to avoid duplicates.`); + return null; + } + } + + // Check whether an issue comment with the given tag already exists. + // Used to avoid posting a duplicate summary comment when the batch + // review already carried the same summary body. + // Returns true/false when the check succeeds, or null when the + // read API is unavailable. Returning null (rather than defaulting + // to false) lets the caller decide whether to skip posting or + // degrade gracefully, instead of silently risking a duplicate + // summary comment. + async function hasIssueCommentWithId({ owner, repo, issueNumber, id }) { + try { + const comments = await readAllPages('listIssueComments', (page, per_page) => + github.rest.issues.listComments({ owner, repo, issue_number: issueNumber, per_page, page }) + ); + // Match the tag anchored to its HTML comment wrapper for + // consistency with getPostedCommentIds and to defend against + // user content that happens to contain the bare tag string. + // `id` is an opaque tag like ``, + // so escape any regex metacharacters before embedding it. + const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const tagRe = new RegExp(''); + return comments.some(c => tagRe.test(c.body || '')); + } catch (e) { + console.log(`[listIssueComments] check failed (${e.message}); treating as unknown to avoid duplicates.`); + return null; + } + } + // Case-insensitive header lookup. Octokit normalizes response headers to // lowercase, but this defensive check also handles original casing so that // quota logging and retry delay computation never silently miss a header. @@ -483,15 +815,55 @@ jobs: } catch (_) { return null; } } - function formatComment(comment) { - let body = comment.content || ''; + // Random per-comment ID, assigned once when the inline-comment item + // is built and carried on the item struct. Random (rather than + // content-derived) so two distinct comments that share the same + // path/line/content still get different IDs and the idempotency + // check never mistakes one for the other (which would silently drop + // the second). Embedded in the comment body as an HTML comment so + // getPostedCommentIds can match it back on retry. + function newCommentId() { + return `ocr-${RUN_TAG}-${crypto.randomBytes(8).toString('hex')}`; + } - // Add code suggestion if available + // Resolve the line-targeting fields for a createReview comment + // payload (start_line/line/start_side/side) from the comment's line + // range. Returned object is spread into the payload in toReviewPayload. + function resolveLines(comment) { + const start = comment.start_line; + const end = comment.end_line; + if (start >= 1 && end >= 1 && start !== end) { + return { start_line: start, line: end, start_side: 'RIGHT', side: 'RIGHT' }; + } else if (end >= 1) { + return { line: end, side: 'RIGHT' }; + } else if (start >= 1) { + return { line: start, side: 'RIGHT' }; + } + return {}; + } + + // Build the createReview payload for an inline-comment item. The + // body is assembled here (at call time) from the item's precomputed + // ID, so retry/idempotency logic works directly off item.id instead + // of recomputing an ID each time it needs to check posting status. + function toReviewPayload(item) { + return { + path: item.comment.path, + body: buildBody(item.comment, item.id), + ...item.lines + }; + } + + // Assemble the visible comment body: the per-comment ID tag (HTML + // comment, invisible when rendered) prepended for idempotency + // matching, plus the code suggestion block if present. + function buildBody(comment, id) { + let body = `\n`; + body += comment.content || ''; if (comment.suggestion_code && comment.existing_code) { body += '\n\n**Suggestion:**\n'; body += fencedBlock(comment.suggestion_code, 'suggestion'); } - return body; } From 44fabbaa683c1a7aa1de426d90a52e38419f232f Mon Sep 17 00:00:00 2001 From: kite Date: Wed, 1 Jul 2026 19:56:24 +0800 Subject: [PATCH 40/87] docs(pages): update i18n docs for unified Provider system Update documentation strings across en/ja/zh to reflect the new provider-based configuration: add full scan review mode, list built-in providers (Anthropic, OpenAI, DashScope, DeepSeek, Z.AI), and replace legacy config keys with provider-scoped keys including extraHeaders. --- pages/src/i18n/en.ts | 26 +++++++++++++++----------- pages/src/i18n/ja.ts | 26 +++++++++++++++----------- pages/src/i18n/zh.ts | 32 ++++++++++++++++++-------------- 3 files changed, 48 insertions(+), 36 deletions(-) diff --git a/pages/src/i18n/en.ts b/pages/src/i18n/en.ts index 560737c..31fc2e1 100644 --- a/pages/src/i18n/en.ts +++ b/pages/src/i18n/en.ts @@ -110,8 +110,8 @@ export const en: TranslationKeys = { 'docs.overviewTitle': 'Overview', 'docs.overviewDesc': '(short ocr) is an AI-powered code review CLI tool.', 'docs.overviewFeatures': 'Core Features:', - 'docs.overviewFeat1': 'Supports workspace changes, branch diff, and single-commit review modes', - 'docs.overviewFeat2': 'Supports LLM services with Anthropic and OpenAI-compatible protocols', + 'docs.overviewFeat1': 'Supports workspace, branch diff, single-commit, and full scan review modes', + 'docs.overviewFeat2': 'Built-in Anthropic, OpenAI, DashScope, DeepSeek, Z.AI and custom provider support', 'docs.overviewFeat3': 'Concurrent review with customizable timeouts', 'docs.overviewFeat4': 'Output format supports text and json', 'docs.overviewFeat5': 'Zero config for Claude Code users', @@ -120,22 +120,26 @@ export const en: TranslationKeys = { 'docs.installLabel': 'Install', 'docs.installVerifyLabel': 'Verify Installation', 'docs.configTitle': 'Configuration & Verification', - 'docs.configDesc': 'Manage CLI configuration settings, stored in ~/.opencodereview/config.json.', + 'docs.configDesc': 'Manage CLI configuration through a unified Provider system. Ships with built-in providers and supports custom providers for private deployments. Config is stored in ~/.opencodereview/config.json.', 'docs.configInteractive': 'Interactive Setup (Recommended)', - 'docs.configInteractiveDesc': 'The easiest way to configure Open Code Review. Select from built-in providers or add a custom one.', + 'docs.configInteractiveDesc': 'The easiest way to configure Open Code Review. Select from built-in providers or add a custom one, set your API key, and choose a model.', 'docs.configModelSelect': 'Model Selection', 'docs.configModelSelectDesc': 'After choosing a provider, pick the model to use.', 'docs.configListProviders': 'List Built-in Providers', 'docs.configListProvidersDesc': 'View all available built-in LLM providers.', 'docs.configManual': 'Manual Configuration', - 'docs.configCommand': 'Command', - 'docs.configExample': 'Example', + 'docs.configManualBuiltin': 'Using a Built-in Provider', + 'docs.configManualBuiltinDesc': 'Built-in providers come with preset API URLs and protocols — just supply an API key.', + 'docs.configManualCustom': 'Using a Custom Provider', + 'docs.configManualCustomDesc': 'Connect to private deployments or other compatible endpoints.', + 'docs.configManualCustomNote': 'url and protocol are required for custom providers. Supported protocols: anthropic, openai.', 'docs.configKeys': 'Supported Config Keys', - 'docs.configKeyUrl': 'API URL', - 'docs.configKeyToken': 'API Key', - 'docs.configKeyModel': 'Model Name', - 'docs.configKeyAnthropic': 'Use Anthropic SDK', - 'docs.configKeyExtraBody': 'Vendor-specific request body fields (JSON)', + 'docs.configKeyProvider': 'Active provider name', + 'docs.configKeyProviderApiKey': 'API key for the provider', + 'docs.configKeyProviderModel': 'Model name', + 'docs.configKeyProviderUrl': 'API URL override', + 'docs.configKeyProviderExtraBody': 'Custom request body fields (JSON)', + 'docs.configKeyProviderExtraHeaders': 'Custom HTTP headers (key=value)', 'docs.configKeyLanguage': 'Output Language', 'docs.configKeyTelemetry': 'Telemetry', 'docs.configVerify': 'Verify Configuration', diff --git a/pages/src/i18n/ja.ts b/pages/src/i18n/ja.ts index f167ccb..51e96ec 100644 --- a/pages/src/i18n/ja.ts +++ b/pages/src/i18n/ja.ts @@ -112,8 +112,8 @@ export const ja: TranslationKeys = { 'docs.overviewTitle': '概要', 'docs.overviewDesc': '(略称 ocr)は AI 駆動のコードレビュー CLI ツールです。', 'docs.overviewFeatures': 'コア機能:', - 'docs.overviewFeat1': 'ワークスペース変更、ブランチ差分、単一コミットレビューモードをサポート', - 'docs.overviewFeat2': 'Anthropic および OpenAI 互換プロトコルの LLM サービスをサポート', + 'docs.overviewFeat1': 'ワークスペース変更、ブランチ差分、単一コミット、フルスキャンレビューモードをサポート', + 'docs.overviewFeat2': 'Anthropic、OpenAI、DashScope、DeepSeek、Z.AI などの内蔵プロバイダーとカスタムプロバイダーをサポート', 'docs.overviewFeat3': 'カスタマイズ可能なタイムアウト付き並行レビュー', 'docs.overviewFeat4': '出力形式は text と json をサポート', 'docs.overviewFeat5': 'Claude Code ユーザーはゼロ設定', @@ -122,22 +122,26 @@ export const ja: TranslationKeys = { 'docs.installLabel': 'インストール', 'docs.installVerifyLabel': 'インストール確認', 'docs.configTitle': '設定と検証', - 'docs.configDesc': 'CLI 設定を管理します。~/.opencodereview/config.json に保存されます。', + 'docs.configDesc': '統一されたプロバイダーシステムで CLI 設定を管理します。多数の内蔵プロバイダーを搭載し、プライベートデプロイメント向けのカスタムプロバイダーもサポート。設定は ~/.opencodereview/config.json に保存されます。', 'docs.configInteractive': '対話式セットアップ(推奨)', - 'docs.configInteractiveDesc': '最も簡単な設定方法。内蔵プロバイダーから選択するか、カスタムプロバイダーを追加します。', + 'docs.configInteractiveDesc': '最も簡単な設定方法。内蔵プロバイダーから選択するかカスタムプロバイダーを追加し、API キーを設定してモデルを選びます。', 'docs.configModelSelect': 'モデル選択', 'docs.configModelSelectDesc': 'プロバイダーを選択した後、使用するモデルを選びます。', 'docs.configListProviders': '内蔵プロバイダー一覧', 'docs.configListProvidersDesc': '利用可能なすべての内蔵 LLM プロバイダーを表示します。', 'docs.configManual': '手動設定', - 'docs.configCommand': 'コマンド', - 'docs.configExample': '例', + 'docs.configManualBuiltin': '内蔵プロバイダーを使用', + 'docs.configManualBuiltinDesc': '内蔵プロバイダーには API URL とプロトコルがプリセットされています。API キーを提供するだけで始められます。', + 'docs.configManualCustom': 'カスタムプロバイダーを使用', + 'docs.configManualCustomDesc': 'プライベートデプロイメントや他の互換エンドポイントに接続します。', + 'docs.configManualCustomNote': 'カスタムプロバイダーには url と protocol が必須です。サポートされるプロトコル:anthropic、openai。', 'docs.configKeys': 'サポートされる設定キー', - 'docs.configKeyUrl': 'API アドレス', - 'docs.configKeyToken': 'API キー', - 'docs.configKeyModel': 'モデル名', - 'docs.configKeyAnthropic': 'Anthropic SDK を使用', - 'docs.configKeyExtraBody': 'ベンダー固有のリクエストボディフィールド (JSON)', + 'docs.configKeyProvider': 'アクティブなプロバイダー名', + 'docs.configKeyProviderApiKey': 'プロバイダーの API キー', + 'docs.configKeyProviderModel': 'モデル名', + 'docs.configKeyProviderUrl': 'API URL オーバーライド', + 'docs.configKeyProviderExtraBody': 'カスタムリクエストボディフィールド (JSON)', + 'docs.configKeyProviderExtraHeaders': 'カスタム HTTP ヘッダー (key=value)', 'docs.configKeyLanguage': '出力言語', 'docs.configKeyTelemetry': 'テレメトリ', 'docs.configVerify': '設定確認', diff --git a/pages/src/i18n/zh.ts b/pages/src/i18n/zh.ts index c65d657..121c911 100644 --- a/pages/src/i18n/zh.ts +++ b/pages/src/i18n/zh.ts @@ -112,8 +112,8 @@ export const zh: TranslationKeys = { 'docs.overviewTitle': '概览', 'docs.overviewDesc': '(简称 ocr)是一款 AI 驱动的代码审查 CLI 工具。', 'docs.overviewFeatures': '核心功能:', - 'docs.overviewFeat1': '支持工作区变更、分支差异和单次提交审查模式', - 'docs.overviewFeat2': '支持 Anthropic 和 OpenAI 兼容协议的 LLM 服务', + 'docs.overviewFeat1': '支持工作区变更、分支差异、单次提交和全量扫描审查模式', + 'docs.overviewFeat2': '内置 Anthropic、OpenAI、DashScope、DeepSeek、Z.AI 等主流供应商及自定义供应商支持', 'docs.overviewFeat3': '并发审查与可自定义超时', 'docs.overviewFeat4': '输出格式支持 text 和 json', 'docs.overviewFeat5': 'Claude Code 用户零配置', @@ -122,22 +122,26 @@ export const zh: TranslationKeys = { 'docs.installLabel': '安装', 'docs.installVerifyLabel': '验证安装', 'docs.configTitle': '配置与验证', - 'docs.configDesc': '管理 CLI 配置,存储在 ~/.opencodereview/config.json。', + 'docs.configDesc': '通过统一的 Provider 体系管理 CLI 配置。内置多个主流供应商,同时支持自定义供应商用于私有化部署。配置存储在 ~/.opencodereview/config.json。', 'docs.configInteractive': '交互式设置(推荐)', - 'docs.configInteractiveDesc': '最简单的配置方式。从内置提供商中选择或添加自定义提供商。', + 'docs.configInteractiveDesc': '最简单的配置方式。从内置供应商中选择或添加自定义供应商,设置 API 密钥,选择模型。', 'docs.configModelSelect': '模型选择', - 'docs.configModelSelectDesc': '选择提供商后,选择要使用的模型。', - 'docs.configListProviders': '列出内置提供商', - 'docs.configListProvidersDesc': '查看所有可用的内置 LLM 提供商。', + 'docs.configModelSelectDesc': '选择供应商后,选择要使用的模型。', + 'docs.configListProviders': '列出内置供应商', + 'docs.configListProvidersDesc': '查看所有可用的内置 LLM 供应商。', 'docs.configManual': '手动配置', - 'docs.configCommand': '命令', - 'docs.configExample': '示例', + 'docs.configManualBuiltin': '使用内置供应商', + 'docs.configManualBuiltinDesc': '内置供应商已预设 API 地址和协议,只需提供 API 密钥即可开始。', + 'docs.configManualCustom': '使用自定义供应商', + 'docs.configManualCustomDesc': '连接到私有化部署或其他兼容端点。', + 'docs.configManualCustomNote': '自定义供应商必须提供 url 和 protocol。支持的协议:anthropic、openai。', 'docs.configKeys': '支持的配置键', - 'docs.configKeyUrl': 'API 地址', - 'docs.configKeyToken': 'API 密钥', - 'docs.configKeyModel': '模型名称', - 'docs.configKeyAnthropic': '使用 Anthropic SDK', - 'docs.configKeyExtraBody': '提供商特定的请求体字段 (JSON)', + 'docs.configKeyProvider': '当前活跃供应商名称', + 'docs.configKeyProviderApiKey': '供应商的 API 密钥', + 'docs.configKeyProviderModel': '模型名称', + 'docs.configKeyProviderUrl': 'API 地址覆盖', + 'docs.configKeyProviderExtraBody': '自定义请求体字段 (JSON)', + 'docs.configKeyProviderExtraHeaders': '自定义 HTTP 头 (key=value)', 'docs.configKeyLanguage': '输出语言', 'docs.configKeyTelemetry': '遥测', 'docs.configVerify': '验证配置', From fef4314d4654c84b3deb1a00327822afe532cd5f Mon Sep 17 00:00:00 2001 From: Eldar Shlomi <72104254+eldar702@users.noreply.github.com> Date: Wed, 1 Jul 2026 18:23:12 +0300 Subject: [PATCH 41/87] fix(agent): recover from panics in per-file review and comment-pool goroutines (#171) (#182) A panic in a single file's review goroutine (dispatchSubtasks) or in a CommentWorkerPool task previously crashed the whole ocr process. Recover in both: the per-file panic is isolated like an error return (counted in subtaskFailed + recorded as a subtask_error warning with stack trace + telemetry, using the parent ctx since fileCtx is already cancelled on unwind), and a panicking comment-pool task is contained so healthy tasks still complete. Rebased onto current main: the pool moved to internal/llmloop, so the pool-side recover + the panic-isolation test now live in internal/llmloop/pool.go and pool_test.go; the per-file recover stays in internal/agent/agent.go. Also documents CommentWorkerPool.Await's concurrency contract (Submit must not race Await). --- internal/agent/agent.go | 16 ++++++++++++++++ internal/llmloop/pool.go | 19 +++++++++++++++++++ internal/llmloop/pool_test.go | 21 +++++++++++++++++++++ 3 files changed, 56 insertions(+) diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 3bb6796..4be6504 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "runtime/debug" "strings" "sync" "sync/atomic" @@ -347,6 +348,21 @@ func (a *Agent) dispatchSubtasks(ctx context.Context) ([]model.LlmComment, error go func(d model.Diff) { 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) + 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 diff --git a/internal/llmloop/pool.go b/internal/llmloop/pool.go index 3df2ea5..a3d3f6f 100644 --- a/internal/llmloop/pool.go +++ b/internal/llmloop/pool.go @@ -9,6 +9,7 @@ package llmloop import ( "fmt" + "runtime/debug" "sync" "github.com/open-code-review/open-code-review/internal/model" @@ -55,6 +56,14 @@ func (p *CommentWorkerPool) Submit(f func() ([]model.LlmComment, error)) { p.wg.Go(func() { p.semaphore <- struct{}{} defer func() { <-p.semaphore }() + // Contain a panic in the submitted work so one bad unit of work cannot + // crash the whole process. The work that panics contributes no comments; + // the semaphore is still released via the defer above. + defer func() { + if r := recover(); r != nil { + fmt.Fprintf(stdout.Writer(), "[ocr] CommentWorkerPool panic: %v\n%s\n", r, debug.Stack()) + } + }() comments, err := f() if err != nil { @@ -68,6 +77,16 @@ func (p *CommentWorkerPool) Submit(f func() ([]model.LlmComment, error)) { // Await blocks until all submitted work has completed and returns // aggregated results from every Submit call so far. +// +// A panic in submitted work is recovered and logged inside Submit (see the +// recover defer there) but is not surfaced here as an error or reflected in +// the returned count — a unit that panics contributes no comments and is +// indistinguishable from one that produced zero. +// +// Concurrency contract: Await must not run concurrently with Submit. Submit +// calls wg.Go (which does wg.Add(1) synchronously), so a Submit racing Await +// would risk sync.WaitGroup's "Add called concurrently with Wait" panic. +// Callers must ensure every Submit has returned before calling Await. func (p *CommentWorkerPool) Await() []model.LlmComment { p.wg.Wait() return p.results diff --git a/internal/llmloop/pool_test.go b/internal/llmloop/pool_test.go index 1db6bbe..2638095 100644 --- a/internal/llmloop/pool_test.go +++ b/internal/llmloop/pool_test.go @@ -97,3 +97,24 @@ func TestCommentWorkerPool_AwaitEmpty(t *testing.T) { t.Errorf("expected nil for no submissions, got %v", results) } } + +func TestCommentWorkerPool_PanicIsIsolated(t *testing.T) { + p := NewCommentWorkerPool(2) + + p.Submit(func() ([]model.LlmComment, error) { + panic("boom in submitted work") + }) + p.Submit(func() ([]model.LlmComment, error) { + return []model.LlmComment{{Path: "healthy.go", Content: "fine"}}, nil + }) + + // Await must not crash: the recovered panic contributes no comments, and the + // healthy task's result is still collected. + results := p.Await() + if len(results) != 1 { + t.Fatalf("expected 1 result after a panicking task, got %d", len(results)) + } + if results[0].Path != "healthy.go" { + t.Errorf("Path = %q, want healthy.go", results[0].Path) + } +} From 64e008fcb4a29ebda8df475a5874e3868cedae2f Mon Sep 17 00:00:00 2001 From: Lei Zhang Date: Thu, 2 Jul 2026 10:33:37 +0800 Subject: [PATCH 42/87] ci: upgrade node version to 24 (#240) --- .github/workflows/deploy-pages.yml | 4 ++-- .github/workflows/ocr-review.yml | 2 +- .github/workflows/release.yml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index e89f248..4eb0d6b 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -20,7 +20,7 @@ jobs: build: runs-on: self-hosted container: - image: node:20 + image: node:24 steps: - uses: actions/checkout@v4 @@ -51,7 +51,7 @@ jobs: url: ${{ steps.deployment.outputs.page_url }} runs-on: self-hosted container: - image: node:20 + image: node:24 needs: build steps: - name: Deploy to GitHub Pages diff --git a/.github/workflows/ocr-review.yml b/.github/workflows/ocr-review.yml index 23daa9d..9abcdf8 100644 --- a/.github/workflows/ocr-review.yml +++ b/.github/workflows/ocr-review.yml @@ -75,7 +75,7 @@ jobs: code-review: runs-on: self-hosted container: - image: node:20 + image: node:24 if: github.event_name == 'pull_request_target' steps: - name: Checkout repository diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e11ee7f..640c7a8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -160,7 +160,7 @@ jobs: needs: release runs-on: self-hosted container: - image: node:20 + image: node:24 permissions: contents: read steps: From 7497d5ac6e92ad888b530e435a9662e7bca8719c Mon Sep 17 00:00:00 2001 From: Fedor Date: Thu, 2 Jul 2026 05:41:39 +0300 Subject: [PATCH 43/87] docs(examples): add GitFlic CI auto-review example (#201) * docs(examples): add GitFlic CI auto-review example Add examples/gitflic_ci/, a CI-layer integration that reviews GitFlic merge requests with OpenCodeReview and posts the findings as MR discussions. Like the GitHub and GitLab examples, the posting glue lives outside the core binary. post_review.py (standard library only) reads `ocr review --format json` and posts inline discussions plus a fallback note and a summary. GitFlic's Discussions API requires an old-side line for inline comments, which the new-side-only review output lacks, so the script recomputes it from the same merge-base diff the review ran on. Ships with a stdlib unittest suite whose line-mapping cases are ported from the review's diff logic. * docs(readme): list the GitFlic CI example in the localized READMEs * fix(examples): address GitFlic CI review feedback from PR #201 Apply the five review comments left on the PR: - gitflic-ci.yaml: guard `ocr config set llm.model` behind a non-empty check so the documented-optional OCR_LLM_MODEL no longer breaks the config step when it is unset - gitflic-ci.yaml: skip posting when `ocr review` produced no output (the step ends with `|| true`) instead of feeding empty/partial JSON to post_review.py - post_review.py: redact the token from HTTP error snippets so it cannot leak into CI logs if GitFlic echoes the request back in an error body - post_review.py: read the review-result file via a `with` block so the handle is closed explicitly - examples/README.md: add the missing trailing newline --- README.ja-JP.md | 1 + README.ko-KR.md | 1 + README.md | 1 + README.ru-RU.md | 1 + README.zh-CN.md | 1 + examples/README.md | 3 +- examples/gitflic_ci/README.md | 80 ++++ examples/gitflic_ci/gitflic-ci.yaml | 84 +++++ examples/gitflic_ci/post_review.py | 480 ++++++++++++++++++++++++ examples/gitflic_ci/post_review_test.py | 240 ++++++++++++ 10 files changed, 891 insertions(+), 1 deletion(-) create mode 100644 examples/gitflic_ci/README.md create mode 100644 examples/gitflic_ci/gitflic-ci.yaml create mode 100644 examples/gitflic_ci/post_review.py create mode 100644 examples/gitflic_ci/post_review_test.py diff --git a/README.ja-JP.md b/README.ja-JP.md index 2e9d8c3..b37648c 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -404,6 +404,7 @@ ocr review \ - [`github_actions/`](./examples/github_actions/) — GitHub Actions統合の例 - [`gitlab_ci/`](./examples/gitlab_ci/) — GitLab CI統合の例 +- [`gitflic_ci/`](./examples/gitflic_ci/) — GitFlic CI統合の例 ## コマンド diff --git a/README.ko-KR.md b/README.ko-KR.md index 06302f1..54e507f 100644 --- a/README.ko-KR.md +++ b/README.ko-KR.md @@ -404,6 +404,7 @@ ocr review \ - [`github_actions/`](./examples/github_actions/): GitHub Actions 통합 예시 - [`gitlab_ci/`](./examples/gitlab_ci/): GitLab CI 통합 예시 +- [`gitflic_ci/`](./examples/gitflic_ci/): GitFlic CI 통합 예시 ## Commands diff --git a/README.md b/README.md index 8910699..1e82856 100644 --- a/README.md +++ b/README.md @@ -406,6 +406,7 @@ 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 ## Commands diff --git a/README.ru-RU.md b/README.ru-RU.md index 7034883..d1fbad8 100644 --- a/README.ru-RU.md +++ b/README.ru-RU.md @@ -406,6 +406,7 @@ ocr review \ - [`github_actions/`](./examples/github_actions/) — пример интеграции с GitHub Actions - [`gitlab_ci/`](./examples/gitlab_ci/) — пример интеграции с GitLab CI +- [`gitflic_ci/`](./examples/gitflic_ci/) — пример интеграции с GitFlic CI ## Команды diff --git a/README.zh-CN.md b/README.zh-CN.md index 83524ea..d84bd7f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -404,6 +404,7 @@ ocr review \ - [`github_actions/`](./examples/github_actions/) — GitHub Actions 集成示例 - [`gitlab_ci/`](./examples/gitlab_ci/) — GitLab CI 集成示例 +- [`gitflic_ci/`](./examples/gitflic_ci/) — GitFlic CI 集成示例 ## 命令 diff --git a/examples/README.md b/examples/README.md index 8a4caea..2ffc3ff 100644 --- a/examples/README.md +++ b/examples/README.md @@ -6,5 +6,6 @@ This directory contains examples for integrating OpenCodeReview (OCR) into vario - **[github_actions/](./github_actions/)** - GitHub Actions integration example - **[gitlab_ci/](./gitlab_ci/)** - GitLab CI integration example +- **[gitflic_ci/](./gitflic_ci/)** - GitFlic CI integration example -Each subdirectory contains its own README with detailed setup instructions. \ No newline at end of file +Each subdirectory contains its own README with detailed setup instructions. diff --git a/examples/gitflic_ci/README.md b/examples/gitflic_ci/README.md new file mode 100644 index 0000000..9f36ce7 --- /dev/null +++ b/examples/gitflic_ci/README.md @@ -0,0 +1,80 @@ +# OpenCodeReview - GitFlic CI Demo + +This demo shows how to integrate OpenCodeReview into a [GitFlic](https://gitflic.ru) CI/CD pipeline to automatically review Merge Requests and post the findings as MR discussions — inline on the changed lines where possible. + +Like the GitHub Actions and GitLab CI examples, the posting glue lives in the CI layer rather than in the `ocr` binary. Here it is a small, dependency-free Python script — [`post_review.py`](post_review.py) — that reads `ocr review --format json` and posts to the GitFlic Discussions API. The only GitFlic-specific wrinkle it handles is the **old-side line**: GitFlic requires it even for a comment on the new side of the diff, and `ocr review` reports new-side positions only, so the script recomputes it from the same merge-base diff the review ran on. + +## How It Works + +``` +MR Created/Updated → Merge Request Pipeline → ocr review → post_review.py → Discussions on MR +``` + +1. A Merge Request Pipeline triggers the `code-review` job +2. It installs OCR via npm in a `node:20` image (which also ships `python3` and `git`) +3. Runs `ocr review --from origin/ --to $CI_COMMIT_SHA --format json --audience agent` +4. Runs `python3 post_review.py`, which reads the JSON and posts: + - **Inline discussions** on the changed lines (`POST .../discussions/create` with `newLine`/`oldLine`/`newPath`/`oldPath`) + - **A fallback note** collecting comments that could not be placed inline + - **A summary note** with the totals + +The MR context (owner, project, MR id, branch refs) is picked up automatically from the predefined GitFlic CI variables (`CI_PROJECT_NAMESPACE`, `CI_PROJECT_NAME`, `CI_MERGE_REQUEST_LOCAL_ID`, `CI_MERGE_REQUEST_TARGET_BRANCH_NAME`, `CI_COMMIT_SHA`), so `post_review.py` needs no arguments in CI. Outside CI every value can be passed via flags — run `python3 post_review.py -h`. + +## Setup + +### 1. Enable Merge Request Pipelines + +Go to **Project Settings → CI/CD Settings** and enable **Merge Request Pipeline**. New merge requests will then trigger the pipeline automatically. + +### 2. Copy the pipeline files + +Copy **both** `gitflic-ci.yaml` (GitFlic expects this exact file name at the repository root) and `post_review.py` into your repository. If you keep `post_review.py` somewhere other than the repo root, adjust the `python3 post_review.py` path in `gitflic-ci.yaml` accordingly. + +### 3. Configure CI/CD Variables + +Go to **Settings → CI/CD → Variables** and add: + +| Variable | Required | Description | +|----------|----------|-------------| +| `OCR_LLM_URL` | Yes | LLM API endpoint URL | +| `OCR_LLM_AUTH_TOKEN` | Yes | LLM API authentication token | +| `GITFLIC_TOKEN` | Yes | GitFlic access token used to post discussions | +| `OCR_LLM_MODEL` | No | Model name (e.g., `gpt-4o`) | +| `GITFLIC_API_URL` | No | REST API base URL for self-hosted GitFlic (default: `https://api.gitflic.ru`) | + +> **Note:** GitFlic CI/CD does not accept variables with values shorter than 8 characters, so `use_anthropic` cannot be set as a CI variable. The pipeline sets it to `false`; to use Anthropic Claude models, edit `gitflic-ci.yaml` directly. + +### 4. Create a GitFlic Access Token + +Create a token in **User Settings → Access Tokens** (or a dedicated service account — its name becomes the bot name shown in discussions) and store it in the `GITFLIC_TOKEN` variable. The token owner must have access to the project sufficient for commenting on merge requests. + +## Notes & Limitations + +- **Inline positioning** — GitFlic requires all four of `newLine`/`oldLine`/`newPath`/`oldPath` for a code comment; if any is missing it silently creates a general comment. `post_review.py` computes the old-side position from the same merge-base diff the review ran on (`git diff merge-base(from, to)..to`), and anchors added lines to the closest preceding old line. +- **Rate limit** — the GitFlic cloud API allows 500 requests/hour per token. One review posts `comments + 2` requests at most, which fits comfortably. +- **Self-hosted GitFlic** — set `GITFLIC_API_URL` to your instance's REST API base URL. +- **Re-reviews** — every push to the MR triggers a new pipeline and a new review. To skip already-reviewed MRs, check existing discussions for the `OpenCodeReview` marker before running the review step. + +## Tests + +`post_review.py` ships with [`post_review_test.py`](post_review_test.py) — standard-library `unittest`, no network or git required: + +```bash +cd examples/gitflic_ci +python3 post_review_test.py +``` + +The line-mapping cases are ported from the upstream Go tests so the script keeps proven parity with the binary. + +## Debugging + +Test the posting step locally without touching the MR: + +```bash +ocr review --from origin/main --to HEAD --format json > /tmp/r.json +python3 post_review.py /tmp/r.json \ + --owner --project --mr \ + --from origin/main --to HEAD --dry-run +``` + +`--dry-run` prints every discussion (with the computed positions) instead of posting, and does not require `GITFLIC_TOKEN`. diff --git a/examples/gitflic_ci/gitflic-ci.yaml b/examples/gitflic_ci/gitflic-ci.yaml new file mode 100644 index 0000000..2a2b9bd --- /dev/null +++ b/examples/gitflic_ci/gitflic-ci.yaml @@ -0,0 +1,84 @@ +# OpenCodeReview - GitFlic CI Merge Request Auto-Review Demo +# +# Reviews Merge Requests with OpenCodeReview and posts the findings onto the +# MR as discussions (inline where possible). The posting glue lives in the CI +# layer, in post_review.py next to this file -- consistent with the GitHub and +# GitLab examples, which keep platform-specific publishing out of the `ocr` +# binary. +# +# Setup: +# - Commit BOTH this file and post_review.py into your repository (adjust the +# `python3 post_review.py` path below if you place the script elsewhere). +# - Enable "Merge Request Pipeline" in Project Settings -> CI/CD Settings. +# - Use a runner able to run the node:20 image (it ships node, python3 and git), +# or any shell runner with node 20+, python3 and git available. +# +# Required CI/CD Variables (Settings -> CI/CD -> Variables): +# OCR_LLM_URL - LLM API endpoint (e.g., https://api.openai.com/v1/chat/completions) +# OCR_LLM_AUTH_TOKEN - Authentication token for the LLM API +# GITFLIC_TOKEN - GitFlic access token used to post MR discussions +# +# Optional CI/CD Variables: +# OCR_LLM_MODEL - Model name (e.g., gpt-4o) +# GITFLIC_API_URL - GitFlic REST API base URL; only needed for self-hosted +# instances (defaults to https://api.gitflic.ru) +# +# post_review.py picks up the MR context automatically from the predefined +# GitFlic CI variables: CI_PROJECT_NAMESPACE, CI_PROJECT_NAME, +# CI_MERGE_REQUEST_LOCAL_ID, CI_MERGE_REQUEST_TARGET_BRANCH_NAME, CI_COMMIT_SHA. + +stages: + - review + +code-review: + stage: review + image: node:20 + script: + # Run only in merge request pipelines + - | + if [ -z "$CI_MERGE_REQUEST_LOCAL_ID" ]; then + echo "Not a merge request pipeline, skipping review." + exit 0 + fi + + # Install OpenCodeReview + - npm install -g @alibaba-group/open-code-review + + # Configure OCR + - | + ocr config set llm.url $OCR_LLM_URL + ocr config set llm.auth_token $OCR_LLM_AUTH_TOKEN + if [ -n "$OCR_LLM_MODEL" ]; then + ocr config set llm.model "$OCR_LLM_MODEL" + fi + ocr config set llm.use_anthropic false + ocr config set llm.extra_body '{"thinking": {"type": "disabled"}}' + + # Make sure the target branch and full history are available for merge-base diff + - git fetch --unshallow 2>/dev/null || true + - git fetch origin "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME" + + # Run OCR review (CI_COMMIT_SHA as head supports forked MRs as well) + - | + ocr review \ + --from "origin/${CI_MERGE_REQUEST_TARGET_BRANCH_NAME}" \ + --to "${CI_COMMIT_SHA}" \ + --format json \ + --audience agent \ + > /tmp/ocr-result.json || true + echo "OCR review completed." + + # Post review comments onto the MR (inline discussions + summary note). + # post_review.py recomputes the old-side line for each comment from the + # merge-base diff, which the GitFlic Discussions API requires for inline + # (code) comments. + # + # The review step above ends with `|| true`, so a failed `ocr review` (bad + # token, network error) leaves an empty or partial file. Skip posting in + # that case instead of feeding invalid JSON to post_review.py. + - | + if [ ! -s /tmp/ocr-result.json ]; then + echo "OCR review produced no output, skipping post." + exit 0 + fi + python3 post_review.py /tmp/ocr-result.json diff --git a/examples/gitflic_ci/post_review.py b/examples/gitflic_ci/post_review.py new file mode 100644 index 0000000..e8b0555 --- /dev/null +++ b/examples/gitflic_ci/post_review.py @@ -0,0 +1,480 @@ +#!/usr/bin/env python3 +"""Post an OpenCodeReview result onto a GitFlic merge request. + +This is the CI-layer "glue" for GitFlic, mirroring examples/gitlab_ci: it keeps +platform-specific publishing out of the `ocr` binary and lives entirely in the +pipeline. It reads the JSON emitted by `ocr review --format json` and posts it +onto the merge request as discussions: + + - one inline discussion per comment that maps onto the diff, + - a single fallback note collecting the comments that do not, + - a final summary note. + +GitFlic's Discussions API needs an *old-side* line even for a comment on the new +side of the diff: an inline (code) discussion requires all four of +newLine/oldLine/newPath/oldPath, otherwise GitFlic silently records a plain +comment. `ocr review` only reports new-side positions, so this script computes +the old-side line itself by parsing the same merge-base diff the review ran on +(`git diff merge-base(from, to)..to`). + +Standard library only (json, urllib, subprocess) so it runs on the stock +node:20 / python image used by the pipeline. +""" + +import argparse +import json +import os +import re +import subprocess +import sys +import urllib.error +import urllib.request +from urllib.parse import quote + +# GitFlic SaaS REST API endpoint; override with --api-url / $GITFLIC_API_URL for +# self-hosted instances (e.g. http://gitflic.example/rest-api). +DEFAULT_API_URL = "https://api.gitflic.ru" + +# Context lines around each hunk; must match what `ocr review` diffs with so the +# new-side line numbers in the comments align with the hunks parsed here. +DIFF_CONTEXT_LINES = 3 + + +def log(msg): + print(msg, file=sys.stderr) + + +# --------------------------------------------------------------------------- # +# Diff parsing +# --------------------------------------------------------------------------- # + +HUNK_CONTEXT, HUNK_ADDED, HUNK_DELETED = range(3) + +_HUNK_HEADER_RE = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@") +_DIFF_HEADER_RE = re.compile(r"^diff --git a/(.+?) b/(.+)$") + + +class Hunk: + """One @@ ... @@ block of a unified diff.""" + + __slots__ = ("old_start", "old_count", "new_start", "new_count", "lines") + + def __init__(self, old_start, old_count, new_start, new_count): + self.old_start = old_start + self.old_count = old_count + self.new_start = new_start + self.new_count = new_count + self.lines = [] # list of (type, content) + + +class FileDiff: + """A single file's section of a unified diff.""" + + __slots__ = ("old_path", "new_path", "is_new", "is_deleted", "is_binary", "text") + + def __init__(self, old_path="", new_path=""): + self.old_path = old_path + self.new_path = new_path + self.is_new = False + self.is_deleted = False + self.is_binary = False + self.text = "" # raw diff body, fed to parse_hunks() on demand + + +def parse_hunks(raw): + """Parse one file's unified diff text into a list of Hunks. + + Lines before the first @@ header (diff --git, ---, +++) are ignored. + """ + hunks = [] + current = None + for line in raw.split("\n"): + m = _HUNK_HEADER_RE.match(line) + if m: + if current is not None: + hunks.append(current) + old_start = int(m.group(1)) + old_count = int(m.group(2)) if m.group(2) else 1 + new_start = int(m.group(3)) + new_count = int(m.group(4)) if m.group(4) else 1 + current = Hunk(old_start, old_count, new_start, new_count) + continue + if current is None: + continue + if line.startswith("\\ No newline at end of file"): + continue + if line.startswith("diff --git "): + break + if line.startswith("+"): + current.lines.append((HUNK_ADDED, line[1:])) + elif line.startswith("-"): + current.lines.append((HUNK_DELETED, line[1:])) + else: + content = line[1:] if line[:1] == " " else line + current.lines.append((HUNK_CONTEXT, content)) + if current is not None: + hunks.append(current) + return hunks + + +def parse_diff(diff_text): + """Split combined unified diff text into per-file FileDiff sections.""" + files = [] + current = None + buf = [] + + def flush(): + if current is not None: + current.text = "\n".join(buf) + files.append(current) + + for line in diff_text.split("\n"): + m = _DIFF_HEADER_RE.match(line) + if m: + flush() + buf = [] + current = FileDiff(old_path=m.group(1), new_path=m.group(2)) + if current is None: + continue + if line.startswith("Binary files ") or line.startswith("GIT binary patch"): + current.is_binary = True + elif line.startswith("new file mode"): + current.is_new = True + elif line.startswith("deleted file mode"): + current.is_deleted = True + elif line.startswith("--- "): + path = line[4:] + if path == "/dev/null": + current.is_new = True + current.old_path = "/dev/null" + elif path.startswith("a/"): + current.old_path = path[2:] + elif line.startswith("+++ "): + path = line[4:] + if path == "/dev/null": + current.is_deleted = True + current.new_path = "/dev/null" + elif path.startswith("b/"): + current.new_path = path[2:] + buf.append(line) + + flush() + return files + + +# --------------------------------------------------------------------------- # +# Line mapping (new file side -> old file side) +# --------------------------------------------------------------------------- # + + +def clamp_line(n): + return 1 if n < 1 else n + + +def old_line_for(hunks, new_line): + """Map a new-side line number to the corresponding old-side line. + + Lines added by the diff have no old counterpart, so they are anchored to the + closest preceding old line -- GitFlic only needs a plausible old-side + position to render the code comment next to the insertion point. The result + is always >= 1. + """ + delta = 0 # cumulative (new - old) line-count shift from preceding hunks + for h in hunks: + if new_line < h.new_start: + break + if new_line < h.new_start + h.new_count: + return _old_line_in_hunk(h, new_line) + delta += h.new_count - h.old_count + return clamp_line(new_line - delta) + + +def _old_line_in_hunk(h, new_line): + """Walk a hunk's lines tracking both counters until reaching new_line.""" + old_ln, new_ln = h.old_start, h.new_start + last_old = h.old_start - 1 # last old line seen before the current position + for line_type, _content in h.lines: + if line_type == HUNK_CONTEXT: + if new_ln == new_line: + return clamp_line(old_ln) + last_old = old_ln + old_ln += 1 + new_ln += 1 + elif line_type == HUNK_DELETED: + last_old = old_ln + old_ln += 1 + elif line_type == HUNK_ADDED: + if new_ln == new_line: + return clamp_line(last_old) + new_ln += 1 + return clamp_line(last_old) + + +# --------------------------------------------------------------------------- # +# Comment formatting +# --------------------------------------------------------------------------- # + + +def format_comment(c): + """Render an inline discussion body.""" + body = c.get("content", "") + suggestion = c.get("suggestion_code", "") + existing = c.get("existing_code", "") + if suggestion and existing: + body += "\n\n**Suggestion:**\n```\n" + suggestion + "\n```" + return body + + +def format_comment_fallback(c): + """Render a comment for the fallback (non-inline) note.""" + md = "### 📄 `%s`" % c.get("path", "") + start_line = c.get("start_line", 0) + end_line = c.get("end_line", 0) + if start_line and end_line: + md += " (L%d-L%d)" % (start_line, end_line) + md += "\n\n" + c.get("content", "") + suggestion = c.get("suggestion_code", "") + existing = c.get("existing_code", "") + if suggestion and existing: + md += "\n\n**Before:**\n```\n" + existing + "\n```\n\n**After:**\n```\n" + suggestion + "\n```" + return md + + +# --------------------------------------------------------------------------- # +# Publishing (transport-agnostic; `post` does the actual API call) +# --------------------------------------------------------------------------- # + + +def publish(result, diffs_by_path, post): + """Post the review result via the `post(discussion)` callable. + + `post` receives a discussion dict and must raise on failure. A general + comment carries only "message"; an inline comment also carries + newLine/oldLine/newPath/oldPath. Returns {"inline": int, "fallback": int}. + """ + comments = result.get("comments") or [] + if not comments: + message = result.get("message") or "No comments generated. Looks good to me." + post({"message": "✅ **OpenCodeReview**: " + message}) + return {"inline": 0, "fallback": 0} + + inline = 0 + failed = [] + hunks_cache = {} + + for c in comments: + path = c.get("path", "") + end_line = c.get("end_line", 0) or 0 + fd = diffs_by_path.get(path) + if fd is None: + log("no diff for %s; folding comment into the summary note" % path) + failed.append(c) + continue + if fd.is_binary or fd.is_deleted or end_line <= 0: + failed.append(c) + continue + + old_path = fd.old_path + old_line = 1 + if fd.is_new or old_path == "" or old_path == "/dev/null": + # GitFlic has no old side for a new file; anchor to the new path. + old_path = fd.new_path + else: + hunks = hunks_cache.get(path) + if hunks is None: + hunks = parse_hunks(fd.text) + hunks_cache[path] = hunks + old_line = old_line_for(hunks, end_line) + + discussion = { + "message": format_comment(c), + "newLine": end_line, + "oldLine": old_line, + "newPath": path, + "oldPath": old_path, + } + try: + post(discussion) + except Exception as e: # noqa: BLE001 - any transport error falls back + log("inline comment failed for %s:%d: %s" % (path, end_line, e)) + failed.append(c) + continue + inline += 1 + + if failed: + note = "🔍 **OpenCodeReview** found issues that could not be posted inline:\n\n---\n\n" + for c in failed: + note += format_comment_fallback(c) + "\n\n---\n\n" + post({"message": note}) + + summary = "🔍 **OpenCodeReview** found **%d** issue(s) in this MR." % len(comments) + summary += "\n- ✅ %d posted as inline comment(s)" % inline + summary += "\n- 📝 %d posted as summary (could not be placed inline)" % len(failed) + warnings = result.get("warnings") or [] + if warnings: + summary += "\n\n⚠️ %d warning(s) occurred during review." % len(warnings) + post({"message": summary}) + + return {"inline": inline, "fallback": len(failed)} + + +# --------------------------------------------------------------------------- # +# GitFlic REST transport +# --------------------------------------------------------------------------- # + + +def make_poster(api_url, token, owner, project, mr): + """Return a post(discussion) that POSTs to the GitFlic Discussions API.""" + endpoint = "%s/project/%s/%s/merge-request/%s/discussions/create" % ( + api_url.rstrip("/"), + quote(owner, safe=""), + quote(project, safe=""), + quote(mr, safe=""), + ) + + def post(discussion): + body = json.dumps(discussion).encode("utf-8") + req = urllib.request.Request(endpoint, data=body, method="POST") + req.add_header("Authorization", "token " + token) + req.add_header("Content-Type", "application/json") + try: + with urllib.request.urlopen(req) as resp: + resp.read() + except urllib.error.HTTPError as e: + snippet = e.read(512).decode("utf-8", "replace").strip() + # Some APIs echo request details back in error bodies; never let the + # token reach the CI log if GitFlic does that. + if token: + snippet = snippet.replace(token, "***") + raise RuntimeError("gitflic API %s %s: %s" % (e.code, e.reason, snippet)) + + return post + + +def make_dry_run_poster(): + """Return a post(discussion) that prints instead of calling the API.""" + + def post(discussion): + if discussion.get("newPath") and "newLine" in discussion and "oldLine" in discussion: + position = "%s:%d (old %s:%d)" % ( + discussion["newPath"], + discussion["newLine"], + discussion.get("oldPath", ""), + discussion["oldLine"], + ) + else: + position = "general" + print("--- dry-run discussion [%s] ---\n%s\n" % (position, discussion["message"])) + + return post + + +# --------------------------------------------------------------------------- # +# git / IO +# --------------------------------------------------------------------------- # + + +def _git(repo, *args): + return subprocess.run( + ["git", *args], cwd=repo, check=True, capture_output=True, text=True + ).stdout + + +def load_diffs_by_path(repo, from_ref, to_ref): + """Build {new_path: FileDiff} for the merge-base diff `ocr review` ran on.""" + base = _git(repo, "merge-base", from_ref, to_ref).strip() + out = _git( + repo, "diff", "--no-ext-diff", "--no-textconv", + "--src-prefix=a/", "--dst-prefix=b/", "--no-color", + "-U%d" % DIFF_CONTEXT_LINES, base, to_ref, "--", + ) + return {fd.new_path: fd for fd in parse_diff(out)} + + +def load_review_result(path): + """Read the JSON produced by `ocr review --format json` (path '-' = stdin).""" + if path == "-": + data = sys.stdin.read() + else: + with open(path, encoding="utf-8") as f: + data = f.read() + return json.loads(data) + + +# --------------------------------------------------------------------------- # +# CLI +# --------------------------------------------------------------------------- # + + +def parse_args(argv): + target = os.environ.get("CI_MERGE_REQUEST_TARGET_BRANCH_NAME", "") + default_from = "origin/" + target if target else "" + + p = argparse.ArgumentParser( + description="Post `ocr review --format json` output onto a GitFlic merge request." + ) + p.add_argument("file", nargs="?", default="-", + help="review result JSON ('-' = stdin, default)") + p.add_argument("--owner", default=os.environ.get("CI_PROJECT_NAMESPACE", ""), + help="project owner alias (default: $CI_PROJECT_NAMESPACE)") + p.add_argument("--project", default=os.environ.get("CI_PROJECT_NAME", ""), + help="project alias (default: $CI_PROJECT_NAME)") + p.add_argument("--mr", default=os.environ.get("CI_MERGE_REQUEST_LOCAL_ID", ""), + help="merge request local id (default: $CI_MERGE_REQUEST_LOCAL_ID)") + p.add_argument("--api-url", default=os.environ.get("GITFLIC_API_URL", "") or DEFAULT_API_URL, + help="GitFlic REST API base URL (default: $GITFLIC_API_URL or %s)" % DEFAULT_API_URL) + p.add_argument("--from", dest="from_ref", default=default_from, + help="base ref of the reviewed range (default: origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME)") + p.add_argument("--to", dest="to_ref", default=os.environ.get("CI_COMMIT_SHA", ""), + help="head ref of the reviewed range (default: $CI_COMMIT_SHA)") + p.add_argument("--repo", default=".", help="git repository root (default: .)") + p.add_argument("--dry-run", action="store_true", + help="print discussions instead of posting them") + return p.parse_args(argv) + + +def main(argv=None): + args = parse_args(sys.argv[1:] if argv is None else argv) + + missing = [name for name, value in ( + ("--owner", args.owner), ("--project", args.project), ("--mr", args.mr), + ("--from", args.from_ref), ("--to", args.to_ref), + ) if not value] + if missing: + log("error: %s required (set via flag or CI environment)" % ", ".join(missing)) + return 2 + + token = os.environ.get("GITFLIC_TOKEN", "") + if not token and not args.dry_run: + log("error: GITFLIC_TOKEN environment variable is required") + return 2 + + try: + result = load_review_result(args.file) + except (OSError, ValueError) as e: + log("error: cannot read review result %s: %s" % (args.file, e)) + return 1 + + try: + diffs_by_path = load_diffs_by_path(args.repo, args.from_ref, args.to_ref) + except (subprocess.CalledProcessError, OSError) as e: + # Without the diff, inline positions cannot be computed; comments still + # go out via the fallback note. + log("warning: cannot read diff %s..%s, posting all comments as fallback: %s" + % (args.from_ref, args.to_ref, e)) + diffs_by_path = {} + + if args.dry_run: + post = make_dry_run_poster() + else: + post = make_poster(args.api_url, token, args.owner, args.project, args.mr) + + stats = publish(result, diffs_by_path, post) + total = len(result.get("comments") or []) + print("Posted %d inline comment(s), %d via fallback note (%d total)." + % (stats["inline"], stats["fallback"], total)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/gitflic_ci/post_review_test.py b/examples/gitflic_ci/post_review_test.py new file mode 100644 index 0000000..be43828 --- /dev/null +++ b/examples/gitflic_ci/post_review_test.py @@ -0,0 +1,240 @@ +#!/usr/bin/env python3 +"""Tests for post_review.py. + +Standard-library unittest only (no pytest, no network, no git): run with + + python3 post_review_test.py # from examples/gitflic_ci/ + python3 -m unittest discover examples/gitflic_ci + +The line-mapping cases are ported 1:1 from the upstream Go test +internal/publish/gitflic/linemap_test.go and publisher_test.go, so the script +keeps proven parity with the binary it replaces. +""" + +import unittest + +import post_review as pr + + +# old file lines 1..10; line 3 modified, a line inserted after old line 5, +# old line 8 deleted. (from linemap_test.go) +SAMPLE_DIFF = """diff --git a/main.go b/main.go +--- a/main.go ++++ b/main.go +@@ -1,10 +1,10 @@ + line1 + line2 +-line3 old ++line3 new + line4 + line5 ++inserted after5 + line6 + line7 +-line8 + line9 + line10 +""" + +NEW_FILE_DIFF = """diff --git a/added.go b/added.go +new file mode 100644 +--- /dev/null ++++ b/added.go +@@ -0,0 +1,3 @@ ++package main ++ ++func main() {} +""" + +DELETED_FILE_DIFF = """diff --git a/gone.go b/gone.go +deleted file mode 100644 +--- a/gone.go ++++ /dev/null +@@ -1,2 +0,0 @@ +-package main +- +""" + +BINARY_DIFF = """diff --git a/logo.png b/logo.png +index 1111111..2222222 100644 +Binary files a/logo.png and b/logo.png differ +""" + + +class OldLineForTest(unittest.TestCase): + def setUp(self): + self.hunks = pr.parse_hunks(SAMPLE_DIFF) + + def test_single_hunk_positions(self): + self.assertEqual(len(self.hunks), 1) + cases = [ + ("context before changes", 1, 1), + ("modified line maps to deleted position anchor", 3, 3), + ("context after modification", 4, 4), + ("added line anchors to preceding old line", 6, 5), + ("context shifted by insertion", 7, 6), + ("context after deletion", 9, 9), + ("last context line", 10, 10), + ] + for name, new_line, want in cases: + with self.subTest(name): + self.assertEqual(pr.old_line_for(self.hunks, new_line), want) + + def test_outside_hunks(self): + # one line added and one deleted -> cumulative delta 0 + self.assertEqual(pr.old_line_for(self.hunks, 42), 42) + + def test_multiple_hunks(self): + multi = ( + "@@ -1,2 +1,4 @@\n" + " line1\n" + "+added2\n" + "+added3\n" + " line2\n" + "@@ -10,3 +12,3 @@\n" + " line10\n" + "-line11 old\n" + "+line11 new\n" + " line12\n" + ) + hunks = pr.parse_hunks(multi) + self.assertEqual(len(hunks), 2) + # between hunks: new 8 = old 6 (two lines added by hunk 1) + self.assertEqual(pr.old_line_for(hunks, 8), 6) + # inside second hunk: modified new 13 anchors to old 11 + self.assertEqual(pr.old_line_for(hunks, 13), 11) + + def test_pure_addition_at_top(self): + hunks = pr.parse_hunks("@@ -0,0 +1,2 @@\n+first\n+second\n") + self.assertEqual(pr.old_line_for(hunks, 1), 1) + + +class ParseDiffTest(unittest.TestCase): + def test_modified_file(self): + fd = pr.parse_diff(SAMPLE_DIFF)[0] + self.assertEqual((fd.old_path, fd.new_path), ("main.go", "main.go")) + self.assertFalse(fd.is_new or fd.is_deleted or fd.is_binary) + + def test_new_file(self): + fd = pr.parse_diff(NEW_FILE_DIFF)[0] + self.assertTrue(fd.is_new) + self.assertEqual(fd.new_path, "added.go") + + def test_deleted_file(self): + fd = pr.parse_diff(DELETED_FILE_DIFF)[0] + self.assertTrue(fd.is_deleted) + self.assertEqual(fd.new_path, "/dev/null") + + def test_binary_file(self): + fd = pr.parse_diff(BINARY_DIFF)[0] + self.assertTrue(fd.is_binary) + + def test_multiple_files(self): + files = pr.parse_diff(SAMPLE_DIFF + NEW_FILE_DIFF) + self.assertEqual([f.new_path for f in files], ["main.go", "added.go"]) + + +class Recorder: + """A post() that records discussions; optionally fails the first inline.""" + + def __init__(self, fail_first_inline=False): + self.calls = [] + self.fail_first_inline = fail_first_inline + self._inline_seen = 0 + + def __call__(self, discussion): + if self.fail_first_inline and "newPath" in discussion: + self._inline_seen += 1 + if self._inline_seen == 1: + raise RuntimeError("simulated 403") + self.calls.append(discussion) + + +def diffs_from(diff_text): + return {fd.new_path: fd for fd in pr.parse_diff(diff_text)} + + +class PublishTest(unittest.TestCase): + def test_inline_and_summary(self): + result = { + "comments": [{ + "path": "main.go", "content": "possible nil dereference", + "start_line": 6, "end_line": 6, + "existing_code": "x := y.Field", + "suggestion_code": "if y != nil { x = y.Field }", + }], + } + rec = Recorder() + stats = pr.publish(result, diffs_from(SAMPLE_DIFF), rec) + + self.assertEqual(stats, {"inline": 1, "fallback": 0}) + self.assertEqual(len(rec.calls), 2) # inline + summary + + inline = rec.calls[0] + self.assertEqual(inline["newLine"], 6) + self.assertEqual(inline["oldLine"], 5) + self.assertEqual((inline["newPath"], inline["oldPath"]), ("main.go", "main.go")) + self.assertIn("possible nil dereference", inline["message"]) + self.assertIn("**Suggestion:**", inline["message"]) + + summary = rec.calls[1] + self.assertNotIn("newPath", summary) + self.assertIn("**1** issue(s)", summary["message"]) + + def test_fallback_for_unmapped_comment(self): + result = { + "comments": [{ + "path": "missing.go", "content": "issue in file absent from diff", + "start_line": 1, "end_line": 1, + }], + "warnings": [{"file": "a.go", "message": "skipped", "type": "subtask_error"}], + } + rec = Recorder() + stats = pr.publish(result, {}, rec) + + self.assertEqual(stats, {"inline": 0, "fallback": 1}) + self.assertEqual(len(rec.calls), 2) # fallback + summary + self.assertIn("could not be posted inline", rec.calls[0]["message"]) + self.assertIn("`missing.go`", rec.calls[0]["message"]) + self.assertIn("1 warning(s)", rec.calls[1]["message"]) + + def test_inline_error_falls_back(self): + result = { + "comments": [{ + "path": "main.go", "content": "finding", + "start_line": 1, "end_line": 1, + }], + } + rec = Recorder(fail_first_inline=True) + stats = pr.publish(result, diffs_from(SAMPLE_DIFF), rec) + + self.assertEqual(stats, {"inline": 0, "fallback": 1}) + self.assertEqual(len(rec.calls), 2) # fallback + summary after inline failure + + def test_no_comments(self): + rec = Recorder() + stats = pr.publish({"message": "No comments generated. Looks good to me."}, {}, rec) + + self.assertEqual(stats, {"inline": 0, "fallback": 0}) + self.assertEqual(len(rec.calls), 1) + self.assertIn("Looks good to me", rec.calls[0]["message"]) + + def test_new_file_anchors_to_new_path(self): + result = { + "comments": [{ + "path": "added.go", "content": "empty main", + "start_line": 3, "end_line": 3, + }], + } + rec = Recorder() + stats = pr.publish(result, diffs_from(NEW_FILE_DIFF), rec) + + self.assertEqual(stats["inline"], 1) + inline = rec.calls[0] + self.assertEqual(inline["oldPath"], "added.go") + self.assertEqual(inline["oldLine"], 1) + self.assertEqual(inline["newLine"], 3) + + +if __name__ == "__main__": + unittest.main() From d70dbfa02bcd15b2f85541c888e0e0de35077378 Mon Sep 17 00:00:00 2001 From: Yinka Metrics <115735904+YinkaMetrics@users.noreply.github.com> Date: Thu, 2 Jul 2026 06:17:42 +0100 Subject: [PATCH 44/87] docs(pages): add MCP Server docs section (#262) * docs: add MCP server docs section * docs: address mcp review feedback --- pages/src/i18n/en.ts | 19 ++++++++++++ pages/src/i18n/ja.ts | 19 ++++++++++++ pages/src/i18n/zh.ts | 19 ++++++++++++ pages/src/pages/DocsPage.tsx | 59 ++++++++++++++++++++++++++++++++++++ 4 files changed, 116 insertions(+) diff --git a/pages/src/i18n/en.ts b/pages/src/i18n/en.ts index 31fc2e1..201280f 100644 --- a/pages/src/i18n/en.ts +++ b/pages/src/i18n/en.ts @@ -106,6 +106,7 @@ export const en: TranslationKeys = { 'docs.review': 'ocr review', 'docs.scan': 'ocr scan', 'docs.viewer': 'ocr viewer', + 'docs.mcp': 'MCP Server', 'docs.env': 'Claude Code Integration', 'docs.overviewTitle': 'Overview', 'docs.overviewDesc': '(short ocr) is an AI-powered code review CLI tool.', @@ -242,6 +243,24 @@ export const en: TranslationKeys = { 'docs.viewerTitle': 'ocr viewer', 'docs.viewerDesc': 'Starts a WebUI session viewer for browsing review session logs in a web interface.', 'docs.viewerNote': 'After running, a local HTTP server starts providing a visual interface for browsing review session logs.', + 'docs.mcpTitle': 'MCP Server', + 'docs.mcpDesc': 'Open Code Review supports Model Context Protocol (MCP) servers, allowing the review agent to use external tools during code review via the stdio transport.', + 'docs.mcpConfig': 'Configure MCP Servers', + 'docs.mcpConfigLocation': 'MCP server configuration is stored in ~/.opencodereview/config.json.', + 'docs.mcpDelete': 'Delete MCP Servers', + 'docs.mcpFields': 'Configuration Fields', + 'docs.mcpFieldCol': 'Field', + 'docs.mcpRequiredCol': 'Required', + 'docs.mcpDescCol': 'Description', + 'docs.mcpYes': 'Yes', + 'docs.mcpNo': 'No', + 'docs.mcpFieldCommandDesc': 'The executable command to start the MCP server', + 'docs.mcpFieldArgsDesc': 'Command-line arguments passed to the server', + 'docs.mcpFieldEnvDesc': 'Environment variables in KEY=VALUE format', + 'docs.mcpFieldToolsDesc': 'Allowed tool names; if empty, all tools from the server are available', + 'docs.mcpFieldSetupDesc': 'A shell command to run before starting the server, such as building an index', + 'docs.mcpNote': 'If an MCP tool name conflicts with a built-in tool, it will be skipped with a warning. The setup command has a 5-minute timeout.', + 'docs.mcpExample': 'Example: Add CodeGraph for Code Structure Analysis', 'docs.envTitle': 'Claude Code Integration', 'docs.envDesc': 'If you are already a Claude Code user with the following environment variables configured, Open Code Review will recognize them automatically — no extra configuration needed:', 'docs.envNote': 'You can also use ocr config to override or supplement these settings.', diff --git a/pages/src/i18n/ja.ts b/pages/src/i18n/ja.ts index 51e96ec..374d81b 100644 --- a/pages/src/i18n/ja.ts +++ b/pages/src/i18n/ja.ts @@ -108,6 +108,7 @@ export const ja: TranslationKeys = { 'docs.review': 'ocr review', 'docs.scan': 'ocr scan', 'docs.viewer': 'ocr viewer', + 'docs.mcp': 'MCP Server', 'docs.env': 'Claude Code 統合', 'docs.overviewTitle': '概要', 'docs.overviewDesc': '(略称 ocr)は AI 駆動のコードレビュー CLI ツールです。', @@ -244,6 +245,24 @@ export const ja: TranslationKeys = { 'docs.viewerTitle': 'ocr viewer', 'docs.viewerDesc': 'WebUI セッションビューアーを起動し、Web インターフェースでレビューセッションログを閲覧します。', 'docs.viewerNote': '実行後、ローカル HTTP サーバーが起動し、レビューセッションログを閲覧するためのビジュアルインターフェースを提供します。', + 'docs.mcpTitle': 'MCP Server', + 'docs.mcpDesc': 'Open Code Review は Model Context Protocol (MCP) サーバーをサポートしており、レビュー Agent が stdio transport 経由でコードレビュー中に外部ツールを使用できます。', + 'docs.mcpConfig': 'MCP Server の設定', + 'docs.mcpConfigLocation': 'MCP server の設定は ~/.opencodereview/config.json に保存されます。', + 'docs.mcpDelete': 'MCP Server の削除', + 'docs.mcpFields': '設定フィールド', + 'docs.mcpFieldCol': 'フィールド', + 'docs.mcpRequiredCol': '必須', + 'docs.mcpDescCol': '説明', + 'docs.mcpYes': 'はい', + 'docs.mcpNo': 'いいえ', + 'docs.mcpFieldCommandDesc': 'MCP server を起動する実行コマンド', + 'docs.mcpFieldArgsDesc': 'server に渡すコマンドライン引数', + 'docs.mcpFieldEnvDesc': 'KEY=VALUE 形式の環境変数', + 'docs.mcpFieldToolsDesc': '許可するツール名。空の場合は server が提供するすべてのツールを使用できます', + 'docs.mcpFieldSetupDesc': 'server 起動前に実行する shell コマンド。例:インデックスの構築', + 'docs.mcpNote': 'MCP ツール名が組み込みツールと競合する場合、そのツールは警告付きでスキップされます。setup コマンドのタイムアウトは 5 分です。', + 'docs.mcpExample': '例:CodeGraph を追加してコード構造を分析', 'docs.envTitle': 'Claude Code 統合', 'docs.envDesc': 'すでに Claude Code ユーザーで以下の環境変数を設定済みの場合、Open Code Review は自動的に認識します。追加設定は不要です:', 'docs.envNote': 'ocr config を使用してこれらの設定を上書きまたは補完することもできます。', diff --git a/pages/src/i18n/zh.ts b/pages/src/i18n/zh.ts index 121c911..2a6d415 100644 --- a/pages/src/i18n/zh.ts +++ b/pages/src/i18n/zh.ts @@ -108,6 +108,7 @@ export const zh: TranslationKeys = { 'docs.review': 'ocr review', 'docs.scan': 'ocr scan', 'docs.viewer': 'ocr viewer', + 'docs.mcp': 'MCP Server', 'docs.env': 'Claude Code 集成', 'docs.overviewTitle': '概览', 'docs.overviewDesc': '(简称 ocr)是一款 AI 驱动的代码审查 CLI 工具。', @@ -244,6 +245,24 @@ export const zh: TranslationKeys = { 'docs.viewerTitle': 'ocr viewer', 'docs.viewerDesc': '启动 WebUI 会话查看器,在 Web 界面中浏览审查会话日志。', 'docs.viewerNote': '运行后将启动一个本地 HTTP 服务器,提供可视化界面浏览审查会话日志。', + 'docs.mcpTitle': 'MCP Server', + 'docs.mcpDesc': 'Open Code Review 支持 Model Context Protocol (MCP) 服务器,允许审查 Agent 通过 stdio transport 在代码审查过程中使用外部工具。', + 'docs.mcpConfig': '配置 MCP Server', + 'docs.mcpConfigLocation': 'MCP server 配置存储在 ~/.opencodereview/config.json。', + 'docs.mcpDelete': '删除 MCP Server', + 'docs.mcpFields': '配置字段', + 'docs.mcpFieldCol': '字段', + 'docs.mcpRequiredCol': '必填', + 'docs.mcpDescCol': '描述', + 'docs.mcpYes': '是', + 'docs.mcpNo': '否', + 'docs.mcpFieldCommandDesc': '用于启动 MCP server 的可执行命令', + 'docs.mcpFieldArgsDesc': '传递给 server 的命令行参数', + 'docs.mcpFieldEnvDesc': 'KEY=VALUE 格式的环境变量', + 'docs.mcpFieldToolsDesc': '允许使用的工具名称;为空时可使用该 server 提供的全部工具', + 'docs.mcpFieldSetupDesc': '启动 server 前运行的 shell 命令,例如构建索引', + 'docs.mcpNote': '如果 MCP 工具名称与内置工具冲突,该工具将被跳过并显示警告。setup 命令的超时时间为 5 分钟。', + 'docs.mcpExample': '示例:添加 CodeGraph 进行代码结构分析', 'docs.envTitle': 'Claude Code 集成', 'docs.envDesc': '如果您已经是 Claude Code 用户并配置了以下环境变量,Open Code Review 将自动识别——无需额外配置:', 'docs.envNote': '您也可以使用 ocr config 覆盖或补充这些设置。', diff --git a/pages/src/pages/DocsPage.tsx b/pages/src/pages/DocsPage.tsx index c7cd1e9..19c1622 100644 --- a/pages/src/pages/DocsPage.tsx +++ b/pages/src/pages/DocsPage.tsx @@ -60,6 +60,7 @@ const sectionDefs: Section[] = [ { id: 'review', labelKey: 'docs.review' }, { id: 'scan', labelKey: 'docs.scan' }, { id: 'viewer', labelKey: 'docs.viewer' }, + { id: 'mcp', labelKey: 'docs.mcp' }, { id: 'env', labelKey: 'docs.env' }, ]; @@ -188,6 +189,16 @@ const DocsPage: React.FC = () => { const subTitle: React.CSSProperties = { fontSize: 15, fontWeight: 600, color: '#FFFFFF', margin: '24px 0 8px 0', lineHeight: '24px', fontFamily }; const desc: React.CSSProperties = { fontSize: 14, color: 'rgba(255,255,255,0.6)', lineHeight: '24px', margin: '0 0 12px 0', fontFamily }; const sectionSpacing: React.CSSProperties = { marginBottom: 56, display: 'flex', flexDirection: 'column' as const, alignItems: 'stretch' }; + const mcpAddCommands = `ocr config set mcp_servers..command +ocr config set mcp_servers..args '["arg1","arg2"]' +ocr config set mcp_servers..tools '["tool_name"]' +ocr config set mcp_servers..setup '' +ocr config set mcp_servers..env '["KEY=VALUE"]'`; + const mcpDeleteCommands = `ocr config unset mcp_servers.`; + const mcpCodeGraphCommands = `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'`; return (
@@ -588,6 +599,54 @@ const DocsPage: React.FC = () => {

{t('docs.viewerNote')}

+ {/* ─── MCP Server ─── */} +
+

{t('docs.mcpTitle')}

+

{t('docs.mcpDesc')}

+ +

{t('docs.mcpConfig')}

+ handleCopy(mcpAddCommands)} /> +

{t('docs.mcpConfigLocation')}

+ +

{t('docs.mcpDelete')}

+ handleCopy(mcpDeleteCommands)} /> + +

{t('docs.mcpFields')}

+
+
+
{t('docs.mcpFieldCol')}
+
{t('docs.mcpRequiredCol')}
+
{t('docs.mcpDescCol')}
+
+ {[ + ['command', t('docs.mcpYes'), t('docs.mcpFieldCommandDesc')], + ['args', t('docs.mcpNo'), t('docs.mcpFieldArgsDesc')], + ['tools', t('docs.mcpNo'), t('docs.mcpFieldToolsDesc')], + ['setup', t('docs.mcpNo'), t('docs.mcpFieldSetupDesc')], + ['env', t('docs.mcpNo'), t('docs.mcpFieldEnvDesc')], + ].map(([field, required, d], idx, arr) => ( +
+
+ {field} +
+
+ {required} +
+
+ {d} +
+
+ ))} +
+ +

+ {t('docs.mcpNote')} +

+ +

{t('docs.mcpExample')}

+ handleCopy(mcpCodeGraphCommands)} /> +
+ {/* ─── Claude Code Integration ─── */}

{t('docs.envTitle')}

From 90a926e964e30656aa58dfba981220cfb30ec809 Mon Sep 17 00:00:00 2001 From: kite Date: Thu, 2 Jul 2026 13:23:17 +0800 Subject: [PATCH 45/87] docs(pages): move MCP setup timeout info into field table Move the 5-minute timeout note from the standalone mcpNote paragraph into the setup field description in the MCP Server table, so users see the timeout constraint directly alongside the field definition. --- pages/src/i18n/en.ts | 4 ++-- pages/src/i18n/ja.ts | 4 ++-- pages/src/i18n/zh.ts | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/pages/src/i18n/en.ts b/pages/src/i18n/en.ts index 201280f..34a3308 100644 --- a/pages/src/i18n/en.ts +++ b/pages/src/i18n/en.ts @@ -258,8 +258,8 @@ export const en: TranslationKeys = { 'docs.mcpFieldArgsDesc': 'Command-line arguments passed to the server', 'docs.mcpFieldEnvDesc': 'Environment variables in KEY=VALUE format', 'docs.mcpFieldToolsDesc': 'Allowed tool names; if empty, all tools from the server are available', - 'docs.mcpFieldSetupDesc': 'A shell command to run before starting the server, such as building an index', - 'docs.mcpNote': 'If an MCP tool name conflicts with a built-in tool, it will be skipped with a warning. The setup command has a 5-minute timeout.', + 'docs.mcpFieldSetupDesc': 'A shell command to run before starting the server, such as building an index (5-minute timeout)', + 'docs.mcpNote': 'If an MCP tool name conflicts with a built-in tool, it will be skipped with a warning.', 'docs.mcpExample': 'Example: Add CodeGraph for Code Structure Analysis', 'docs.envTitle': 'Claude Code Integration', 'docs.envDesc': 'If you are already a Claude Code user with the following environment variables configured, Open Code Review will recognize them automatically — no extra configuration needed:', diff --git a/pages/src/i18n/ja.ts b/pages/src/i18n/ja.ts index 374d81b..55265bc 100644 --- a/pages/src/i18n/ja.ts +++ b/pages/src/i18n/ja.ts @@ -260,8 +260,8 @@ export const ja: TranslationKeys = { 'docs.mcpFieldArgsDesc': 'server に渡すコマンドライン引数', 'docs.mcpFieldEnvDesc': 'KEY=VALUE 形式の環境変数', 'docs.mcpFieldToolsDesc': '許可するツール名。空の場合は server が提供するすべてのツールを使用できます', - 'docs.mcpFieldSetupDesc': 'server 起動前に実行する shell コマンド。例:インデックスの構築', - 'docs.mcpNote': 'MCP ツール名が組み込みツールと競合する場合、そのツールは警告付きでスキップされます。setup コマンドのタイムアウトは 5 分です。', + 'docs.mcpFieldSetupDesc': 'server 起動前に実行する shell コマンド。例:インデックスの構築(タイムアウト 5 分)', + 'docs.mcpNote': 'MCP ツール名が組み込みツールと競合する場合、そのツールは警告付きでスキップされます。', 'docs.mcpExample': '例:CodeGraph を追加してコード構造を分析', 'docs.envTitle': 'Claude Code 統合', 'docs.envDesc': 'すでに Claude Code ユーザーで以下の環境変数を設定済みの場合、Open Code Review は自動的に認識します。追加設定は不要です:', diff --git a/pages/src/i18n/zh.ts b/pages/src/i18n/zh.ts index 2a6d415..b75f4c3 100644 --- a/pages/src/i18n/zh.ts +++ b/pages/src/i18n/zh.ts @@ -260,8 +260,8 @@ export const zh: TranslationKeys = { 'docs.mcpFieldArgsDesc': '传递给 server 的命令行参数', 'docs.mcpFieldEnvDesc': 'KEY=VALUE 格式的环境变量', 'docs.mcpFieldToolsDesc': '允许使用的工具名称;为空时可使用该 server 提供的全部工具', - 'docs.mcpFieldSetupDesc': '启动 server 前运行的 shell 命令,例如构建索引', - 'docs.mcpNote': '如果 MCP 工具名称与内置工具冲突,该工具将被跳过并显示警告。setup 命令的超时时间为 5 分钟。', + 'docs.mcpFieldSetupDesc': '启动 server 前运行的 shell 命令,例如构建索引(超时 5 分钟)', + 'docs.mcpNote': '如果 MCP 工具名称与内置工具冲突,该工具将被跳过并显示警告。', 'docs.mcpExample': '示例:添加 CodeGraph 进行代码结构分析', 'docs.envTitle': 'Claude Code 集成', 'docs.envDesc': '如果您已经是 Claude Code 用户并配置了以下环境变量,Open Code Review 将自动识别——无需额外配置:', From d83a758a6dfdc6f6123ff15a59a56690b4499364 Mon Sep 17 00:00:00 2001 From: kite Date: Thu, 2 Jul 2026 13:27:50 +0800 Subject: [PATCH 46/87] docs(pages): update page title to "AI Code Review" --- pages/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pages/index.html b/pages/index.html index 757f6d7..c01e8c3 100644 --- a/pages/index.html +++ b/pages/index.html @@ -3,7 +3,7 @@ - Open Code Review — Agent Native Code Review + Open Code Review — AI Code Review From 0d635537e3dd864d2921e565251286748774d098 Mon Sep 17 00:00:00 2001 From: Gongyl01 <107625814+Gongyl01@users.noreply.github.com> Date: Thu, 2 Jul 2026 16:36:54 +0800 Subject: [PATCH 47/87] docs(pages): add landing page development guide (#270) --- pages/README.md | 129 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 pages/README.md diff --git a/pages/README.md b/pages/README.md new file mode 100644 index 0000000..6b1df65 --- /dev/null +++ b/pages/README.md @@ -0,0 +1,129 @@ +# OpenCodeReview Landing Page (`pages/`) + +This directory contains the OpenCodeReview landing page, built with TypeScript, React, Webpack, and Tailwind CSS. + +## Getting Started + +### Prerequisites + +- Node.js `>=18` (recommended: latest LTS) +- `npm` (comes with Node.js) or `pnpm` + +### Install dependencies + +From the `pages/` directory: + +```bash +npm install +``` + +Or with pnpm: + +```bash +pnpm install +``` + +### Start local dev server + +```bash +npx webpack serve +``` + +Equivalent npm script: + +```bash +npm run dev +``` + +Default dev server settings (from `webpack.config.js`): + +- URL: `http://localhost:3030` +- Host: `0.0.0.0` +- Port: `3030` + +### Build for production + +```bash +npx webpack +``` + +Project script (recommended, sets production mode): + +```bash +npm run build +``` + +Build output is generated in `pages/dist/`. + +### Type checking + +```bash +npm run typecheck +``` + +## Project Structure + +```text +pages/ +├── src/ # React + TypeScript source code +│ ├── components/ # Reusable UI components +│ ├── pages/ # Route-level page components +│ ├── i18n/ # Localization resources and i18n context +│ ├── styles/ # Global styles (Tailwind entry, custom CSS) +│ └── index.tsx # Frontend entry point +├── dist/ # Production build artifacts (generated) +├── index.html # HTML template used by HtmlWebpackPlugin +├── webpack.config.js # Bundling + dev server config +├── tailwind.config.js # Tailwind theme/content configuration +├── postcss.config.js # PostCSS pipeline (Tailwind + Autoprefixer) +├── tsconfig.json # TypeScript compiler options +└── package.json # Dependencies and scripts +``` + +## Development Guidelines + +### PR screenshots are required + +Any PR that changes files in `pages/` must include **before/after screenshots** of affected views in the PR description. + +Please include: + +- What page/section changed +- Before screenshot +- After screenshot +- Desktop or mobile context (if responsive behavior changed) + +### Code style and formatting + +- Follow existing TypeScript + React style in this directory. +- Keep components focused and readable; prefer splitting large JSX blocks into smaller components. +- Prefer utility-first Tailwind classes and reuse existing design tokens from `tailwind.config.js`. +- Keep imports and file naming consistent with surrounding code. +- Run `npm run typecheck` before opening a PR. + +### Tailwind CSS configuration notes + +- Tailwind config is in `tailwind.config.js`. +- Content scanning targets: + - `./src/**/*.{ts,tsx}` + - `./index.html` +- PostCSS integration is configured in `postcss.config.js` with: + - `tailwindcss` + - `autoprefixer` + +### TypeScript configuration notes + +- TypeScript config is in `tsconfig.json`. +- Important defaults: + - `strict: true` + - `jsx: react-jsx` + - `target: ES2020` + - `noEmit: true` (Webpack handles output) + +## Suggested PR Checklist + +- [ ] Dependencies installed and project runs locally +- [ ] `npm run typecheck` passes +- [ ] `npm run build` succeeds +- [ ] Before/after screenshots added to PR +- [ ] Scope is limited to one logical change From 74a2ac1c1c213c429324c219b1f5702abf4eacee Mon Sep 17 00:00:00 2001 From: "Mountain Ghost. W" Date: Thu, 2 Jul 2026 19:23:17 +0800 Subject: [PATCH 48/87] feat(llm): add z-ai-coding provider for GLM Coding Plan endpoint (#258) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(llm): add z-ai-coding provider for GLM Coding Plan endpoint Z.AI (智谱) subscribers to the GLM Coding Plan must route requests through the dedicated coding endpoint (https://open.bigmodel.cn/api/coding/paas/v4) for them to be billed against the subscription quota. The existing z-ai provider points at the generic pay-as-you-go endpoint (https://open.bigmodel.cn/api/paas/v4), so Coding Plan keys silently drain the wallet balance instead of consuming the plan quota, surfacing as a spurious "1113 余额不足" error even when the plan is barely used. Add a dedicated z-ai-coding provider following the existing *-tokenplan pattern (dashscope/dashscope-tokenplan, tencent-tokenhub/hy-tokenplan). It reuses Z_AI_API_KEY — the same key authenticates against both endpoints, so selecting this provider is all that's needed to activate the plan. The model list is restricted to the models officially supported by the Coding Plan to avoid selecting a non-plan model that falls back to wallet billing. - internal/llm/providers.go: register z-ai-coding preset - extensions/vscode/src/shared/providers.ts: mirror the preset (kept in sync with the Go registry per the file header) - internal/llm/providers_test.go: update the sorted provider list assertion Co-Authored-By: Oz * docs(pages): add Z.AI GLM Coding Plan config tip to docs page Subscribers to the Z.AI (Zhipu) GLM Coding Plan must route requests through the dedicated coding endpoint (https://open.bigmodel.cn/api/coding/paas/v4) to bill against the plan quota. The default z-ai preset points at the generic pay-as-you-go endpoint, so coding-plan keys silently drain the wallet and surface a spurious "1113 余额不足" error — a recurring trap for new users. Add a provider-specific callout at the end of the Docs config section showing the one-line fix that works today on any released version: ocr config set providers.z-ai.url https://open.bigmodel.cn/api/coding/paas/v4 This complements the z-ai-coding provider added in the previous commit: the provider gives a native first-class option going forward, while this doc tip rescues users already running released builds. Copy/localized for zh/en/ja. Co-Authored-By: Oz * fix(llm): address review feedback for z-ai-coding provider - Switch z-ai-coding to a dedicated Z_AI_CODING_API_KEY env var instead of reusing Z_AI_API_KEY, matching the tokenplan-provider convention so pay-as-you-go and Coding Plan keys can be configured independently - Remove comment blocks from the z-ai-coding presets (Go and TS) to keep the registry as plain data consistent with the other entries - Revert pages/ changes (i18n + DocsPage.tsx); provider docs are out of scope for a provider-registration PR Co-Authored-By: Oz --------- Co-authored-by: mountainwu Co-authored-by: Oz --- extensions/vscode/src/shared/providers.ts | 8 ++++++++ internal/llm/providers.go | 13 +++++++++++++ internal/llm/providers_test.go | 2 +- 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/extensions/vscode/src/shared/providers.ts b/extensions/vscode/src/shared/providers.ts index ef03f51..684fb7a 100644 --- a/extensions/vscode/src/shared/providers.ts +++ b/extensions/vscode/src/shared/providers.ts @@ -95,6 +95,14 @@ export const PROVIDER_PRESETS: OcrProviderPreset[] = [ envVar: 'Z_AI_API_KEY', models: ['glm-5.2', 'glm-5.1', 'glm-5-turbo', 'glm-4.7'], }, + { + name: 'z-ai-coding', + displayName: 'Z.AI Coding Plan API', + protocol: 'openai', + baseUrl: 'https://open.bigmodel.cn/api/coding/paas/v4', + envVar: 'Z_AI_CODING_API_KEY', + models: ['glm-5.2', 'glm-5.1', 'glm-5-turbo', 'glm-4.7'], + }, { name: 'mimo', displayName: 'Xiaomi MiMo API', diff --git a/internal/llm/providers.go b/internal/llm/providers.go index 83b1e16..9a80981 100644 --- a/internal/llm/providers.go +++ b/internal/llm/providers.go @@ -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", diff --git a/internal/llm/providers_test.go b/internal/llm/providers_test.go index c4f5e07..4f3fdf4 100644 --- a/internal/llm/providers_test.go +++ b/internal/llm/providers_test.go @@ -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)) } From 589a7249f512ece8638e1565eb925e16274873f8 Mon Sep 17 00:00:00 2001 From: paker Date: Fri, 3 Jul 2026 11:26:03 +0800 Subject: [PATCH 49/87] fix(telemetry): support http:// scheme in otlp_endpoint for insecure gRPC (#280) * fix(telemetry): support http:// scheme in otlp_endpoint for insecure gRPC Parse the otlp_endpoint scheme before creating the OTLP gRPC exporters: - http://host:port -> strip scheme, call WithInsecure() (plaintext gRPC) - https://host:port -> strip scheme, keep default TLS - host:port -> unchanged, keep default TLS (backward compatible) Scheme matching is case-insensitive. Applies to both the trace and metric exporters in initOTLPProviders. Fixes #268 * fix(telemetry): trim trailing slash from otlp_endpoint after scheme strip Addresses review feedback: a URL-style endpoint with a trailing slash (e.g. "http://localhost:4317/") left the trailing "/" in the address passed to WithEndpoint(), which expects a bare host:port with no path and could cause connection failures. --- internal/telemetry/exporter.go | 37 ++++++++++++++++++++++++----- internal/telemetry/exporter_test.go | 29 ++++++++++++++++++++++ 2 files changed, 60 insertions(+), 6 deletions(-) diff --git a/internal/telemetry/exporter.go b/internal/telemetry/exporter.go index 096d3e3..58638c8 100644 --- a/internal/telemetry/exporter.go +++ b/internal/telemetry/exporter.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "strings" sdkmetric "go.opentelemetry.io/otel/sdk/metric" "go.opentelemetry.io/otel/sdk/resource" @@ -25,11 +26,33 @@ func newStdoutMetricExporter() (sdkmetric.Exporter, error) { return stdoutmetric.New(stdoutmetric.WithPrettyPrint()) } +// parseOTLPEndpoint strips a http:// or https:// scheme from the endpoint and +// reports whether the connection should be insecure (plaintext gRPC). +// Scheme matching is case-insensitive per RFC 3986. A bare host:port (no +// scheme) is left unchanged and defaults to TLS. Any trailing slash left +// over from a URL-style endpoint (e.g. "http://localhost:4317/") is +// trimmed, since otlptracegrpc/otlpmetricgrpc's WithEndpoint expects a bare +// host:port with no path. +func parseOTLPEndpoint(endpoint string) (addr string, insecure bool) { + switch { + case len(endpoint) >= 7 && strings.EqualFold(endpoint[:7], "http://"): + return strings.TrimRight(endpoint[7:], "/"), true + case len(endpoint) >= 8 && strings.EqualFold(endpoint[:8], "https://"): + return strings.TrimRight(endpoint[8:], "/"), false + default: + return endpoint, false + } +} + // initOTLPProviders sets up OTLP gRPC exporters for traces and metrics. func initOTLPProviders(ctx context.Context, res *resource.Resource, cfg Config) { - traceExp, err := otlptracegrpc.New(ctx, - otlptracegrpc.WithEndpoint(cfg.OTLPEndpoint), - ) + addr, insecure := parseOTLPEndpoint(cfg.OTLPEndpoint) + + traceOpts := []otlptracegrpc.Option{otlptracegrpc.WithEndpoint(addr)} + if insecure { + traceOpts = append(traceOpts, otlptracegrpc.WithInsecure()) + } + traceExp, err := otlptracegrpc.New(ctx, traceOpts...) if err != nil { fmt.Fprintf(os.Stderr, "[ocr] WARNING: failed to create OTLP trace exporter: %v\n", err) return @@ -42,9 +65,11 @@ func initOTLPProviders(ctx context.Context, res *resource.Resource, cfg Config) tracerProvider = tp shutdownFuncs = append(shutdownFuncs, func(ctx context.Context) error { return tp.Shutdown(ctx) }) - metricExp, err := otlpmetricgrpc.New(ctx, - otlpmetricgrpc.WithEndpoint(cfg.OTLPEndpoint), - ) + metricOpts := []otlpmetricgrpc.Option{otlpmetricgrpc.WithEndpoint(addr)} + if insecure { + metricOpts = append(metricOpts, otlpmetricgrpc.WithInsecure()) + } + metricExp, err := otlpmetricgrpc.New(ctx, metricOpts...) if err != nil { fmt.Fprintf(os.Stderr, "[ocr] WARNING: failed to create OTLP metric exporter: %v\n", err) return diff --git a/internal/telemetry/exporter_test.go b/internal/telemetry/exporter_test.go index 64f8573..95efd4b 100644 --- a/internal/telemetry/exporter_test.go +++ b/internal/telemetry/exporter_test.go @@ -7,6 +7,35 @@ import ( "go.opentelemetry.io/otel/sdk/resource" ) +func TestParseOTLPEndpoint(t *testing.T) { + cases := []struct { + name string + endpoint string + wantAddr string + wantInsecure bool + }{ + {"http scheme strips and is insecure", "http://192.0.2.1:4317", "192.0.2.1:4317", true}, + {"https scheme strips and keeps TLS", "https://otel.example.com:4317", "otel.example.com:4317", false}, + {"bare host:port unchanged and keeps TLS", "localhost:4317", "localhost:4317", false}, + {"uppercase HTTP scheme strips and is insecure", "HTTP://192.0.2.1:4317", "192.0.2.1:4317", true}, + {"mixed-case Https scheme strips and keeps TLS", "Https://otel.example.com:4317", "otel.example.com:4317", false}, + {"endpoint shorter than scheme prefix is unchanged", "ht", "ht", false}, + {"http scheme with trailing slash is trimmed", "http://192.0.2.1:4317/", "192.0.2.1:4317", true}, + {"https scheme with trailing slash is trimmed", "https://otel.example.com:4317/", "otel.example.com:4317", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + addr, insecure := parseOTLPEndpoint(tc.endpoint) + if addr != tc.wantAddr { + t.Errorf("addr = %q, want %q", addr, tc.wantAddr) + } + if insecure != tc.wantInsecure { + t.Errorf("insecure = %v, want %v", insecure, tc.wantInsecure) + } + }) + } +} + func TestNewStdoutTraceExporter(t *testing.T) { exp, err := newStdoutTraceExporter() if err != nil { From 022ed7568275512ed699baf6aa08fcef562c1e99 Mon Sep 17 00:00:00 2001 From: "hezheng.lsw" Date: Fri, 3 Jul 2026 11:45:33 +0800 Subject: [PATCH 50/87] feat(pages): add Docs page with search, markdown rendering, and i18n support (#273) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(pages): add Docs page with search, markdown rendering, and i18n support - Add DocsPage with full-text search modal (⌘K trigger) - Add MarkdownRenderer with DOMPurify sanitization - Add bilingual docs content (en/zh) for all sections - Add shared headingId utility for consistent TOC anchors - Add search keyboard hints with i18n support - Update Navbar with Docs navigation link - Add icon-search.svg asset - Configure webpack for markdown imports * fix(pages): address PR #273 code review feedback - Replace marked.setOptions() with new Marked instance (no global mutation) - Escape heading ID attribute value to prevent XSS - Use crypto.randomUUID() for mermaid diagram IDs (no collisions) - Add cancellation flag for async mermaid renders on unmount - Move inline
 styles to CSS class (only dynamic align-items inline)
- Move @types/dompurify to devDependencies
- Remove @ts-nocheck from docs/index.ts
- Extract getRawContent helper to reduce duplication
- Fix searchDocs fallback consistency (add enDocs fallback)
- Fix heading ID mismatch by stripping markdown links before ID generation
- Separate sidebar chevron (expand) from label (navigate)
- Guard ⌘K shortcut against input/textarea focus interception
---
 pages/index.html                              |    2 +-
 pages/package.json                            |    4 +
 pages/src/App.tsx                             |    2 +-
 pages/src/assets/icons/icon-search.svg        |    1 +
 pages/src/components/MarkdownRenderer.tsx     |  187 +++
 pages/src/components/Navbar.tsx               |    2 +-
 pages/src/content/docs/en/architecture.md     |  374 +++++
 pages/src/content/docs/en/cli-reference.md    |  396 +++++
 pages/src/content/docs/en/configuration.md    |  283 ++++
 pages/src/content/docs/en/contributing.md     |  220 +++
 pages/src/content/docs/en/faq.md              |  329 +++++
 pages/src/content/docs/en/installation.md     |  183 +++
 pages/src/content/docs/en/integrations.md     |   66 +
 .../docs/en/integrations/agent-skill.md       |  119 ++
 pages/src/content/docs/en/integrations/ci.md  |  459 ++++++
 .../docs/en/integrations/claude-code.md       |  119 ++
 .../docs/en/integrations/subprocess.md        |  171 +++
 pages/src/content/docs/en/overview.md         |  139 ++
 pages/src/content/docs/en/quickstart.md       |  242 +++
 pages/src/content/docs/en/review-rules.md     |  261 ++++
 pages/src/content/docs/en/telemetry.md        |  281 ++++
 pages/src/content/docs/en/tools.md            |  389 +++++
 pages/src/content/docs/en/viewer.md           |  182 +++
 pages/src/content/docs/index.ts               |  177 +++
 pages/src/content/docs/md.d.ts                |    4 +
 pages/src/content/docs/zh/architecture.md     |  328 +++++
 pages/src/content/docs/zh/cli-reference.md    |  376 +++++
 pages/src/content/docs/zh/configuration.md    |  272 ++++
 pages/src/content/docs/zh/contributing.md     |  201 +++
 pages/src/content/docs/zh/faq.md              |  283 ++++
 pages/src/content/docs/zh/installation.md     |  177 +++
 pages/src/content/docs/zh/integrations.md     |   56 +
 .../docs/zh/integrations/agent-skill.md       |  102 ++
 pages/src/content/docs/zh/integrations/ci.md  |  406 +++++
 .../docs/zh/integrations/claude-code.md       |  102 ++
 .../docs/zh/integrations/subprocess.md        |  164 +++
 pages/src/content/docs/zh/overview.md         |  125 ++
 pages/src/content/docs/zh/quickstart.md       |  235 +++
 pages/src/content/docs/zh/review-rules.md     |  241 +++
 pages/src/content/docs/zh/telemetry.md        |  258 ++++
 pages/src/content/docs/zh/tools.md            |  353 +++++
 pages/src/content/docs/zh/viewer.md           |  151 ++
 pages/src/i18n/en.ts                          |   30 +-
 pages/src/i18n/ja.ts                          |   30 +-
 pages/src/i18n/zh.ts                          |   30 +-
 pages/src/pages/DocsPage.tsx                  | 1302 ++++++++---------
 pages/src/styles/docs-markdown.css            |  242 +++
 pages/src/utils/headingId.ts                  |   10 +
 pages/webpack.config.js                       |    4 +
 49 files changed, 9402 insertions(+), 668 deletions(-)
 create mode 100644 pages/src/assets/icons/icon-search.svg
 create mode 100644 pages/src/components/MarkdownRenderer.tsx
 create mode 100644 pages/src/content/docs/en/architecture.md
 create mode 100644 pages/src/content/docs/en/cli-reference.md
 create mode 100644 pages/src/content/docs/en/configuration.md
 create mode 100644 pages/src/content/docs/en/contributing.md
 create mode 100644 pages/src/content/docs/en/faq.md
 create mode 100644 pages/src/content/docs/en/installation.md
 create mode 100644 pages/src/content/docs/en/integrations.md
 create mode 100644 pages/src/content/docs/en/integrations/agent-skill.md
 create mode 100644 pages/src/content/docs/en/integrations/ci.md
 create mode 100644 pages/src/content/docs/en/integrations/claude-code.md
 create mode 100644 pages/src/content/docs/en/integrations/subprocess.md
 create mode 100644 pages/src/content/docs/en/overview.md
 create mode 100644 pages/src/content/docs/en/quickstart.md
 create mode 100644 pages/src/content/docs/en/review-rules.md
 create mode 100644 pages/src/content/docs/en/telemetry.md
 create mode 100644 pages/src/content/docs/en/tools.md
 create mode 100644 pages/src/content/docs/en/viewer.md
 create mode 100644 pages/src/content/docs/index.ts
 create mode 100644 pages/src/content/docs/md.d.ts
 create mode 100644 pages/src/content/docs/zh/architecture.md
 create mode 100644 pages/src/content/docs/zh/cli-reference.md
 create mode 100644 pages/src/content/docs/zh/configuration.md
 create mode 100644 pages/src/content/docs/zh/contributing.md
 create mode 100644 pages/src/content/docs/zh/faq.md
 create mode 100644 pages/src/content/docs/zh/installation.md
 create mode 100644 pages/src/content/docs/zh/integrations.md
 create mode 100644 pages/src/content/docs/zh/integrations/agent-skill.md
 create mode 100644 pages/src/content/docs/zh/integrations/ci.md
 create mode 100644 pages/src/content/docs/zh/integrations/claude-code.md
 create mode 100644 pages/src/content/docs/zh/integrations/subprocess.md
 create mode 100644 pages/src/content/docs/zh/overview.md
 create mode 100644 pages/src/content/docs/zh/quickstart.md
 create mode 100644 pages/src/content/docs/zh/review-rules.md
 create mode 100644 pages/src/content/docs/zh/telemetry.md
 create mode 100644 pages/src/content/docs/zh/tools.md
 create mode 100644 pages/src/content/docs/zh/viewer.md
 create mode 100644 pages/src/styles/docs-markdown.css
 create mode 100644 pages/src/utils/headingId.ts

diff --git a/pages/index.html b/pages/index.html
index c01e8c3..757f6d7 100644
--- a/pages/index.html
+++ b/pages/index.html
@@ -3,7 +3,7 @@
 
   
   
-  Open Code Review — AI Code Review
+  Open Code Review — Agent Native Code Review
   
   
   
diff --git a/pages/package.json b/pages/package.json
index c2ce253..66acb5e 100644
--- a/pages/package.json
+++ b/pages/package.json
@@ -9,6 +9,9 @@
   },
   "dependencies": {
     "@agentscope-ai/icons": "^1.0.68",
+    "dompurify": "^3.4.11",
+    "marked": "^18.0.5",
+    "mermaid": "^11.16.0",
     "react": "^18.2.0",
     "react-dom": "^18.2.0",
     "react-router-dom": "^6.8.0",
@@ -22,6 +25,7 @@
     "@babel/preset-env": "^7.29.5",
     "@babel/preset-react": "^7.23.5",
     "@babel/preset-typescript": "^7.23.3",
+    "@types/dompurify": "^3.0.5",
     "@types/react": "^18.2.0",
     "@types/react-dom": "^18.2.0",
     "@types/three": "^0.185.0",
diff --git a/pages/src/App.tsx b/pages/src/App.tsx
index 73de908..ffe893e 100644
--- a/pages/src/App.tsx
+++ b/pages/src/App.tsx
@@ -22,7 +22,7 @@ const App: React.FC = () => {
         } />
         } />
         } />
-        } />
+        } />
       
     
   );
diff --git a/pages/src/assets/icons/icon-search.svg b/pages/src/assets/icons/icon-search.svg
new file mode 100644
index 0000000..3e5f898
--- /dev/null
+++ b/pages/src/assets/icons/icon-search.svg
@@ -0,0 +1 @@
+
diff --git a/pages/src/components/MarkdownRenderer.tsx b/pages/src/components/MarkdownRenderer.tsx
new file mode 100644
index 0000000..c3d6899
--- /dev/null
+++ b/pages/src/components/MarkdownRenderer.tsx
@@ -0,0 +1,187 @@
+import React, { useMemo, useEffect, useRef, useState, useCallback, useId } from 'react';
+import ReactDOM from 'react-dom';
+import { Marked, Renderer } from 'marked';
+import DOMPurify from 'dompurify';
+import mermaid from 'mermaid';
+import { useTranslation } from '../i18n';
+import copyIcon from '../assets/icons/icon-copy.svg';
+import { generateHeadingId } from '../utils/headingId';
+
+// Initialize mermaid with dark theme
+mermaid.initialize({
+  startOnLoad: false,
+  theme: 'dark',
+  themeVariables: {
+    primaryColor: '#1a1a2e',
+    primaryTextColor: 'rgba(255,255,255,0.85)',
+    primaryBorderColor: 'rgba(255,255,255,0.2)',
+    lineColor: 'rgba(255,255,255,0.4)',
+    secondaryColor: '#16213e',
+    tertiaryColor: '#0f3460',
+    background: '#000000',
+    mainBkg: 'rgba(255,255,255,0.04)',
+    nodeBorder: 'rgba(255,255,255,0.16)',
+    clusterBkg: 'rgba(255,255,255,0.02)',
+    titleColor: '#FFFFFF',
+    edgeLabelBackground: '#000000',
+  },
+  flowchart: {
+    htmlLabels: true,
+    curve: 'basis',
+  },
+});
+
+interface MarkdownRendererProps {
+  content: string;
+}
+
+/**
+ * Renders markdown content with dark theme styling matching the existing DocsPage design.
+ * Uses `marked` to parse markdown into HTML, then renders with styled container.
+ * Mermaid diagrams are rendered client-side after mount.
+ */
+const MarkdownRenderer: React.FC = ({ content }) => {
+  const containerRef = useRef(null);
+  const { t } = useTranslation();
+  const [toastVisible, setToastVisible] = useState(false);
+
+  const handleCopy = useCallback((text: string) => {
+    if (navigator.clipboard && window.isSecureContext) {
+      navigator.clipboard.writeText(text).then(() => {
+        setToastVisible(true);
+      }).catch(() => fallbackCopy(text));
+    } else {
+      fallbackCopy(text);
+    }
+  }, []);
+
+  const fallbackCopy = (text: string) => {
+    const textarea = document.createElement('textarea');
+    textarea.value = text;
+    textarea.style.position = 'fixed';
+    textarea.style.opacity = '0';
+    document.body.appendChild(textarea);
+    textarea.select();
+    const success = document.execCommand('copy');
+    document.body.removeChild(textarea);
+    if (success) setToastVisible(true);
+  };
+
+  useEffect(() => {
+    if (!toastVisible) return;
+    const timer = setTimeout(() => setToastVisible(false), 1200);
+    return () => clearTimeout(timer);
+  }, [toastVisible]);
+
+  const html = useMemo(() => {
+    // Custom renderer to generate heading IDs matching the TOC extraction logic
+    const renderer = new Renderer();
+    renderer.heading = function ({ text, depth }: { text: string; depth: number }) {
+      const id = generateHeadingId(text);
+      // Escape id attribute value to prevent XSS
+      const safeId = id.replace(/"/g, '"');
+      return `${text}\n`;
+    };
+    // Strip trailing newlines from code blocks to avoid empty line at bottom
+    renderer.code = function ({ text, lang, escaped }: { text: string; lang?: string; escaped?: boolean }) {
+      const trimmed = text.replace(/^\n+|\n+$/g, '');
+      const content = escaped ? trimmed : trimmed.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
+      const langClass = lang ? ` class="language-${lang}"` : '';
+      const isMultiline = trimmed.includes('\n');
+      const alignItems = isMultiline ? 'flex-start' : 'center';
+      return `
${content}
\n`; + }; + const instance = new Marked({ gfm: true, breaks: false, renderer }); + return DOMPurify.sanitize(instance.parse(content) as string); + }, [content]); + + // Render mermaid diagrams and add copy buttons to code blocks after DOM update + useEffect(() => { + if (!containerRef.current) return; + let cancelled = false; + + // Add copy buttons to all pre > code blocks (except mermaid) + const preBlocks = containerRef.current.querySelectorAll('pre'); + preBlocks.forEach((pre) => { + const codeEl = pre.querySelector('code'); + if (!codeEl || codeEl.classList.contains('language-mermaid')) return; + if (pre.querySelector('.code-copy-btn')) return; // already added + + // Create copy button matching reference HTML + const btn = document.createElement('div'); + btn.className = 'code-copy-btn'; + btn.style.cssText = 'display:flex;flex-shrink:0;justify-content:flex-start;align-items:flex-start;flex-direction:column;padding-top:4px;padding-bottom:4px;cursor:pointer;'; + btn.innerHTML = `copy`; + btn.addEventListener('click', () => { + const text = codeEl.textContent || ''; + handleCopy(text); + }); + pre.appendChild(btn); + }); + + // Render mermaid diagrams + const mermaidBlocks = containerRef.current.querySelectorAll('code.language-mermaid'); + if (mermaidBlocks.length === 0) return; + + const renderPromises = Array.from(mermaidBlocks).map(async (block) => { + const pre = block.parentElement; + if (!pre) return; + const code = block.textContent || ''; + try { + const id = `mermaid-diagram-${crypto.randomUUID()}`; + const { svg } = await mermaid.render(id, code); + if (cancelled) return; + // Replace the
 with rendered SVG
+        const wrapper = document.createElement('div');
+        wrapper.className = 'mermaid-rendered';
+        wrapper.innerHTML = svg;
+        pre.replaceWith(wrapper);
+      } catch (e) {
+        if (cancelled) return;
+        // If rendering fails, show the code block normally
+        (block as HTMLElement).style.display = 'block';
+        console.warn('[Mermaid] render failed:', e);
+      }
+    });
+
+    return () => { cancelled = true; };
+  }, [html]);
+
+  return (
+    <>
+      
+ {ReactDOM.createPortal( +
+ {t('quickstart.copied')} +
, + document.body + )} + + ); +}; + +export default MarkdownRenderer; diff --git a/pages/src/components/Navbar.tsx b/pages/src/components/Navbar.tsx index 4488d75..76ac72e 100644 --- a/pages/src/components/Navbar.tsx +++ b/pages/src/components/Navbar.tsx @@ -187,7 +187,7 @@ const Navbar: React.FC = () => { rel="noopener noreferrer" style={{ display: 'flex', alignItems: 'center', opacity: 0.6 }} > - Social + Social
- {/* ─── Overview ─── */} -
-

{t('docs.overviewTitle')}

-

- Open Code Review {t('docs.overviewDesc').replace(/<\/?code>/g, '')} -

-

- {t('docs.overviewFeatures')} -

-
- - {'✔\n✔\n✔\n✔\n✔\n✔'.split('\n').map((c, i) => {c}
)} -
- - {t('docs.overviewFeat1')}
- {t('docs.overviewFeat2')}
- {t('docs.overviewFeat3')}
- {t('docs.overviewFeat4')}
- {t('docs.overviewFeat5')}
- {t('docs.overviewFeat6')} -
-
-
- - {/* ─── Install ─── */} -
-

{t('docs.installTitle')}

- {/* Install item */} -
-
- - {t('docs.installLabel')} + {sidebarTree.map((group, gi) => ( +
+ {/* Group header */} +
+ + {t(group.groupLabelKey)} +
- handleCopy('npm i -g @alibaba-group/open-code-review')} /> -
- {/* Verify item */} -
-
- - {t('docs.installVerifyLabel')} -
- handleCopy('ocr version')} /> -
-
- - {/* ─── Configuration & Verification ─── */} -
-

{t('docs.configTitle')}

-

{t('docs.configDesc').replace(/<\/?code>/g, '')}

- -

{t('docs.configInteractive')}

-

{t('docs.configInteractiveDesc')}

- handleCopy('ocr config provider')} /> - -

{t('docs.configModelSelect')}

-

{t('docs.configModelSelectDesc')}

- handleCopy('ocr config model')} /> - -

{t('docs.configListProviders')}

-

{t('docs.configListProvidersDesc')}

- handleCopy('ocr llm providers')} /> - -

{t('docs.configManual')}

-

{t('docs.configCommand')}

- '} /> - -

{t('docs.configExample')}

- handleCopy(`ocr config set llm.url https://api.anthropic.com \\\n && ocr config set llm.auth_token {{your-api-key}} \\\n && ocr config set llm.model claude-opus-4-6 \\\n && ocr config set llm.use_anthropic true \\\n && ocr config set language Chinese`)} - /> - -

{t('docs.configKeys')}

-
- {/* 2-column grid of config keys */} - {[ - [{ key: 'llm.url', desc: t('docs.configKeyUrl') }, { key: 'llm.auth_token', desc: t('docs.configKeyToken') }], - [{ key: 'llm.model', desc: t('docs.configKeyModel') }, { key: 'llm.use_anthropic', desc: t('docs.configKeyAnthropic') }], - [{ key: 'telemetry.enabled', desc: t('docs.configKeyTelemetry') }, { key: 'language', desc: t('docs.configKeyLanguage') }], - [{ key: 'llm.extra_body', desc: t('docs.configKeyExtraBody') }], - ].map((row, ri) => ( -
- {row.map(({ key, desc: d }) => ( -
-

- {key} - {d} -

+ {/* Group items */} + {group.items.map((item) => { + const isActive = item.slug === activeSlug; + const hasChildren = item.children && item.children.length > 0; + const isExpanded = expandedItems[item.id] ?? false; + return ( + +
{ + navigateToDoc(item.slug); + }} + style={{ + height: 36, + display: 'flex', + alignItems: 'center', + gap: 8, + borderRadius: 6, + padding: '10px 12px', + cursor: 'pointer', + transition: 'background 0.15s', + background: isActive ? 'rgba(43, 222, 94, 0.12)' : 'transparent', + }} + > +
+ + {t(item.labelKey)} + +
+ {hasChildren && ( +
{ + e.stopPropagation(); + toggleExpand(item.id); + }} + style={{ display: 'flex', alignItems: 'center', padding: 2 }} + > + +
+ )}
- ))} -
- ))} + {/* Children (sub-items) */} + {hasChildren && isExpanded && item.children!.map((child) => { + const childActive = child.slug === activeSlug; + return ( +
navigateToDoc(child.slug)} + style={{ + height: 36, + display: 'flex', + alignItems: 'center', + gap: 8, + borderRadius: 6, + padding: '10px 12px 10px 28px', + cursor: 'pointer', + background: childActive ? 'rgba(43, 222, 94, 0.12)' : 'transparent', + }} + > +
+ + {t(child.labelKey)} + +
+
+ ); + })} + + ); + })}
+ ))} + + )} -

{t('docs.configVerify')}

- handleCopy('ocr llm test')} - /> -

{t('docs.configVerifyDesc')}

-
+ {/* ─── Main content area ─── */} +
+ {/* Doc title */} +

+ {docTitle} +

+ {/* Rendered markdown content */} + - {/* ─── ocr review ─── */} -
-

{t('docs.reviewTitle')}

-

{t('docs.reviewDesc').replace(/<\/?code>/g, '')}

- -

{t('docs.reviewModes')}

- {/* Workspace Mode */} -
-
- -
- {t('docs.reviewWorkspace')} -

{t('docs.reviewWorkspaceDesc')}

-
-
- handleCopy('ocr review')} /> -
- {/* Branch Diff Mode */} -
-
- -
- {t('docs.reviewBranch')} -

{t('docs.reviewBranchDesc')}

-
-
- handleCopy('ocr review --from master --to dev-ref')} /> -
- {/* Single Commit Mode */} -
-
- -
- {t('docs.reviewCommit')} -

{t('docs.reviewCommitDesc')}

-
-
- handleCopy('ocr review -c abc123')} /> -
- -

{t('docs.reviewAdvanced')}

- {/* Review with Requirement Context */} -
-
- -
- {t('docs.reviewBackground')} -

{t('docs.reviewBackgroundDesc')}

-
-
- handleCopy('ocr review --background "requirement context"')} /> -
- {/* JSON Output */} -
-
- -
- {t('docs.reviewJson')} -

{t('docs.reviewJsonDesc')}

-
-
- handleCopy('ocr review --format json')} /> -
- {/* Agent Mode */} -
-
- -
- {t('docs.reviewAgent')} -

{t('docs.reviewAgentDesc')}

-
-
- handleCopy('ocr review --audience agent')} /> -
- {/* Dry-Run Preview */} -
-
- -
- {t('docs.reviewPreviewLabel')} -

{t('docs.reviewPreviewDesc')}

-
-
- handleCopy('ocr review --preview')} /> -
- -

{t('docs.reviewFlags')}

- {/* Flags table */} -
- {/* Header */} -
-
{t('docs.reviewFlagCol1')}
-
{t('docs.reviewFlagCol2')}
-
{t('docs.reviewFlagCol3')}
-
- {/* Rows */} - {[ - ['-c, --commit', t('docs.reviewFlag1Desc'), '—'], - ['--from', t('docs.reviewFlag2Desc'), '—'], - ['--to', t('docs.reviewFlag3Desc'), '—'], - ['-f, --format', t('docs.reviewFlag4Desc'), 'text'], - ['--repo', t('docs.reviewFlag5Desc'), t('docs.reviewFlag5Default')], - ['--rule', t('docs.reviewFlag6Desc'), t('docs.reviewFlag6Default')], - ['--concurrency', t('docs.reviewFlag7Desc'), '8'], - ['--timeout', t('docs.reviewFlag8Desc'), '10'], - ['--audience', t('docs.reviewFlag9Desc'), 'human'], - ['--max-tools', t('docs.reviewFlag10Desc'), t('docs.reviewFlag10Default')], - ].map(([flag, d, def], idx, arr) => ( -
-
- {flag} -
-
- {d} -
-
- {def} -
-
- ))} -
-

- {t('docs.reviewNote').replace(/<\/?code>/g, '')} -

-
- - {/* ─── ocr scan ─── */} -
-

{t('docs.scanTitle')}

-

{t('docs.scanDesc').replace(/<\/?code>/g, '')}

- -

{t('docs.scanVsTitle')}

-
-
- -
- {t('docs.scanVsReviewLabel')} -

{t('docs.scanVsReview').replace(/<\/?code>/g, '')}

-
-
-
-
-
- -
- {t('docs.scanVsScanLabel')} -

{t('docs.scanVsScan').replace(/<\/?code>/g, '')}

-
-
-
- -

{t('docs.scanUsage')}

-
-
- -
- {t('docs.scanUsageWhole')} -

{t('docs.scanUsageWholeDesc')}

-
-
- handleCopy('ocr scan')} /> -
-
-
- -
- {t('docs.scanUsagePath')} -

{t('docs.scanUsagePathDesc')}

-
-
- handleCopy('ocr scan --path internal/agent')} /> -
-
-
- -
- {t('docs.scanUsageFile')} -

{t('docs.scanUsageFileDesc')}

-
-
- handleCopy('ocr scan --path internal/agent/agent.go,internal/diff/scan.go')} /> -
-
-
- -
- {t('docs.scanUsagePreviewLabel')} -

{t('docs.scanUsagePreviewDesc')}

-
-
- handleCopy('ocr scan --preview')} /> -
- -

{t('docs.scanBatching')}

-

{t('docs.scanBatchingDesc').replace(/<\/?code>/g, '')}

-
- {[ - [t('docs.scanBatchingNone'), t('docs.scanBatchingNoneDesc')], - [t('docs.scanBatchingLang'), t('docs.scanBatchingLangDesc')], - [t('docs.scanBatchingDir'), t('docs.scanBatchingDirDesc')], - ].map(([name, d]) => ( -
-

- {name} - {d} -

-
- ))} -
- handleCopy('ocr scan --batch by-directory')} /> - -

{t('docs.scanToggles')}

-

{t('docs.scanTogglesDesc')}

-
- {[ - ['--no-plan', t('docs.scanTogglesPlanDesc')], - ['--no-dedup', t('docs.scanTogglesDedupDesc')], - ['--no-summary', t('docs.scanTogglesSummaryDesc')], - ].map(([flag, d]) => ( -
- {flag} - {d} -
- ))} -
- handleCopy('ocr scan --no-plan --no-dedup --no-summary')} /> - -

{t('docs.scanBudget')}

-

{t('docs.scanBudgetDesc').replace(/<\/?code>/g, '')}

- handleCopy('ocr scan --max-tokens-budget 500000')} /> - -

{t('docs.scanFlags')}

-
-
-
{t('docs.scanFlagCol1')}
-
{t('docs.scanFlagCol2')}
-
{t('docs.scanFlagCol3')}
-
- {[ - ['--path', t('docs.scanFlag1Desc'), t('docs.scanFlag1Default')], - ['--exclude', t('docs.scanFlag2Desc'), '—'], - ['-p, --preview', t('docs.scanFlag3Desc'), 'false'], - ['--max-tokens-budget', t('docs.scanFlag4Desc'), '0'], - ['--no-plan', t('docs.scanFlag5Desc'), 'false'], - ['--no-dedup', t('docs.scanFlag6Desc'), 'false'], - ['--no-summary', t('docs.scanFlag7Desc'), 'false'], - ['--batch', t('docs.scanFlag8Desc'), 'by-language'], - ['-f, --format', t('docs.scanFlag9Desc'), 'text'], - ['--concurrency', t('docs.scanFlag10Desc'), '8'], - ['--timeout', t('docs.scanFlag11Desc'), '10'], - ['--audience', t('docs.scanFlag12Desc'), 'human'], - ['-b, --background', t('docs.scanFlag13Desc'), '—'], - ['--max-tools', t('docs.scanFlag14Desc'), t('docs.scanFlag14Default')], - ['--max-git-procs', t('docs.scanFlag15Desc'), '16'], - ['--rule', t('docs.scanFlag16Desc'), '—'], - ['--tools', t('docs.scanFlag17Desc'), t('docs.scanFlag17Default')], - ['--repo', t('docs.scanFlag18Desc'), t('docs.scanFlag18Default')], - ].map(([flag, d, def], idx, arr) => ( -
-
- {flag} -
-
- {d} -
-
- {def} -
-
- ))} -
-

- {t('docs.scanNote').replace(/<\/?code>/g, '')} -

-
- - {/* ─── Viewer ─── */} -
-

{t('docs.viewerTitle')}

-

{t('docs.viewerDesc')}

- handleCopy('ocr viewer')} /> -

{t('docs.viewerNote')}

-
- - {/* ─── MCP Server ─── */} -
-

{t('docs.mcpTitle')}

-

{t('docs.mcpDesc')}

- -

{t('docs.mcpConfig')}

- handleCopy(mcpAddCommands)} /> -

{t('docs.mcpConfigLocation')}

- -

{t('docs.mcpDelete')}

- handleCopy(mcpDeleteCommands)} /> - -

{t('docs.mcpFields')}

-
-
-
{t('docs.mcpFieldCol')}
-
{t('docs.mcpRequiredCol')}
-
{t('docs.mcpDescCol')}
-
- {[ - ['command', t('docs.mcpYes'), t('docs.mcpFieldCommandDesc')], - ['args', t('docs.mcpNo'), t('docs.mcpFieldArgsDesc')], - ['tools', t('docs.mcpNo'), t('docs.mcpFieldToolsDesc')], - ['setup', t('docs.mcpNo'), t('docs.mcpFieldSetupDesc')], - ['env', t('docs.mcpNo'), t('docs.mcpFieldEnvDesc')], - ].map(([field, required, d], idx, arr) => ( -
-
- {field} -
-
- {required} -
-
- {d} -
-
- ))} -
- -

- {t('docs.mcpNote')} -

- -

{t('docs.mcpExample')}

- handleCopy(mcpCodeGraphCommands)} /> -
- - {/* ─── Claude Code Integration ─── */} -
-

{t('docs.envTitle')}

-

- {t('docs.envDesc').replace(/<\/?code>/g, '')} -

- handleCopy('export ANTHROPIC_BASE_URL=https://api.anthropic.com\nexport ANTHROPIC_AUTH_TOKEN=sk-ant-xxxxx\nexport ANTHROPIC_MODEL=claude-opus-4-6')} - /> -

- {t('docs.envNote').replace(/<\/?code>/g, '')} -

-
+ {/* ─── Prev / Next pagination ─── */} +
+ {prevDoc ? ( + + ) : } + {nextDoc ? ( + + ) : }
- {/* ─── Right sidebar: CONTENTS (fixed) ─── */} - {!isMobile && ( -
+ {/* ─── Right sidebar: page TOC ─── */} + {!isMobile && headings.length > 0 && ( +
- - {t('docs.toc')} + + + {t('docs.toc')} +
- {sections.map((s) => ( - - ))} + {headings.map((h, i) => { + const isActive = h.id === activeHeadingId; + const isHovered = h.id === hoveredHeadingId; + return ( + + ); + })}
)}
- + + {/* Search Modal */} + {searchOpen && ( +
setSearchOpen(false)} + > +
e.stopPropagation()} + > + {/* Search input */} +
+ + setSearchQuery(e.target.value)} + onKeyDown={handleSearchKeyDown} + placeholder={t('docs.search.placeholder')} + style={{ + flex: 1, + marginLeft: 12, + background: 'transparent', + border: 'none', + outline: 'none', + color: '#ffffff', + fontSize: 14, + fontFamily, + }} + /> +
+ {/* Results */} +
+ {searchQuery && searchResults.length === 0 && ( +
+ {t('docs.search.noResults')} +
+ )} + {searchResults.map((result, idx) => ( + + ))} +
+ {/* Footer hints */} + {searchResults.length > 0 && ( +
+
+ + + + {t('docs.search.hint.select')} + + + + {t('docs.search.hint.open')} + +
+ + esc + {t('docs.search.hint.close')} + +
+ )} +
+
+ )}
); }; diff --git a/pages/src/styles/docs-markdown.css b/pages/src/styles/docs-markdown.css new file mode 100644 index 0000000..6a8968e --- /dev/null +++ b/pages/src/styles/docs-markdown.css @@ -0,0 +1,242 @@ +/* Docs Markdown dark theme styles — matching DocsPage design */ +.docs-markdown { + font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Segoe UI', Roboto, sans-serif; + font-size: 14px; + line-height: 24px; + color: rgba(255, 255, 255, 0.7); +} + +.docs-markdown h1 { + font-size: 28px; + font-weight: 700; + color: #FFFFFF; + margin: 0 0 24px 0; + line-height: 36px; +} + +.docs-markdown h2 { + font-size: 20px; + font-weight: 600; + color: #FFFFFF; + margin: 40px 0 16px 0; + line-height: 28px; +} + +.docs-markdown h3 { + font-size: 16px; + font-weight: 600; + color: #FFFFFF; + margin: 28px 0 12px 0; + line-height: 24px; +} + +.docs-markdown h4 { + font-size: 14px; + font-weight: 600; + color: rgba(255, 255, 255, 0.9); + margin: 20px 0 8px 0; + line-height: 22px; +} + +.docs-markdown p { + margin: 0 0 12px 0; + color: rgba(255, 255, 255, 0.6); + line-height: 24px; +} + +.docs-markdown a { + color: #2BDE5E; + text-decoration: none; + transition: opacity 0.2s; +} + +.docs-markdown a:hover { + opacity: 0.8; + text-decoration: underline; +} + +/* Inline code */ +.docs-markdown code { + font-family: 'Menlo', 'Monaco', 'Consolas', monospace; + font-size: 13px; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 4px; + padding: 2px 6px; + color: rgba(255, 255, 255, 0.85); +} + +/* Code blocks */ +.docs-markdown pre { + background: #000000; + border: 1px solid rgba(255, 255, 255, 0.16); + border-radius: 6px; + padding: 4px 16px; + margin: 0 0 16px 0; + overflow-x: auto; + display: flex; + align-self: stretch; + justify-content: space-between; + align-items: center; +} + +.docs-markdown pre code { + background: none; + border: none; + padding: 0; + font-size: 13px; + line-height: 22px; + color: rgba(255, 255, 255, 0.8); + white-space: pre; + display: block; + flex: 1; + min-width: 0; +} + +/* Lists */ +.docs-markdown ul, +.docs-markdown ol { + margin: 0 0 16px 0; + padding-left: 24px; +} + +.docs-markdown li { + margin-bottom: 6px; + color: rgba(255, 255, 255, 0.6); + line-height: 24px; +} + +.docs-markdown li > ul, +.docs-markdown li > ol { + margin-top: 6px; + margin-bottom: 0; +} + +/* Tables */ +.docs-markdown table { + width: 100%; + border-collapse: separate; + border-spacing: 0; + margin: 0 0 16px 0; + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.16); +} + +.docs-markdown thead th { + background: rgba(255, 255, 255, 0.04); + font-size: 13px; + font-weight: 500; + color: rgba(255, 255, 255, 0.6); + text-align: left; + padding: 12px 16px; + border-bottom: 1px solid rgba(255, 255, 255, 0.16); +} + +.docs-markdown thead th:first-child { + border-top-left-radius: 8px; +} + +.docs-markdown thead th:last-child { + border-top-right-radius: 8px; +} + +.docs-markdown tbody td { + font-size: 13px; + color: rgba(255, 255, 255, 0.6); + padding: 12px 16px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + vertical-align: top; +} + +.docs-markdown tbody tr:last-child td { + border-bottom: none; +} + +.docs-markdown tbody tr:last-child td:first-child { + border-bottom-left-radius: 8px; +} + +.docs-markdown tbody tr:last-child td:last-child { + border-bottom-right-radius: 8px; +} + +/* Blockquotes */ +.docs-markdown blockquote { + margin: 0 0 16px 0; + padding: 12px 16px; + border-left: 2px solid #2BDE5E; + background: rgba(255, 255, 255, 0.06); + border-radius: 0 6px 6px 0; +} + +.docs-markdown blockquote p { + margin: 0; + color: rgba(255, 255, 255, 0.6); + font-size: 13px; +} + +.docs-markdown blockquote p + p { + margin-top: 8px; +} + +/* Horizontal rule */ +.docs-markdown hr { + border: none; + border-top: 1px solid rgba(255, 255, 255, 0.1); + margin: 32px 0; +} + +/* Strong / bold */ +.docs-markdown strong { + color: rgba(255, 255, 255, 0.9); + font-weight: 600; +} + +/* Em / italic */ +.docs-markdown em { + font-style: italic; + color: rgba(255, 255, 255, 0.7); +} + +/* Images */ +.docs-markdown img { + max-width: 100%; + border-radius: 8px; + margin: 8px 0; +} + +/* Mermaid code blocks — hide raw source (will be replaced by rendered SVG) */ +.docs-markdown pre code.language-mermaid { + display: none; +} + +/* Rendered mermaid diagrams */ +.docs-markdown .mermaid-rendered { + margin: 16px 0; + padding: 24px 16px; + background: rgba(255, 255, 255, 0.02); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + overflow-x: auto; + display: flex; + justify-content: center; +} + +.docs-markdown .mermaid-rendered svg { + max-width: 100%; + height: auto; +} + +/* Scrollbar styling for code blocks */ +.docs-markdown pre::-webkit-scrollbar { + height: 6px; +} + +.docs-markdown pre::-webkit-scrollbar-track { + background: transparent; +} + +.docs-markdown pre::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.15); + border-radius: 3px; +} diff --git a/pages/src/utils/headingId.ts b/pages/src/utils/headingId.ts new file mode 100644 index 0000000..0a87854 --- /dev/null +++ b/pages/src/utils/headingId.ts @@ -0,0 +1,10 @@ +/** + * Shared utility to generate heading IDs from text. + * Used by both extractHeadings (DocsPage TOC) and MarkdownRenderer (heading renderer) + * to ensure consistent anchor IDs. + */ +export function generateHeadingId(text: string): string { + // Strip HTML tags first (from marked output), then strip markdown formatting chars + const plain = text.replace(/<[^>]+>/g, '').replace(/[`*_\[\]()]/g, '').trim(); + return plain.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fff]+/g, '-').replace(/^-|-$/g, ''); +} diff --git a/pages/webpack.config.js b/pages/webpack.config.js index 55816da..2228fb2 100644 --- a/pages/webpack.config.js +++ b/pages/webpack.config.js @@ -36,6 +36,10 @@ module.exports = { { test: /\.(png|jpg|jpeg|gif)$/, type: 'asset/resource' + }, + { + test: /\.md$/, + type: 'asset/source' } ] }, From 64398465d42dae5824530a51a302182ec0d330d6 Mon Sep 17 00:00:00 2001 From: ScarletCarpet <46567025+ScarletCarpet@users.noreply.github.com> Date: Fri, 3 Jul 2026 12:01:46 +0800 Subject: [PATCH 51/87] feat(viewer): make human readable token usage for session (#278) * < 1,000: show the raw number (e.g. 842) * >= 1,000: show as K (e.g. 1.22K) * >= 1,000,000: show as M (e.g. 1.21M) hover shows exact number. --- internal/viewer/server.go | 47 ++++++++++++++++++++++++++ internal/viewer/server_test.go | 36 ++++++++++++++++++++ internal/viewer/templates/session.html | 22 ++++++------ 3 files changed, 94 insertions(+), 11 deletions(-) diff --git a/internal/viewer/server.go b/internal/viewer/server.go index 3803860..3452e52 100644 --- a/internal/viewer/server.go +++ b/internal/viewer/server.go @@ -6,6 +6,7 @@ import ( "html/template" "io/fs" "net/http" + "strconv" "strings" "time" ) @@ -79,6 +80,7 @@ func parseTemplate(name string) (*template.Template, error) { "formatDuration": formatDuration, "formatTime": formatTime, "truncate": truncateText, + "formatNumber": formatNumber, "add": func(a, b int) int { return a + b }, "cardCount": func(tasks map[TaskType][]*TaskCard) int { n := 0 @@ -164,6 +166,51 @@ func staticFS() fs.FS { return sub } +func formatNumber(n int) string { + var display string + switch { + case n >= 1_000_000: + if n%1_000_000 == 0 { + display = fmt.Sprintf("%dM", n/1_000_000) + } else { + display = trimFloatSuffix(fmt.Sprintf("%.2fM", float64(n)/1_000_000)) + } + case n >= 1_000: + if n%1_000 == 0 { + display = fmt.Sprintf("%dK", n/1_000) + } else { + display = trimFloatSuffix(fmt.Sprintf("%.2fK", float64(n)/1_000)) + } + default: + display = strconv.Itoa(n) + } + return display +} + +// trimFloatSuffix removes trailing zeros and the trailing dot from a +// floating-point string like "1.10K" → "1.1K", "1.00K" → "1K". +func trimFloatSuffix(s string) string { + // Find the dot position before the suffix (K/M). + // Input is always "%d.%dX" or "%dX". + dot := strings.LastIndexByte(s, '.') + if dot < 0 { + return s + } + // Find the suffix letter (K or M) — it's always the last character. + suffix := s[len(s)-1] + mantissa := s[:len(s)-1] // strip suffix + + // Trim trailing zeros from the fractional part. + i := len(mantissa) - 1 + for i >= 0 && mantissa[i] == '0' { + i-- + } + if i >= 0 && mantissa[i] == '.' { + i-- // also trim the dot if whole fractional part was zeros + } + return mantissa[:i+1] + string(suffix) +} + func formatDuration(seconds float64) string { d := time.Duration(seconds * float64(time.Second)) if d < time.Minute { diff --git a/internal/viewer/server_test.go b/internal/viewer/server_test.go index 2c3a833..a2691fa 100644 --- a/internal/viewer/server_test.go +++ b/internal/viewer/server_test.go @@ -30,6 +30,42 @@ func TestTruncateText(t *testing.T) { } } +func TestFormatNumber(t *testing.T) { + tests := []struct { + name string + n int + want string + }{ + {"zero", 0, "0"}, + {"below 1K", 999, "999"}, + {"exactly 1K", 1000, "1K"}, + {"just above 1K rounds to 1K", 1001, "1K"}, + {"1.1K", 1100, "1.1K"}, + {"1.23K", 1234, "1.23K"}, + {"9.99K", 9995, "9.99K"}, + {"exactly 10K", 10000, "10K"}, + {"exactly 100K", 100000, "100K"}, + {"just above 100K rounds to 100K", 100001, "100K"}, + {"999.5K", 999500, "999.5K"}, + {"999999 just below 1M", 999999, "1000K"}, + {"exactly 1M", 1000000, "1M"}, + {"just above 1M rounds to 1M", 1000001, "1M"}, + {"1.1M", 1100000, "1.1M"}, + {"1.23M", 1234567, "1.23M"}, + {"1.5M", 1500000, "1.5M"}, + {"exactly 10M", 10000000, "10M"}, + {"12.35M", 12345678, "12.35M"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := formatNumber(tt.n) + if got != tt.want { + t.Errorf("formatNumber(%d) = %q, want %q", tt.n, got, tt.want) + } + }) + } +} + func TestFormatDuration(t *testing.T) { tests := []struct { name string diff --git a/internal/viewer/templates/session.html b/internal/viewer/templates/session.html index e2362d5..4735e11 100644 --- a/internal/viewer/templates/session.html +++ b/internal/viewer/templates/session.html @@ -32,28 +32,28 @@

Token Usage

-
{{.Session.TokenUsage.TotalPromptTokens}}
+
{{formatNumber .Session.TokenUsage.TotalPromptTokens}}
Prompt Tokens
-
{{.Session.TokenUsage.TotalCompletionTokens}}
+
{{formatNumber .Session.TokenUsage.TotalCompletionTokens}}
Completion Tokens
-
{{add .Session.TokenUsage.TotalPromptTokens .Session.TokenUsage.TotalCompletionTokens}}
+
{{formatNumber (add .Session.TokenUsage.TotalPromptTokens .Session.TokenUsage.TotalCompletionTokens)}}
Total Tokens
-
{{.Session.TokenUsage.RequestCount}}
+
{{formatNumber .Session.TokenUsage.RequestCount}}
LLM Requests
{{if or .Session.TokenUsage.TotalCacheReadTokens .Session.TokenUsage.TotalCacheWriteTokens}}
-
{{.Session.TokenUsage.TotalCacheReadTokens}}
+
{{formatNumber .Session.TokenUsage.TotalCacheReadTokens}}
Cache Read
-
{{.Session.TokenUsage.TotalCacheWriteTokens}}
+
{{formatNumber .Session.TokenUsage.TotalCacheWriteTokens}}
Cache Write
{{end}} @@ -71,10 +71,10 @@ {{range .}} {{.FilePath | truncate 60}} - {{.PromptTokens}} - {{.CompletionTokens}} - {{if or $.Session.TokenUsage.TotalCacheReadTokens $.Session.TokenUsage.TotalCacheWriteTokens}}{{.CacheReadTokens}}{{.CacheWriteTokens}}{{end}} - {{add .PromptTokens .CompletionTokens}} + {{formatNumber .PromptTokens}} + {{formatNumber .CompletionTokens}} + {{if or $.Session.TokenUsage.TotalCacheReadTokens $.Session.TokenUsage.TotalCacheWriteTokens}}{{formatNumber .CacheReadTokens}}{{formatNumber .CacheWriteTokens}}{{end}} + {{formatNumber (add .PromptTokens .CompletionTokens)}} {{end}} @@ -112,7 +112,7 @@
Request #{{.RequestNo}} {{if .Model}}{{.Model}}{{end}} - {{if or .PromptTokens .CompletionTokens}}P:{{.PromptTokens}} C:{{.CompletionTokens}}{{if or .CacheReadTokens .CacheWriteTokens}} CR:{{.CacheReadTokens}} CW:{{.CacheWriteTokens}}{{end}}{{end}} + {{if or .PromptTokens .CompletionTokens}}P:{{formatNumber .PromptTokens}} C:{{formatNumber .CompletionTokens}}{{if or .CacheReadTokens .CacheWriteTokens}} CR:{{formatNumber .CacheReadTokens}} CW:{{formatNumber .CacheWriteTokens}}{{end}}{{end}} {{if .DurationMs}}{{.DurationMs}}ms{{end}} {{if .Error}}{{.Error}}{{end}}
From db254dd9c82eab2df8239336330745be4a73a9c1 Mon Sep 17 00:00:00 2001 From: kite Date: Fri, 3 Jul 2026 14:23:35 +0800 Subject: [PATCH 52/87] docs(pages): add Japanese (ja) translation for docs content (#282) Translate all 17 docs pages from zh to ja under content/docs/ja/, translate frontmatter titles, and wire ja into content/docs/index.ts (replacing the previous English fallback). Sidebar i18n keys already existed in i18n/ja.ts, so navigation renders Japanese automatically. --- pages/src/content/docs/index.ts | 41 +- pages/src/content/docs/ja/architecture.md | 258 ++++++++++++ pages/src/content/docs/ja/cli-reference.md | 350 +++++++++++++++++ pages/src/content/docs/ja/configuration.md | 271 +++++++++++++ pages/src/content/docs/ja/contributing.md | 205 ++++++++++ pages/src/content/docs/ja/faq.md | 304 +++++++++++++++ pages/src/content/docs/ja/installation.md | 177 +++++++++ pages/src/content/docs/ja/integrations.md | 60 +++ .../docs/ja/integrations/agent-skill.md | 85 ++++ pages/src/content/docs/ja/integrations/ci.md | 366 ++++++++++++++++++ .../docs/ja/integrations/claude-code.md | 87 +++++ .../docs/ja/integrations/subprocess.md | 155 ++++++++ pages/src/content/docs/ja/overview.md | 126 ++++++ pages/src/content/docs/ja/quickstart.md | 235 +++++++++++ pages/src/content/docs/ja/review-rules.md | 217 +++++++++++ pages/src/content/docs/ja/telemetry.md | 258 ++++++++++++ pages/src/content/docs/ja/tools.md | 353 +++++++++++++++++ pages/src/content/docs/ja/viewer.md | 151 ++++++++ 18 files changed, 3698 insertions(+), 1 deletion(-) create mode 100644 pages/src/content/docs/ja/architecture.md create mode 100644 pages/src/content/docs/ja/cli-reference.md create mode 100644 pages/src/content/docs/ja/configuration.md create mode 100644 pages/src/content/docs/ja/contributing.md create mode 100644 pages/src/content/docs/ja/faq.md create mode 100644 pages/src/content/docs/ja/installation.md create mode 100644 pages/src/content/docs/ja/integrations.md create mode 100644 pages/src/content/docs/ja/integrations/agent-skill.md create mode 100644 pages/src/content/docs/ja/integrations/ci.md create mode 100644 pages/src/content/docs/ja/integrations/claude-code.md create mode 100644 pages/src/content/docs/ja/integrations/subprocess.md create mode 100644 pages/src/content/docs/ja/overview.md create mode 100644 pages/src/content/docs/ja/quickstart.md create mode 100644 pages/src/content/docs/ja/review-rules.md create mode 100644 pages/src/content/docs/ja/telemetry.md create mode 100644 pages/src/content/docs/ja/tools.md create mode 100644 pages/src/content/docs/ja/viewer.md diff --git a/pages/src/content/docs/index.ts b/pages/src/content/docs/index.ts index b4e6e38..2ea81bd 100644 --- a/pages/src/content/docs/index.ts +++ b/pages/src/content/docs/index.ts @@ -38,6 +38,25 @@ import zhCicd from './zh/integrations/ci.md'; import zhContributing from './zh/contributing.md'; import zhFaq from './zh/faq.md'; +// Japanese docs +import jaOverview from './ja/overview.md'; +import jaQuickstart from './ja/quickstart.md'; +import jaInstallation from './ja/installation.md'; +import jaConfiguration from './ja/configuration.md'; +import jaCliReference from './ja/cli-reference.md'; +import jaReviewRules from './ja/review-rules.md'; +import jaArchitecture from './ja/architecture.md'; +import jaTools from './ja/tools.md'; +import jaViewer from './ja/viewer.md'; +import jaTelemetry from './ja/telemetry.md'; +import jaIntegrations from './ja/integrations.md'; +import jaAgentSkill from './ja/integrations/agent-skill.md'; +import jaClaudeCode from './ja/integrations/claude-code.md'; +import jaSubprocess from './ja/integrations/subprocess.md'; +import jaCicd from './ja/integrations/ci.md'; +import jaContributing from './ja/contributing.md'; +import jaFaq from './ja/faq.md'; + export type DocSlug = | 'overview' | 'quickstart' @@ -97,10 +116,30 @@ const zhDocs: Record = { 'faq': zhFaq, }; +const jaDocs: Record = { + 'overview': jaOverview, + 'quickstart': jaQuickstart, + 'installation': jaInstallation, + 'configuration': jaConfiguration, + 'cli-reference': jaCliReference, + 'review-rules': jaReviewRules, + 'architecture': jaArchitecture, + 'tools': jaTools, + 'viewer': jaViewer, + 'telemetry': jaTelemetry, + 'integrations': jaIntegrations, + 'agent-skill': jaAgentSkill, + 'claude-code': jaClaudeCode, + 'subprocess': jaSubprocess, + 'cicd': jaCicd, + 'contributing': jaContributing, + 'faq': jaFaq, +}; + const docsMap: Record> = { en: enDocs, zh: zhDocs, - ja: enDocs, // fallback to English for Japanese + ja: jaDocs, }; /** diff --git a/pages/src/content/docs/ja/architecture.md b/pages/src/content/docs/ja/architecture.md new file mode 100644 index 0000000..86f31e5 --- /dev/null +++ b/pages/src/content/docs/ja/architecture.md @@ -0,0 +1,258 @@ +--- +title: アーキテクチャ +sidebar: + order: 8 +--- + +あなたが Enter キーを押してから JSON がターミナルに出力されるまで、`ocr review` が内部で実際にどう動くかのガイドです。挙動をデバッグし、引数をチューニングし、自信を持ってソースコードを読めるだけの十分なメンタルモデルを構築することを目的としています。 + +## 高レベルのパイプライン + +```mermaid +flowchart TD + A["ocr review"] + B["bootstrap
Resolve LLM endpoint (config → env → rc files)
Load template, tool registry, system rules
"] + C["diff provider
git diff / ls-files / show — produce []model.Diff
Modes: Workspace · Commit · Range
"] + D["filter & rules
5-gate filter (preview.go) — drop binaries,
excluded paths, unsupported extensions. Pick rule per file.
"] + E["subtask dispatch
For every diff in parallel (concurrency=N):
Plan phase (optional) → Main loop → Comments
"] + F["output writer
Synchronous line-resolution & review-filter; renders text
or JSON depending on --format / --audience.
"] + + A --> B --> C --> D --> E --> F +``` + +オーケストレーションのロジックは [`internal/agent/`](https://github.com/alibaba/open-code-review/blob/main/internal/agent/) パッケージにあり、4 つのファイルに分かれています: `agent.go`(メインループとディスパッチ)、`compression.go`(メモリ圧縮)、`preview.go`(ファイルフィルタリング)、`util.go`(ヘルパー)。注目すべきエントリポイントは 2 つです: `Agent.Run`(パイプラインの最上部)と `Agent.dispatchSubtasks`(ファイルごとのファンアウト)。 + +## diff provider + +`internal/diff/git.go` は `Provider` 構造体を定義しており、その未エクスポートのフィールド `mode`(型は `Mode`、`int` 列挙体)が、CLI 引数に対応する 3 つのモードのいずれかを選択します: + +| モード | トリガー方法 | 返す内容 | +|---|---|---| +| `Workspace` | 引数なし | staged + unstaged + untracked の変更 | +| `Commit` | `--commit ` / `-c ` | `` が導入した変更(`git show ` 経由。`^..` の diff に相当) | +| `Range` | `--from
--to ` | `merge-base(a, b)..b` | + +各 diff は次を保持します: old/new path、old/new hunk、挿入/削除カウント、バイナリフラグ、リネーム検出。`DiffContextLines` は **3** に固定されており、Git のデフォルトと一致します。 + +untracked ファイルはディスクから読み込まれ、ファイル全体の新規追加として扱われるため、commit 前にレビューできます。 + +## 5 段階ゲートのファイルフィルタリング + +diff の読み込み後、各ファイルは [`whyExcluded`](https://github.com/alibaba/open-code-review/blob/main/internal/agent/preview.go) を通過します。この関数は次のいずれかを返します: + +``` +binary — file is binary +user_exclude — matched a pattern in your `exclude` list +unsupported_ext — extension is not in supported_file_types.json +default_path — matched a built-in test-file exclude pattern +``` + +……またはファイルが保持される場合は空を返します。`deleted` は `whyExcluded` からは**返されません**。これは `Preview()` の中でそのあと計算されます。保持されたファイルの diff が `IsDeleted` を報告したときです。各ゲートは以下の順序で実行されます: + +1. `binary`: バイナリファイルが最初に破棄されます。 +2. `user_exclude`: あなたのプロジェクトの `exclude` が常に優先されます。 +3. `user_include`: include パターンが設定されており**かつ**ファイルがそのいずれかに一致する場合、即座に保持され(空を返す)、下記の `unsupported_ext` と `default_path` のゲートをバイパスします。 +4. `unsupported_ext` は拡張子のホワイトリストでフィルタリングします。 +5. `default_path` は最後のゲートです: 組み込みの**テストファイル**除外パターン(`**/*_test.go`、`**/*.test.{js,jsx,ts,tsx}`、`**/__tests__/**`、`**/*_test.py`、`**/*_spec.rb`、`**/*.test.ets`……)に一致します。各パターンはルートプレフィックスとして `**/` を付けます。 + +ノイズディレクトリのフィルタリング(`vendor/`、`node_modules/`、`target/`……)は、より早い段階、diff-provider 層で、`internal/diff/git.go` の `providerDirIgnoreDirs` リストを通じて発生します。これらのディレクトリの diff は解析されたあと `filterDiffs` によって除去され、ファイルごとのフィルターに到達することは決してありません。 + +`ocr review --preview` を実行すると、token を消費せずに完全なフィルタリング結果を確認できます。完全なアルゴリズムは[レビュールール](../review-rules/#how-files-are-filtered)を参照してください。 + +## ファイルごとのサブタスク: plan + main + +フィルタリングを通過した各ファイルについて、OCR はサブエージェントを起動します。各サブエージェントは自身の goroutine 内で実行され、`--concurrency`(デフォルト **8**)によって制限され、独立した LLM メッセージバッファを持ちます。 + +1 つのサブタスクは最大**2 つの段階**を持ちます: + +### 段階 1: Plan(任意) + +```go +threshold := template.PlanModeLineThreshold // 50 +changeLines := d.Insertions + d.Deletions +if changeLines < threshold { skip plan } +``` + +小さな diff に対しては、plan はレイテンシを増やすだけで価値がないため、静かにスキップされ、main ループが直接実行されます。より大きな diff に対しては、OCR は**1 回だけ** `PLAN_TASK` の LLM 呼び出しを行います。`Tools` フィールドを送らないため、plan の間モデルはツールを呼び出せません。読み取り専用ツールのサブセット(`code_search`、`file_read_diff`、`file_find`。`tools.json` で `plan_task` フラグが `true` の 3 つ)が、`{{plan_tools}}` プレースホルダー(`formatToolDefs` でレンダリング)を通じてプレーンテキストとして埋め込まれ、あとで何が使えるかをモデルに知らせます。モデルはチェックリストを返し、それが main prompt 内の `{{plan_guidance}}` になります。 + +### 段階 2: main ループ + +main ループは `MAIN_TASK` prompt を組み立て、モデルとツール呼び出しの対話を展開します。完全なツールセットは、plan 段階のツールに **`task_done`**、**`code_comment`**、**`file_read`** を加えたものです。完全な一覧は[ツール](../tools/)を参照してください。 + +``` +loop up to MAX_TOOL_REQUEST_TIMES (default 30): + response = llm.complete(messages, tools) + if response.toolCalls is empty: + nudge model with "You did not successfully call any tools. + Please try again or use task_done if finished." + continue + for each call: execute → collect result + if any call was task_done: break + addNextMessage(...) # may trigger compression +``` + +ループには 5 つの終了条件があります: + +1. `task_done` が呼び出された。 +2. `MAX_TOOL_REQUEST_TIMES` を使い切った。 +3. 有効なツール結果が 3 ラウンド連続で生成されなかった(`maxConsecutiveEmptyRounds = 3`)。 +4. context がキャンセルされた。 +5. `addNextMessage` が false を返した。圧縮してもメッセージバッファを警告しきい値以下に戻せなかった場合です。 + +いずれの場合でも、収集済みの `code_comment` 呼び出しがレビューコメントになります。 + +## メモリ圧縮 + +長いツール呼び出しループは、最終的にコンテキストウィンドウをあふれさせます。OCR は**3 分割**戦略で管理し、`MAX_TOKENS = 58888` で定義される token 予算でトリガーされます: + +| しきい値 | 定数 | 動作 | +|---|---|---| +| MAX_TOKENS の 60% | `tokenSoftThreshold` | **非同期**のバックグラウンド圧縮を起動します。現在のループは中断せず継続します。 | +| MAX_TOKENS の 80% | `tokenWarningThreshold` | 次のリクエストを送る前に**同期的**に圧縮を実行します。 | + +### 3 つのゾーン + +```mermaid +flowchart LR + subgraph messages["messages"] + direction LR + F["frozen
first 2 msgs
(system +
initial user)"] + C["compress
summarized
into one
user msg"] + A["active
K most recent
complete
rounds"] + end + F --- C --- A +``` + +1「ラウンド」とは、1 つの assistant メッセージと、それに続くツール結果メッセージのことです。`partitionMessages` は末尾から前へラウンドを辿り、`(0.80 × MAX_TOKENS) - reservedTokens` に収まる限り多くのラウンドを保持します。それより前の内容が **compress ゾーン**になります。 + +compress ゾーンは XML としてレンダリングされ、`MEMORY_COMPRESSION_TASK` prompt でモデルに渡されます。返されたサマリーは、`` タグで包まれて元の user メッセージ内に追記されます。 + +圧縮後: `messages = frozen[2] + compressed_user_msg + active`。 + +```go +// compression.go +func (a *Agent) runCompression(ctx context.Context, msgs []llm.Message, filePath string) ([]llm.Message, error) { + part := partitionMessages(msgs, a.args.Template.MaxTokens, 0) + contextXML := buildMessageXML(msgs[part.frozenEnd:part.compressEnd]) + // … MEMORY_COMPRESSION_TASK を呼び出す … + rebuilt[1] = llm.NewTextMessage(role, currentText+ + "\n\n\n"+rawSummary+"\n") + for i := part.compressEnd; i < len(msgs); i++ { + rebuilt = append(rebuilt, msgs[i]) + } + return rebuilt, nil +} +``` + +### 非同期 vs 同期 + +非同期パスでは、バックグラウンドで圧縮が実行されている間も main ループがツール呼び出しを生成し続けられます。次の token チェックが発生したとき、準備できたサマリーが `tryApplyPendingCompression` を通じて適用されます。非同期タスクが完了する前に比率が警告しきい値を越えた場合、ループは一時停止して `runCompression` を同期的に実行します。これにより次のリクエストが必ず収まることが保証されます。 + +## コメント処理パイプライン + +各 `code_comment` ツール呼び出しは 1 つ以上の生のコメントを生成します。それらは**CommentWorkerPool**(固定サイズの goroutine プール)を通過し、メインのツール呼び出しループが後処理でブロックされることを防ぎます: + +1. **行解決**(worker 内): `existing_code` をスライディングウィンドウアルゴリズムで diff と照合し、正確な `start_line` / `end_line` を計算します。照合に失敗した場合は両方ともデフォルトで `0` になります。行範囲 `0` は「アンカーされていない」コメントの暗黙のシグナルであり、ユーザーが手動で位置を特定する必要があります(格納されるフラグはありません。下流のコンシューマーが `start_line == 0` をチェックします)。 +2. **再配置タスク** *(任意のフォールバック)*: 行解決がより複雑な diff で失敗したとき、OCR は `RE_LOCATION_TASK` prompt を実行し、モデルにスニペットの再アンカーを依頼します。書き換えられた `existing_code` 文字列に有用です。 +3. **レビューフィルター**: main ループの終了後(worker プールが空になったあと)、`REVIEW_FILTER_TASK` の LLM 呼び出しが、収集されたコメントを diff と照合してチェックし、明らかに誤りと証明できるコメントを削除します。ここでのエラーは記録されたうえで無視されます。 +4. **2 回目の行解決**: `Agent.Run` が戻ったあと、トップレベルのコマンドが完全なコメントセットに対して `diff.ResolveLineNumbers` を再実行します(`cmd/opencodereview/review_cmd.go` を参照)。`existing_code` が複数ファイルにまたがる、あるいは再配置ステップで更新されたコメントを捕捉するためです。 +5. **レンダリング**: `--format` に従って text または JSON にレンダリングします。 + +## token 予算ガード + +LLM を呼び出す前に、OCR はまず fail-fast のチェックを行います: + +```go +tokenLimit := MaxTokens * 4 / 5 // 80 % +if countMessagesTokens(messages) > tokenLimit { + record warning "token_threshold_exceeded" + return nil // skip this file +} +``` + +これにより、巨大な diff(自動生成された lock ファイル、数千行に触れるリファクタリング)がリクエストを消費する前にそれらを食い止めます。スキップされたファイルは致命的でない警告として stdout に報告され、JSON の `warnings` 配列に追加されます。 + +2 つ目のチェックは `filterLargeDiffs` の中で実行されます: diff が単独で `MAX_TOKENS` の 80% を超える場合、ファイルごとのディスパッチャーが起動する前にフィルタリングで除去されます。 + +## テンプレートとプレースホルダー + +`internal/config/template/task_template.json` には**5 つの prompt** が含まれます: + +| Key | 用途 | +|---|---| +| `PLAN_TASK` | plan 段階。チェックリストを生成します。 | +| `MAIN_TASK` | main レビューループ。`code_comment` 呼び出しを発行します。 | +| `MEMORY_COMPRESSION_TASK` | compress ゾーンを要約します。 | +| `REVIEW_FILTER_TASK` | ループ後に、明らかに誤りと証明できるコメントを削除する処理。 | +| `RE_LOCATION_TASK` | `existing_code` を照合できないコメントを再アンカーします。 | + +各 prompt は `{role, prompt_file}` 参照のリストで、テンプレートディレクトリ内の `.md` ファイルを指します(例: `{"role": "system", "prompt_file": "main_task_system.md"}`)。読み込み時に `resolveConversation` がこれらのファイルをメモリ内の `{role, content}` メッセージに読み込み、その後テンプレートのプレースホルダーがファイルごとに解決されます: + +| プレースホルダー | 置換される内容 | +|---|---| +| `{{system_rule}}` | 4 層チェーンから解決されたルール本文。 | +| `{{change_files}}` | PR 内の他の各変更ファイルの状態 + パス。 | +| `{{diff}}` | このファイルの diff(生の `git diff` 出力)。 | +| `{{current_file_path}}` | このファイルの新しいパス。 | +| `{{plan_guidance}}` | plan 段階の出力。plan がスキップされた場合は削除されます。 | +| `{{plan_tools}}` | plan 段階のツール定義のプレーンテキスト(`formatToolDefs` でレンダリング)。`PLAN_TASK` の system prompt に使用されます。 | +| `{{requirement_background}}` | `--background` 引数の内容。 | +| `{{current_system_date_time}}` | 実行時のローカルタイムスタンプ。形式は `YYYY-MM-DD HH:MM`(秒やタイムゾーンなし)。 | +| `{{context}}` | (圧縮時のみ)要約対象の XML レンダリング済みメッセージ。 | +| `{{path}}` | ファイルパス。`REVIEW_FILTER_TASK` に使用されます。 | +| `{{comments}}` | 蓄積されたコメント(JSON)。`REVIEW_FILTER_TASK` に使用されます。 | + +プレースホルダーの置換は [`agent.go`](https://github.com/alibaba/open-code-review/blob/main/internal/agent/agent.go) にあります。テンプレート自体は CLI では上書きできません。prompt を変更するには、[`task_template.json`](https://github.com/alibaba/open-code-review/blob/main/internal/config/template/task_template.json) を編集して再ビルドする必要があります。`--tools` 引数は*ツールレジストリ*の上書きです(`internal/config/toolsconfig` が消費する JSON を置き換えます)。テンプレートの上書きではありません。[ツール](../tools/#customizing-tools)を参照してください。 + +> **プレースホルダー構文についての注意。** 上記のプレースホルダーはすべて二重波括弧 `{{…}}` 構文を使用します。*ただし* `RE_LOCATION_TASK` は例外で、単一波括弧の `{diff}`、`{existing_code}`、`{suggestion_content}` を置換します(`internal/diff/relocation.go` を参照)。 + +## 永続化 + +各レビューは JSONL としてディスクに書き込まれます: + +``` +~/.opencodereview/sessions//.jsonl +``` + +リポジトリパスは base64 エンコード**されません**。`encodeRepoPath`(`internal/session/persist.go` 内)が `/` と `\` を `-` に、`:` を `_` に置換し、パスをファイルシステムで安全にします。 + +各行は 1 つのイベントです: 送信された prompt、LLM レスポンス、ツール呼び出し、ツール結果、発行されたコメントなど。Web UI(`ocr viewer`)はこれらのファイルを直接読みます。データベースはなく、append-only のログだけです。UI ガイドとイベント schema は[セッションビューア](../viewer/)を参照してください。 + +## テレメトリ + +テレメトリを有効にすると、agent は 3 つのパイプラインレベルの span を発行します(`review.run` はジョブ全体を包み、`diff.parse` は diff の読み込みを包み、レビューされた各ファイルにつき 1 つの `subtask.execute.`)。加えて、各決定ポイントで短命な `event.` span を発行します(`plan.skipped`、`token.threshold.exceeded`、`subtask.error`……)。LLM の往復とツール呼び出しは metrics としてのみ記録され、span としては記録されません。prompt とレスポンスの内容がテレメトリに添付されることは**決してありません**。`OCR_CONTENT_LOGGING` フラグは配線済みですが、現在はデッドコードです。完全な schema は[テレメトリ](../telemetry/)を参照してください。 + +## *自動化されない*もの + +一部の決定は意図的に手動のままにされています: + +- **エンドポイント発見にフォールバックはありません。** config + env + rc ファイルが完全な `(URL, token, model)` の三つ組を与えられない場合、OCR は推測するのではなく非ゼロコードで終了します。 +- **サブエージェントの失敗は隔離され、リトライされません。** 1 つの失敗したファイルは 1 つの警告を生成し、残りは継続します。リトライは、それを包む CI パイプラインの責務であり、agent の責務ではありません。 +- **クロスファイルの推論はありません。** 各ファイルはそれ自身の LLM 対話でレビューされます。ファイルをまたぐ問題は `file_read_diff` / `code_search` ツール呼び出しを通じて扱われ、共有コンテキストは使いません。それら*他の*ファイルで見つかった問題をコメント対象にすることも禁止されています。`main_task` prompt は、コンテキストツールを理解のためだけに使い、現在の diff の外にあるファイルで見つかった問題は無視するようモデルに指示します。 + +これらの選択により、実行は**ファイルごとに決定的**になり、コストが予測可能になります。 + +## ソースコードマップ + +対照しながら読みたい場合: + +| 関心事 | ファイル | +|---|---| +| トップレベルのコマンドディスパッチ | `cmd/opencodereview/main.go` | +| `review` の引数解析 | `cmd/opencodereview/flags.go` | +| agent のオーケストレーションと圧縮 | `internal/agent/`(agent.go、compression.go、util.go) | +| ファイルフィルタリング / プレビュー | `internal/agent/preview.go` | +| diff の読み込み(Git モード) | `internal/diff/git.go` | +| ルール解決チェーン | `internal/config/rules/system_rules.go` | +| ツールレジストリと実装 | `internal/tool/` | +| LLM エンドポイントリゾルバ | `internal/llm/resolver.go` | +| セッション JSONL ライター | `internal/session/persist.go` | +| Web ビューア | `internal/viewer/server.go` | + +ビルドとテストの説明は[コントリビュート](../contributing/)を参照してください。 + +## 関連項目 + +- [ツール](../tools/): agent ループが呼び出す 6 種類のツール。 +- [レビュールール](../review-rules/): ファイルごとのルールテキストがどう解決されるか。 +- [セッションビューア](../viewer/): このパイプラインが書き出したトランスクリプトを確認します。 diff --git a/pages/src/content/docs/ja/cli-reference.md b/pages/src/content/docs/ja/cli-reference.md new file mode 100644 index 0000000..22691c7 --- /dev/null +++ b/pages/src/content/docs/ja/cli-reference.md @@ -0,0 +1,350 @@ +--- +title: CLI リファレンス +sidebar: + order: 6 +--- + +各 `ocr` サブコマンド、引数、終了時の挙動に関する完全なリファレンスです。 + +## グローバルな使い方 + +```text +OpenCodeReview - AI-Powered Code Review CLI + +Usage: + ocr [command] + +Commands: + review, r Start a code review + rules Inspect and debug review rules + config Manage configuration settings + llm LLM utility commands + viewer Start the WebUI session viewer + version Show version information + +Examples: + ocr review --from master --to dev Review diff range + ocr review --commit abc123 Review a single commit + ocr config provider Interactive provider setup + ocr config model Interactive model selection + 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 version Show version info + +Use "ocr review -h" for more information about review. +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. + +GitHub: https://github.com/alibaba/open-code-review +``` + +## コマンド一覧 + +| コマンド | エイリアス | 役割 | +|---|---|---| +| `ocr review` | `ocr r` | コードレビューを実行してコメントを出力します。 | +| `ocr rules check ` | — | あるファイルパスにどのルールが適用され、その出所はどこかを表示します。 | +| `ocr config set ` | — | 設定値を `~/.opencodereview/config.json` に永続化します。 | +| `ocr config unset custom_providers.` | — | カスタムプロバイダーを削除します(現在有効なものであれば、有効な `provider`/`model` もクリアされます)。 | +| `ocr config provider` | — | 対話的なプロバイダー設定 TUI。 | +| `ocr config model` | — | 対話的な model 選択 TUI。 | +| `ocr llm test` | — | 短い chat リクエストを送信し、設定されたエンドポイントを検証します。 | +| `ocr llm providers` | — | 組み込みの LLM プロバイダーをすべて一覧表示します。 | +| `ocr viewer` | — | 過去のレビューセッション用のローカル Web UI を起動します(`localhost:5483`)。 | +| `ocr version` | — | バージョン、commit、プラットフォーム、ビルド日、GitHub URL を出力します。 | + +`ocr` および `ocr -h` はトップレベルの使い方を出力します。各サブコマンドも `-h` / `--help` を受け付けます。 + +## `ocr review` + +メインコマンドです。Git diff を解析し、ファイルごとのサブエージェントをディスパッチし、レビューコメントを収集して出力します。 + +### 概要 + +```text +ocr review [flags] +ocr r [flags] (alias) +``` + +引数を何も渡さない場合、OCR は**ワークスペースモード**で動作します。カレントディレクトリのリポジトリ内にある staged + unstaged + untracked のすべての変更をレビューします。 + +### 引数 + +| 引数 | 短縮形 | デフォルト | 説明 | +|---|---|---|---| +| `--repo ` | — | カレントディレクトリ | Git リポジトリのルート。 | +| `--from ` | — | — | diff の開始 ref(例: `main`)。 | +| `--to ` | — | — | diff の終了 ref(例: `feature-branch`)。設定すると OCR は `merge-base(from, to)..to` を計算します。 | +| `--commit ` | `-c` | — | 単一の commit をレビューします(その親との差分)。 | +| `--preview` | `-p` | `false` | フィルタリングのパイプラインを実行しますが LLM はスキップします。ファイル一覧と除外理由を出力します。 | +| `--format ` | `-f` | `text` | `text`(人間が読みやすい形式)または `json`(機械可読なコメント配列)。 | +| `--audience ` | — | `human` | `human` は進捗行をストリーム出力します。`agent` は stdout を静音化し、最終サマリー / JSON のみを出力します。 | +| `--background ` | `-b` | — | plan + main prompt に注入する、任意の要件 / 業務コンテキスト。 | +| `--concurrency ` | — | `8` | 並行してレビューするファイルの最大数。 | +| `--timeout ` | — | `10` | ファイルごとの締め切り時間。`0` でタイムアウトを無効化します。 | +| `--rule ` | — | — | カスタム JSON レビュールールファイルのパス。プロジェクトレベルおよびグローバルの `rule.json` を上書きします。 | +| `--max-tools ` | — | テンプレートのデフォルト | ファイルごとの最大ツール呼び出し回数。`0` はテンプレートのデフォルト(`30`)を使用します。1〜9 は `10` に引き上げられます。`≥ 10` の値はすべてテンプレートのデフォルトを上書きします(`30` より小さくても)。 | +| `--model ` | — | — | 今回のレビューについて、解決済みの LLM model を上書きします(例: `claude-opus-4-6`)。 | +| `--max-git-procs ` | — | `16` | 並行 git サブプロセスの最大数。 | +| `--tools ` | — | 埋め込み | カスタム JSON ツール設定ファイルのパス。埋め込みのツール定義を上書きします。 | + +> モード引数は排他です: `--from`/`--to` を渡すか、`--commit` を渡すか、いずれも渡さない(ワークスペースモード)かのいずれかです。 +> 混在させるとそのままエラーになります。 + +### モード + +#### ワークスペースモード(デフォルト) + +```bash +ocr review +``` + +OCR は 2 つの git コマンドからワークツリーの変更を組み立てます: + +- `git diff HEAD` で追跡済みの変更を取得します(staged + unstaged をまとめて `HEAD` と比較。空の場合は `git diff --staged` にフォールバック) +- `git ls-files --others --exclude-standard` で untracked ファイルを取得し、ディスクから読み込んでファイル全体の新規追加として扱います + +これは通常、commit 前に確認したい内容そのものです。より小さな範囲が必要なら、選択的に stage してください。 + +#### 範囲モード + +```bash +ocr review --from main --to feature-branch +``` + +OCR は `merge-base(main, feature-branch)..feature-branch` を計算するため、feature ブランチが*導入した* diff だけが表示されます。ブランチを切ったあとに `main` へ入った無関係な変更は含まれません。 + +#### Commit モード + +```bash +ocr review --commit abc123 +ocr review -c abc123 +``` + +`git show abc123` が生成する diff(すなわちその commit が導入した変更)をレビューします。 + +### 出力 + +#### Text(デフォルト、`--audience human`) + +レビュー実行中は進捗行をストリーム出力し、続いてコメントごとに 1 ブロックを出力します(`path:start-end` を含む暗色の Unicode 区切りヘッダー、100 桁で折り返されたコメント本文、そして(存在する場合は)提案された置換のカラー化されたインライン diff)。実行終了時には stdout の末尾にサマリーを出力します: + +``` +[ocr] 17 file(s) changed, reviewing 9 in /path/to/repo +[ocr] Skipping image.png — filtered by path/extension rules +[ocr] ▶ file_read "src/foo.go" +[ocr] ✔ file_read (12ms) +[ocr] Plan completed for src/foo.go +… + +─── src/foo.go:42-47 ─── +Concurrent map access without a lock — wrap with sync.RWMutex. + +- m[k] = v ++ mu.Lock(); defer mu.Unlock(); m[k] = v + +… +[ocr] Summary: 9 file(s) reviewed, 14 comment(s), ~21344 token(s) used (input: ~18012, output: ~3332), 1m12s elapsed +``` + +#### Text(agent、`--audience agent`) + +コメントの出力は同じですが、内部的に静音化可能な stdout ライターを通じて進捗行が抑制されます([`internal/stdout`](https://github.com/alibaba/open-code-review/blob/main/internal/stdout/stdout.go))。CI / パイプライン内で別の agent に引き渡す場合に使用します。 + +#### JSON + +```bash +ocr review --format json --audience agent +``` + +```json +{ + "status": "success", + "summary": { + "files_reviewed": 9, + "comments": 1, + "total_tokens": 21344, + "input_tokens": 18012, + "output_tokens": 3332, + "elapsed": "1m12s" + }, + "comments": [ + { + "path": "src/foo.go", + "content": "Concurrent map access without a lock — wrap with sync.RWMutex.", + "start_line": 42, + "end_line": 47, + "existing_code": "m[k] = v", + "suggestion_code": "mu.Lock(); defer mu.Unlock(); m[k] = v", + "thinking": "Looking at line 42, the map …" + } + ] +} +``` + +トップレベルのフィールド: + +| フィールド | 説明 | +|---|---| +| `status` | `success`、`completed_with_warnings`、`completed_with_errors`、または `skipped`。 | +| `message` | 任意。人間が読みやすいサマリー(例: `"No comments generated. Looks good to me."`)。 | +| `summary` | 任意。実行の集計: `files_reviewed`、`comments`、`total_tokens`、`input_tokens`、`output_tokens`、`cache_read_tokens`(omitempty)、`cache_write_tokens`(omitempty)、`elapsed`。`skipped` の実行時は省略されます。 | +| `comments` | 常に存在しますが、空の場合があります。各コメントのフィールドは上記の例のとおりです。 | +| `warnings` | 任意。1 つ以上のサブエージェントが失敗した場合に存在します。各項目は影響を受けたファイルとエラーを記述します。 | + +レビュー対象のファイルがない場合、JSON モードは `skipped` の外殻を発行し、呼び出し側が「変更なし」と「発見なし」を区別できるようにします: + +```json +{ + "status": "skipped", + "message": "No supported files changed.", + "comments": [] +} +``` + +### 終了コード + +| コード | 意味 | +|---|---| +| `0` | レビューが完了しました(コメントがゼロの場合や、致命的でない警告がある場合もあります)。 | +| `1` | 致命的エラー。引数の誤り、LLM エンドポイントを解決できない、すべてのファイルごとのサブエージェントが失敗した、などです。エラーテキストは stderr に出力されます。 | + +致命的でない警告(個々のサブエージェントの失敗、あるファイルが token しきい値を超過、など)はインラインで出力されます。JSON モードでは `warnings` 配列に追加されます。 + +## `ocr rules` + +ルールの自己確認です。サブコマンドは 1 つだけです: + +```text +ocr rules check [flags] + +Flags: + --repo Git repository root (default: current dir) + --rule Path to a custom rule JSON file +``` + +与えられたファイルパスに対して、OCR は次を行います: + +1. 4 層のルールチェーン(custom → project → global → system)を辿ります。 +2. 最初に一致したものを採用します。 +3. **出所となる層**、一致した **glob パターン**、そして解決された**ルールテキスト**を出力します。 + +```bash +$ ocr rules check src/main/java/com/example/Foo.java +File: src/main/java/com/example/Foo.java +Source: System built-in +Pattern: **/*.java +Rule: +──────────────────────────────────────── + +──────────────────────────────────────── +``` + +「なぜ自分のカスタムルールが発火しないのか?」を調査するのに使えます。優先順位の完全な説明は[レビュールール](../review-rules/)を参照してください。 + +## `ocr config` + +key を `~/.opencodereview/config.json` に永続化し、対話的な設定 TUI を提供します。4 つのサブコマンドがあります: + +```text +ocr config set +ocr config unset custom_providers. Delete a custom provider +ocr config provider Interactive provider setup +ocr config model Interactive model selection +``` + +- **`set`**: 非対話的に単一の設定値を書き込みます。 +- **`unset`**: カスタムプロバイダーを削除します。サポートされるのは `custom_providers.` のみです。削除するものが現在有効なプロバイダーの場合、`provider` と `model` がクリアされます(`ocr config provider` を実行して再選択してください)。 +- **`provider`**: 対話的なプロバイダー設定 TUI を起動します(追加の引数なし。非対話的には `ocr config set provider ` を使用してください)。 +- **`model`**: 対話的な model 選択 TUI を起動します(追加の引数なし。非対話的には `ocr config set model ` を使用してください)。 + +key の完全なリファレンス、schema、例は[設定](../configuration/)を参照してください。 + +## `ocr llm` + +LLM ユーティリティコマンドです。2 つのサブコマンドがあります: + +```text +ocr llm + +Sub-commands: + test Send a test conversation to the configured LLM model + providers List all built-in LLM providers +``` + +### `ocr llm test` + +```text +ocr llm test +``` + +`ocr review` とまったく同じ方法で LLM エンドポイントを解決し、[`internal/config/testconnection/task.json`](https://github.com/alibaba/open-code-review/blob/main/internal/config/testconnection/task.json) からあらかじめ用意された chat リクエストを送信して、以下を出力します: + +``` +Source: +URL: +Model: + +✓ Connection test successful +``` + +非ゼロで終了した場合は、エンドポイントが完全に設定されていないか、リクエストが失敗した(ネットワーク / 認証 / モデルのエラー)ことを意味します。エラーメッセージがどのケースかを示します。 + +### `ocr llm providers` + +```text +ocr llm providers +``` + +各組み込み LLM プロバイダーを 3 列のテーブルで一覧表示します: + +``` +Built-in providers: + NAME PROTOCOL BASE URL + ---- -------- -------- + anthropic anthropic https://api.anthropic.com + … +``` + +続いて、`ocr config provider` で対話的に設定するか、`ocr config set provider ` で非対話的に設定できる旨のヒントが表示されます。 + +## `ocr viewer` + +```text +ocr viewer [flags] + +Flags: + --addr
listen address (default: localhost:5483) + +Examples: + ocr viewer # start on default port + ocr viewer --addr :3000 # bind to all interfaces on port 3000 +``` + +埋め込み HTTP サーバーを起動し、`~/.opencodereview/sessions/...` を読み込んで、過去のレビューセッションをブラウザで扱いやすい UI としてレンダリングします。[セッションビューア](../viewer/)を参照してください。 + +## `ocr version` + +```text +ocr version +ocr --version +ocr -V +``` + +ビルド時に書き込まれたバージョン情報、短い Git commit(存在する場合)、プラットフォーム(`/`)、ビルド日(存在する場合)、そして GitHub URL(`https://github.com/alibaba/open-code-review`)を出力します。 + +## ヒントと注意点 + +- `--audience agent` は `--format json` を**含意しません**。両者は異なることを制御します。UI の抑制 vs 構造化されたペイロードです。両方が必要な場合は組み合わせて使用してください。 +- `--background` はレビュー品質を高めるのに最も効果的な引数の 1 つです。他の agent から呼び出す際は、常に要件 / PR の説明を渡してください。 +- あるファイルの diff が単独で `MAX_TOKENS` の 80%(デフォルト `58888`)を超える場合、LLM を呼び出す前に破棄されます。これはログに記録されますが、実行を失敗にはしません。 +- あるファイルの変更行数が `PLAN_MODE_LINE_THRESHOLD`(`50`)を下回る場合、plan 段階は**自動的にスキップ**されます。 + +## 関連項目 + +- [クイックスタート](../quickstart/): インストールして最初のレビューを完了します。 +- [設定](../configuration/): 引数の背後にある環境変数と config key。 +- [レビュールール](../review-rules/): `--rule` 引数とルールの解決。 +- [連携](../integrations/): agent と CI から `ocr review` を呼び出します。 diff --git a/pages/src/content/docs/ja/configuration.md b/pages/src/content/docs/ja/configuration.md new file mode 100644 index 0000000..a7db64b --- /dev/null +++ b/pages/src/content/docs/ja/configuration.md @@ -0,0 +1,271 @@ +--- +title: 設定 +sidebar: + order: 5 +--- + +## エンドポイントの解決 + +`ocr review` または `ocr llm test` が実行されると、4 つの来源を順に試し、 +完全な `(URL, token, model)` の三つ組を最初に返せた来源を使用します。 + +| 優先度 | 来源 | 読み取る内容 | +|---|---|---| +| 1 | `~/.opencodereview/config.json` | `provider` が設定されている場合は `providers`/`custom_providers` マッピングを通じて解決します(provider が優先。[組み込み provider](#built-in-providers) を参照)。provider が設定されていない場合にのみ、レガシーの `llm` セクションにフォールバックします。 | +| 2 | OCR 環境変数 | `OCR_LLM_URL`、`OCR_LLM_TOKEN`、`OCR_LLM_MODEL`、`OCR_USE_ANTHROPIC`、`OCR_LLM_AUTH_HEADER`。 | +| 3 | Claude Code 環境変数 | `ANTHROPIC_BASE_URL`、`ANTHROPIC_AUTH_TOKEN`、`ANTHROPIC_MODEL`。 | +| 4 | シェルの rc ファイル | `~/.zshrc`、`~/.bashrc`、`~/.bash_profile`、`~/.profile` から解析された `export ANTHROPIC_*=…` 行。 | + +Claude Code 風の来源については、`ANTHROPIC_BASE_URL` にバージョン付きのパス +(`/v1/...`)がない場合、OCR は自動的に `/v1/messages` を追加します。 + +いずれの戦略でも完全な三つ組が得られない場合、OCR は次のメッセージで終了します。 + +``` +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 +``` + +> 解決は、単に最初に空だった来源ではなく、最初に**エラーとなった**来源で停止します。特に注意してください。 +> `config.json` に `provider` が設定されているのにその項目の設定が誤っている場合(不明な provider 名、 +> 環境変数のフォールバックもなく `api_key` が欠落、`model` が欠落、カスタム provider の +> `url`/`protocol` が欠落)、OCR はそのエラーで終了し、OCR 環境変数、 +> Claude Code、rc ファイルの来源にフォールバックすることは**ありません**。環境変数ベースの設定に切り替えるには、まず +> `provider` key を解除してください。 + +> 来源の優先順位により、設定ファイルが完全に埋まっている場合、**環境変数はいかなる値も上書きしません**。 +> 環境変数を有効にするには、`~/.opencodereview/config.json` から該当する `llm.*` key を削除するか、 +> `ocr config set` で新しい値に切り替えてください。 + +## `ocr config set` ——`~/.opencodereview/config.json` を管理する + +```bash +ocr config set +``` + +`config set` は key/value ペアでファイルを変更し、schema を認識した解析を行います。インタラクティブな TUI +コマンド `ocr config provider` と `ocr config model` も同じファイルに書き込みます( +[インタラクティブ設定](#interactive-setup--ocr-config-provider--ocr-config-model) を参照)。認識される +key: + +| Key | 型 | 説明 | +|---|---|---| +| `provider` | string | 現在の provider を設定します(組み込み名またはカスタム)。provider を切り替えると model がクリアされます。 | +| `model` | string | 現在の provider に model を設定します(provider 項目の下に保存。provider がない場合はトップレベルの `model` に保存)。 | +| `providers..` | varies | 組み込み provider のフィールドごとの設定:`api_key`、`url`、`protocol`、`model`、`models`、`auth_header`、`extra_body`。 | +| `custom_providers..` | varies | 同じフィールド。カスタム(非組み込み)provider 用。カスタム provider には少なくとも `url` と `protocol` を設定する必要があります。 | +| `llm.url` | string | エンドポイント URL。Anthropic では完全な Messages URL(例:`https://api.anthropic.com/v1/messages`)を使用します。OpenAI 互換では chat-completions URL を使用します。 | +| `llm.auth_token` | string | API key。`Authorization: Bearer …` として送信されます(OpenAI)。レガシーの Anthropic パスもデフォルトで `Authorization: Bearer …`(プリセットの `anthropic` provider ではデフォルトが `x-api-key` に変わります)。`llm.auth_header` を明示的に設定した場合にのみ `x-api-key` を使用します。 | +| `llm.auth_header` | string | Auth header 名(`x-api-key`、`authorization`、または `bearer`)。Anthropic のみで使用します。`x-api-key` を必要とする一部の Anthropic 設定で必須です。 | +| `llm.model` | string | モデル名。`[<数字>m]` サフィックスは自動的に除去されます。 | +| `llm.use_anthropic` | boolean | `true`(デフォルト)→ Anthropic Messages プロトコル。`false` → OpenAI Chat Completions。 | +| `llm.extra_body` | JSON object | ベンダー固有のリクエストフィールド。各 chat リクエストボディにマージされます。例:`'{"thinking":{"type":"disabled"}}'`。 | +| `language` | string | system prompt に追加される指示として転送されます。未設定の場合はデフォルトで `English`。[言語の選択](#choosing-a-language) を参照。 | +| `telemetry.enabled` | boolean | OpenTelemetry エクスポートのマスタースイッチ。デフォルトは無効。 | +| `telemetry.exporter` | string | `console` または `otlp`。 | +| `telemetry.otlp_endpoint` | string | OTLP collector のアドレス(例:`localhost:4317`)。 | +| `telemetry.content_logging` | boolean | エクスポートされるイベントデータに LLM の prompt / レスポンスを含めます。 | + +例: + +```bash +ocr config set llm.url https://api.anthropic.com/v1/messages +ocr config set llm.auth_token sk-ant-xxxxxxxxxx +ocr config set llm.model claude-opus-4-6 +ocr config set llm.use_anthropic true +ocr config set llm.extra_body '{"thinking":{"type":"disabled"}}' +ocr config set language English +ocr config set telemetry.enabled true +ocr config set telemetry.exporter otlp +ocr config set telemetry.otlp_endpoint localhost:4317 + +# provider ベースの設定(推奨) +ocr config set provider anthropic +ocr config set model claude-opus-4-6 +ocr config set providers.anthropic.api_key "$ANTHROPIC_API_KEY" + +# カスタム(非組み込み)provider +ocr config set provider my-gateway +ocr config set custom_providers.my-gateway.url https://gateway.internal.com/v1 +ocr config set custom_providers.my-gateway.protocol openai +ocr config set custom_providers.my-gateway.model llama-3-70b +ocr config set custom_providers.my-gateway.api_key "$MY_API_KEY" +``` + +boolean は Go の `strconv.ParseBool` が受け付ける任意の形式(`true`、`false`、`1`、`0`、 +`t`、`f`……)を受け付けます。`llm.extra_body` は正しい JSON でなければなりません。 + +## ファイル schema リファレンス + +上記のコマンドを実行すると、`~/.opencodereview/config.json` は次のようになります。 + +```json +{ + "llm": { + "url": "https://api.anthropic.com/v1/messages", + "auth_token": "sk-ant-xxxxxxxxxx", + "auth_header": "x-api-key", + "model": "claude-opus-4-6", + "use_anthropic": true, + "extra_body": { + "thinking": { "type": "disabled" } + } + }, + "language": "English", + "telemetry": { + "enabled": true, + "exporter": "otlp", + "otlp_endpoint": "localhost:4317" + } +} +``` + +provider ベースの形式では、レガシーの `llm` ブロックではなく `provider`、`model`、`providers`、 +`custom_providers` を使用します。 + +```json +{ + "provider": "anthropic", + "model": "claude-opus-4-6", + "providers": { + "anthropic": { + "api_key": "sk-ant-xxxxxxxxxx", + "model": "claude-opus-4-6" + } + }, + "custom_providers": { + "my-gateway": { + "url": "https://gateway.internal.com/v1", + "protocol": "openai", + "model": "llama-3-70b", + "models": ["llama-3-70b", "llama-3-8b"], + "api_key": "gw-xxxxxxxxxx", + "auth_header": "authorization" + } + }, + "language": "English" +} +``` + +`provider` が設定されている場合、解決は `providers`/`custom_providers` マッピングによって駆動されます。この設定では +レガシーの `llm` セクションは無視されます。 + +このファイルを手動で編集することもできますが、次回の書き込み時に `ocr config set` が `" "` インデントで +再シリアライズします。 + +## インタラクティブ設定——`ocr config provider` / `ocr config model` + +provider と model を選択するために key を手動で入力する手間を省くため、OCR は 2 つのインタラクティブな Bubble Tea TUI を提供します。 +どちらも同様に `~/.opencodereview/config.json` を変更します。 + +```bash +ocr config provider +ocr config model +``` + +- `ocr config provider`——組み込みまたはカスタムの provider を選択し、URL / protocol / + API key / model を入力するインタラクティブな TUI です。選択内容は config に保存され、自動的に `ocr llm test` を実行して + エンドポイントを検証します。組み込み provider の場合、直接入力しなければ API key はその provider の環境変数から + 読み取れます([組み込み provider](#built-in-providers) を参照)。手動設定を選んだ場合は、レガシーの + `llm.*` ブロックに代わりに書き込みます。 +- `ocr config model`——現在の provider のプリセットリスト、および + `providers..models` / `custom_providers..models` の下でユーザーが追加した + model からモデルを選択するインタラクティブな TUI です。先に provider を設定しておく必要があります(`ocr config provider`)。 + +## 組み込み provider + +以下の provider が OCR に同梱されています。それぞれにプリセットの `BaseURL`、`Protocol`、および +(該当する場合)`providers..api_key` が未設定のときにフォールバックとなる API key 環境変数があります。 + +| 名称 | Protocol | Base URL | API key 環境変数 | +|---|---|---|---| +| `anthropic` | anthropic | `https://api.anthropic.com` | `ANTHROPIC_API_KEY` | +| `openai` | openai | `https://api.openai.com/v1` | `OPENAI_API_KEY` | +| `dashscope` | openai | `https://dashscope.aliyuncs.com/compatible-mode/v1` | `DASHSCOPE_API_KEY` | +| `dashscope-tokenplan` | openai | `https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1` | `DASHSCOPE_TOKENPLAN_KEY` | +| `volcengine` | openai | `https://ark.cn-beijing.volces.com/api/v3` | `ARK_API_KEY` | +| `deepseek` | openai | `https://api.deepseek.com` | `DEEPSEEK_API_KEY` | +| `tencent-tokenhub` | openai | `https://tokenhub.tencentmaas.com/v1` | `TENCENT_TOKENHUB_API_KEY` | +| `hy-tokenplan` | openai | `https://api.lkeap.cloud.tencent.com/plan/v3` | `TENCENT_HUNYUAN_TOKENPLAN_KEY` | +| `kimi` | openai | `https://api.moonshot.cn/v1` | `MOONSHOT_API_KEY` | +| `z-ai` | openai | `https://open.bigmodel.cn/api/paas/v4` | `Z_AI_API_KEY` | +| `mimo` | openai | `https://api.xiaomimimo.com/v1` | `MIMO_API_KEY` | +| `minimax` | openai | `https://api.minimaxi.com/v1` | `MINIMAX_API_KEY` | +| `baidu-qianfan` | openai | `https://qianfan.baidubce.com/v2` | `QIANFAN_API_KEY` | + +その他の provider 名はすべてカスタムとみなされ、`custom_providers` の下で設定する必要があり、少なくとも +`url` と `protocol` が必要です。 + +## 環境変数リファレンス + +| 変数 | 用途 | +|---|---| +| `OCR_LLM_URL` | エンドポイント URL——`llm.url` と同形。 | +| `OCR_LLM_TOKEN` | API key——`llm.auth_token` と同じ。 | +| `OCR_LLM_MODEL` | モデル名。 | +| `OCR_LLM_AUTH_HEADER` | Auth header 名(`x-api-key`、`authorization`、または `bearer`)。Anthropic のみ。`llm.auth_header` と同じ。未設定の場合はデフォルトで `authorization`。 | +| `OCR_USE_ANTHROPIC` | 未設定 → Anthropic プロトコル(デフォルト)。`true` / `1` / `yes`(大文字小文字を区別しない)に設定 → Anthropic。その他の値(`false`、`0`、`no`、スペルミス……)に設定 → OpenAI。 | +| `ANTHROPIC_BASE_URL` | Claude Code 互換の base URL。 | +| `ANTHROPIC_AUTH_TOKEN` | Claude Code 互換の API key。 | +| `ANTHROPIC_MODEL` | Claude Code 互換の model。 | +| `OCR_ENABLE_TELEMETRY` | `1` で環境変数からテレメトリを有効化します。 | +| `OTEL_SERVICE_NAME` | span/metric の service name を上書きします。 | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP collector のアドレス——同時に exporter を `otlp` に強制します。 | +| `OTEL_EXPORTER_OTLP_PROTOCOL` | OTLP のトランスポートプロトコル(`grpc`、`http/protobuf`、または `http/json`)。デフォルトは `grpc`。 | +| `OCR_CONTENT_LOGGING` | `1` でテレメトリイベントに prompt/レスポンスを含めます。 | + +各 provider の API key(`ANTHROPIC_API_KEY`、`OPENAI_API_KEY`、 +`DASHSCOPE_API_KEY`……)は、組み込み provider の `api_key` フィールドが未設定のときにフォールバックとなります。 +各 provider の環境変数名は [組み込み provider](#built-in-providers) の表を参照してください。 + +## なぜ `extra_body` があるのか + +一部のホスト型 provider は、リクエストボディに非標準のフィールドを追加します(例:Bedrock 風の `thinking`、 +ベンダー固有の `temperature_strategy`、ストリーミングオプション)。`llm.extra_body` は発行される各リクエストに +マージされるため、ソースコードを変更せずにこれらのフィールドを送信できます。 + +```bash +ocr config set llm.extra_body '{"thinking":{"type":"enabled","budget_tokens":2048}}' +``` + +## 言語の選択 + +`language` key は 1 つのことだけを制御します。それは、レビューと `ocr llm test` の prompt 内の各 +system-role メッセージに追加される 1 つの指示です。注入される正確な文字列は次のとおりです。 + +``` +\n\nAlways respond in . +``` + +- *未設定* または空——`English` として扱われます。 +- `Chinese`、`English`、またはその他任意の文字列——そのまま透過されます。 + +組み込みの rule docs は言語の切り替えをサポートしません。`internal/config/rules/rule_docs/` の下に埋め込まれたファイルは +固定のファイル名で読み込まれ、ほとんどが中国語で書かれています(`default.md` は英語の例外)。`language` +の設定にかかわらず、それらはそのまま prompt に現れます。したがって `language` を `English` に設定した場合、prompt +には英語の指示と大量の中国語の rule テキストが重なります——強力なモデルは指示に従って英語のコメントを生成し、 +弱いモデルは中国語と英語が混在した内容を出力する可能性があります。 + +`language` には環境変数、CLI 引数、プロジェクトレベルの上書きがありません——設定できる唯一の場所はグローバルな +`~/.opencodereview/config.json` で、 +[`ocr config set`](#ocr-config-set--managing-opencodereviewconfigjson) を通じて設定します。 + +```bash +ocr config set language English +``` + +純粋な英語の rule テキストが必要な場合は、`--rule`、`/.opencodereview/rule.json`、 +または `~/.opencodereview/rule.json` を通じて独自のルールを提供してください( +[レビュールール](../review-rules/#priority-chain) を参照)。 + +## プロジェクトレベル vs グローバル設定 + +CLI 自体はグローバルに設定されます(`~/.opencodereview/config.json`)——プロジェクトレベルの LLM 設定はありません。 +ただし**レビュールール**はプロジェクトレベルです。[レビュールール](../review-rules/#priority-chain) を参照してください。 + +## 関連項目 + +- [クイックスタート](../quickstart/)——最小限のセットアップと初回のレビュー。 +- [CLI リファレンス](../cli-reference/)——review コマンドが受け入れる各引数。 +- [テレメトリ](../telemetry/)——OTLP / console exporter への接続方法。 diff --git a/pages/src/content/docs/ja/contributing.md b/pages/src/content/docs/ja/contributing.md new file mode 100644 index 0000000..dc17c4a --- /dev/null +++ b/pages/src/content/docs/ja/contributing.md @@ -0,0 +1,205 @@ +--- +title: コントリビュート +sidebar: + order: 13 +--- + +OCR は Apache-2.0 ライセンスのオープンソースプロジェクトです。バグ報告、ドキュメント修正、 +コード貢献を歓迎します。本ページはクイックリファレンスです。正式版は +[`CONTRIBUTING.md`](https://github.com/alibaba/open-code-review/blob/main/CONTRIBUTING.md) にあります。 + +## 貢献の方法 + +Go を書かなくても貢献できます。 + +- **バグ報告**——再現手順を添えて + [GitHub issue](https://github.com/alibaba/open-code-review/issues/new/choose) を作成してください。 +- **機能リクエスト**—— + [Discussions](https://github.com/alibaba/open-code-review/discussions/categories/ideas) + に投稿するか、feature-request issue を作成してください。 +- **ドキュメント**——誤字、不足している例、リンク切れ——これらの PR は通常、最も早くマージされます。 +- **他の PR のレビュー**——メンテナー以外からのコメントは、レビュアーの負担軽減に役立ちます。 +- **コード**——バグ修正、パフォーマンス改善、新機能。 + +## ローカル開発環境のセットアップ + +### 前提条件 + +- [Go ≥ 1.25](https://go.dev/dl/) +- [Git](https://git-scm.com/) +- [Make](https://www.gnu.org/software/make/) + +### ソースコードの取得 + +```bash +# GitHub で Fork し、次に: +git clone https://github.com//open-code-review.git +cd open-code-review +git remote add upstream https://github.com/alibaba/open-code-review.git + +make build # dist/opencodereview を書き出す +make test # LC_ALL=C go test -v -race -count=1 ./... +``` + +> `upstream` remote は読み取り専用です。`origin`(あなたの fork)にプッシュし、そこから PR を出してください。 + +### ローカルビルドの実行 + +```bash +./dist/opencodereview review --preview +``` + +便宜のため、`dist/opencodereview` を指すシンボリックリンクを `~/bin/ocr-dev` に置いておくと、 +任意のリポジトリで `ocr-dev` を呼び出せます。 + +### Make target + +| Target | 作用 | +|---|---| +| `make build` | 現在のプラットフォーム向けにビルド → `dist/opencodereview`。 | +| `make build-darwin-amd64` | macOS Intel 向けのクロスコンパイル。 | +| `make build-darwin-arm64` | macOS Apple Silicon 向けのクロスコンパイル。 | +| `make build-linux-amd64` | Linux x86_64 向けのクロスコンパイル。 | +| `make build-linux-arm64` | Linux ARM64 向けのクロスコンパイル。 | +| `make build-windows-amd64` | Windows x86_64 向けのクロスコンパイル。 | +| `make build-windows-arm64` | Windows ARM64 向けのクロスコンパイル。 | +| `make build-all` | 6 つすべてのクロスコンパイルバイナリ(linux/darwin/windows × amd64/arm64)。 | +| `make sha256sum` | ビルド成果物の `sha256sum.txt` を生成。 | +| `make dist` | `clean → build-all → sha256sum`。CI が実行する内容。 | +| `make test` | race 検出を有効にしてテストを実行。 | +| `make clean` | `dist/` を削除。 | + +## ブランチとコミットの規約 + +### ブランチのプレフィックス + +| プレフィックス | 用途 | +|---|---| +| `feat/` | 新機能 | +| `fix/` | バグ修正 | +| `docs/` | ドキュメントのみ | +| `refactor/` | 挙動を変えないリファクタリング | +| `test/` | テストのみの変更 | +| `chore/` | ビルド / CI / ツール | + +```bash +git checkout main +git pull upstream main +git checkout -b feat/anthropic-streaming +``` + +### コミットメッセージ + +[Conventional Commits](https://www.conventionalcommits.org/) フォーマット: + +``` +(): + +[optional body explaining the why] +``` + +例: + +``` +feat(agent): add support for custom tool definitions +fix(llm): handle timeout errors in Anthropic API calls +docs(readme): clarify endpoint resolution priority +refactor(viewer): extract task-card rendering into helper +``` + +**PR タイトル**も同じフォーマットを使うと、生成される changelog に整然と表示されます。 + +## プロジェクト構成 + +``` +open-code-review/ +├── cmd/opencodereview/ # CLI エントリーポイント——引数解析、ディスパッチ +├── internal/ +│ ├── agent/ # レビューエージェントのロジック、サブエージェントのディスパッチ +│ ├── config/ # テンプレート、ルール、ホワイトリスト、埋め込み JSON +│ ├── diff/ # Git diff の解析、3 つのモード +│ ├── gitcmd/ # Git サブプロセスランナー +│ ├── llm/ # LLM client(Anthropic と OpenAI)、エンドポイント解決 +│ ├── model/ # データ構造(LlmComment、Diff……) +│ ├── pathutil/ # パスユーティリティ +│ ├── release/ # Release notes の生成 +│ ├── session/ # JSONL セッションライター +│ ├── stdout/ # ミュート可能な stdout writer +│ ├── suggestdiff/ # 提案 diff のレンダリング +│ ├── telemetry/ # OpenTelemetry の設定 + ヘルパー +│ ├── tool/ # ツールレジストリ + provider の実装 +│ └── viewer/ # 埋め込み HTTP UI +├── pages/ # WebUI マーケティングページ(独立した React app) +├── plugins/ # Claude Code slash コマンド +├── extensions/ # エディタ拡張(VS Code) +├── examples/ # CI レシピ(GitHub Actions、GitLab CI) +├── skills/ # Agent SDK skill manifest +├── scripts/ # NPM postinstall + クロスプラットフォームビルドスクリプト +├── npm/ # 各プラットフォーム向け optional dependency パッケージ +└── bin/ # NPM wrapper(Node) +``` + +ほとんどの貢献は `internal/agent/`、`internal/tool/`、`internal/llm/` に触れます。 +`cmd/opencodereview/` の CLI レイヤーは意図的に薄く保たれています——引数を解析してから +agent パッケージへディスパッチします。 + +## コード品質チェック + +PR を出す前に: + +```bash +go fmt ./... +go vet ./... +make test # race 有効、CI では push のたびに実行される +make build # バイナリがビルドできることのスモークテスト +``` + +CI は push のたびに同じ一式を実行するため、予期せぬ結果になることはありません。 + +## 新しいツールの追加 + +ツールは 2 つの部分から成ります。 + +1. [`internal/config/toolsconfig/tools.json`](https://github.com/alibaba/open-code-review/blob/main/internal/config/toolsconfig/tools.json) + 内の **JSON 定義**: name、description、そして LLM が見る JSON-schema 引数。 +2. `internal/tool/definitions.go` に登録される **Go provider**(実際の実装を含む)。 + +両方が揃って初めて、新しいツール名が機能します。既存の 6 つは[ツール](../tools/)にあり、 +テンプレートとして使えます。 + +## 新しいルールパターンの追加 + +`internal/config/rules/system_rules.json` を編集して新しい glob をルールドキュメントにマッピングし、 +`internal/config/rules/rule_docs/` 下に対応する markdown を追加します。ルールドキュメントは +パターンごとに 1 ファイルで保存されます(英語)。`language` 設定は system prompt に +「その言語で応答せよ」という指示を 1 行追加するだけです。rule-doc ファイルは切り替えません。 + +## PR のフロー + +1. **大きな変更はまず issue を作成してください。** 事前に方向性を合わせておく方が、 + コードレビューで初めて相違に気づくよりも良いです。 +2. **1 つの PR につき 1 つの論理的変更。** 無関係な修正が 2 つあるなら、PR を 2 つ出してください。 +3. **テストを更新してください。** 挙動の変更にはテストのカバレッジが必要です——`make test` は必ず通ること。 +4. **ドキュメントを更新してください。** 変更が引数、config key、レビューパイプラインに影響する場合は、 + 本ドキュメントサイト([`docs/`](https://github.com/alibaba/open-code-review) 内)と関連するインラインヘルプの + 両方を更新してください。 +5. **PR テンプレートを記入してください。** メンテナーがレビューします。通常は数営業日以内です。 + +## コントリビューターライセンス契約(CLA) + +本プロジェクトは Alibaba Open Source CLA を必要とします。初めて PR を出すと bot がリンクを貼ります—— +電子署名してください(1 分程度)。以降の PR では再署名は不要です。 + +## 初めての貢献ですか? + +[`good first issue`](https://github.com/alibaba/open-code-review/labels/good%20first%20issue) +または [`help wanted`](https://github.com/alibaba/open-code-review/labels/help%20wanted) +のラベルが付いた issue を探してください。ほとんどは小規模で自己完結しており、issue の説明に +着手に十分なコンテキストがあります。 + +## 関連項目 + +- [アーキテクチャ](../architecture/)——`internal/agent/` を変更する前に必要なメンタルモデル。 +- [ツール](../tools/)——既存のツールがどのようなものか。 +- 完全な貢献ガイド: + [CONTRIBUTING.md](https://github.com/alibaba/open-code-review/blob/main/CONTRIBUTING.md) diff --git a/pages/src/content/docs/ja/faq.md b/pages/src/content/docs/ja/faq.md new file mode 100644 index 0000000..9def2c0 --- /dev/null +++ b/pages/src/content/docs/ja/faq.md @@ -0,0 +1,304 @@ +--- +title: FAQ +sidebar: + order: 14 +--- + +よくあるエラー、想定外の挙動、そして「これは仕様ですか?」という疑問をまとめました。ここに +あなたの問題が見つからない場合は、実行手順と完全な出力を添えて +[GitHub issue](https://github.com/alibaba/open-code-review/issues) を作成してください。 + +## 設定と起動 + +### `no valid LLM endpoint configured` + +``` +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 +``` + +OCR は 4 つのソースからなる解決チェーン([設定](../configuration/#endpoint-resolution))を +たどりましたが、完全な `(URL, token, model)` の三つ組を見つけられませんでした。次のいずれかを +行ってください。 + +- `ocr config set llm.url …` / `llm.auth_token …` / `llm.model …` を実行して + `~/.opencodereview/config.json` を埋める、**または** +- `OCR_LLM_URL` / `OCR_LLM_TOKEN` / `OCR_LLM_MODEL` をエクスポートする、**または** +- すでに Claude Code を使っている場合は、`ANTHROPIC_BASE_URL` / `ANTHROPIC_AUTH_TOKEN` / + `ANTHROPIC_MODEL` をエクスポートする。 + +その後 `ocr llm test` で接続性を検証してからレビューを再試行してください。 + +### `ocr llm test` が誤ったソースを表示する + +OCR は**最後**ではなく**最初**の完全な三つ組を採用します。したがって、設定ファイルに +すでに 3 つの llm.* key がすべて揃っていると、環境変数は無視されます。環境変数を有効にするには、 +設定 key を削除する(ファイルを削除するか手動で unset する)か、`ocr config set` で新しい値に +切り替えてください。 + +### `ocr llm test` が 401 / 403 を返す + +token に scope が不足している、期限切れ、あるいはプロバイダーが一致していません。Anthropic と +OpenAI は異なる auth header と URL フォーマットを使います——`llm.use_anthropic` が指し先の URL と +一致していることを確認してください。 + +- Anthropic: URL は `/v1/messages` で終わり、`use_anthropic=true`。 +- OpenAI / OpenAI 互換: URL は `/v1/chat/completions` で終わり、 + `use_anthropic=false`。 + +### `not a git repository` + +`ocr review` はカレントディレクトリに対して `git diff`(および untracked ファイルに対する +`git ls-files`)を実行します。Git ワークツリー内にいない場合は、早期に終了します。リポジトリに +`cd` するか、`--repo /path/to/repo` を渡してください。 + +## フィルタリングとルール + +### ファイルがレビューされない + +`ocr review --preview` を実行してください(LLM コストなし)。出力には各候補ファイルと、それが +保持されたか破棄されたかの**理由**が一覧されます。 + +``` +src/foo.go modified +src/foo_test.go modified (excluded: user_exclude) +node_modules/lib.js added (excluded: default_path) +imgs/logo.png binary (excluded: unsupported_ext) +``` + +5 種類の除外理由は、[ファイルフィルタリング](../review-rules/#how-files-are-filtered)のゲートに対応します。 + +| 理由 | 修正方法 | +|---|---| +| `binary` | 対処不要——バイナリファイルにはレビュー可能なテキストがありません。 | +| `user_exclude` | あなたの `exclude` リストからそのパターンを削除してください。 | +| `unsupported_ext` | ホワイトリストゲートを回避するため、拡張子を `include` リストに追加してください。 | +| `default_path` | ファイルを `include` に追加してください——組み込みのテストファイル除外パターンを上書きします。 | +| `deleted` | 対処不要——レビュー対象の新しい内容がありません。 | + +### カスタムルールが発火しない + +`ocr rules check ` を実行してください。マッチした**レイヤー**と **glob パターン**を +すべて表示します。 + +``` +File: src/api/UserHandler.go +Source: Project (.opencodereview/rule.json) +Pattern: src/api/**/*.go +Rule: … +``` + +レイヤーが正しくない場合(プロジェクトルールを期待していたのに "System built-in" と表示される等)、 +多くは**宣言順序**の問題です——最初にマッチしたパターンが適用されます。より具体的なルールを +`rules` 配列内で前に移動するか、glob を修正してください。 + +### 波括弧展開が動作しない + +`bmatcuk/doublestar/v4` は `{ts,tsx}` の波括弧をサポートします。マッチしない場合は、余分な空白を +確認してください——`{ts, tsx}` のように空白があると、`tsx` に静かにマッチしなくなります。 + +## レビュー + +### あるファイルにコメントが 0 件——本当にレビューされたのか? + +[セッションビューア](../viewer/)(`ocr viewer`)を開き、セッションを見つけ、そのファイルの +`main_task` レーンを確認してください。 + +- ツール呼び出しあり + `task_done` で終了 → クリーンなレビュー。 +- ツール呼び出しあり + ループ途中で終了 → エラーカードを探してください。 +- `main_task` カードが全くない → ファイルはレビュー前にフィルタされました。上記の[フィルタリングとルール](#filtering--rules)を参照してください。 + +### コメントの `start_line: 0` と `end_line: 0` + +OCR はコメントを diff 内の正確な行にアンカーできませんでした。よくある原因は 2 つです。 + +- モデルが `existing_code` を diff からそのままコピーせず、書き換えてしまった。モデルには + そうしないよう指示されていますが、時々起きます。 +- diff のフォーマットが異常(CRLF、tab/空白の混在)で、スライディングウィンドウのマッチングが + 壊れた。 + +コメント自体は本物です——ただ自動的に配置されなかっただけです。ほとんどのエージェント統合 +(SKILL、Claude Code plugin)は `existing_code` フィールドを読み、ファイル内で自ら位置を特定します。 + +### Token threshold exceeded + +``` +[ocr] WARNING: prompt tokens (94000) exceed 80% of max_tokens(58888) for src/big.sql +``` + +そのファイルの初期 prompt(ルール + diff + change-files リスト)が、モデルが応答できる前に +すでに `MAX_TOKENS = 58888` の 80 % を超えました。OCR はそのファイルをスキップして続行します—— +JSON モードでは `warnings` にも表示されます。 + +緩和策: + +- 自動生成されたものなら、ファイルを `exclude` リストに追加してください。 +- 大きなリファクタリングをより小さな commit に分割してください。 +- 一連の小さな commit に対しては、一括のワークスペースモードではなく `--commit` モードで + レビューしてください。 + +### ファイルが小さいのに plan フェーズに時間がかかる + +まず `ocr review --preview` を実行してください。ファイルの `lines.changed` が +`PLAN_MODE_LINE_THRESHOLD`(デフォルト **50**)を超えると、plan フェーズが実行されます。これは +意図的なものです——大きな diff は plan の恩恵を受けます。単一のレビューでスキップするには、 +より小さな diff で実行するか、埋め込みテンプレートを一時的に編集してください(上級者向け。 +`--tools` の上書きが必要)。 + +### "Max tool requests reached" + +``` +[ocr] Max tool requests reached for src/foo.go. +``` + +モデルが 30(`MAX_TOOL_REQUEST_TIMES`)回のツール呼び出しを費やしたのに `task_done` を +呼びませんでした。その時点までに発せられたコメントは、依然として収集されレンダリングされます。 +ほとんどのファイルでこうなる場合、原因は通常次のとおりです。 + +- モデルが「完了したら `task_done` を呼べ」という指示に従うのが苦手。より強力なモデル + (Claude Opus など)に切り替えてください。 +- あるツールがエラーを出し続け、モデルがリトライし続けている。セッション JSONL を確認してください—— + 同じツール結果が繰り返されていれば、それが原因です。 +- ファイルが本当に大きい、あるいはコンテキストが重く、30 回では足りない。`--max-tools ` で + 上げるか下げるか調整してください(例: `--max-tools 40` でより多く、`--max-tools 15` でより少なく)。 + 1〜9 は 10 に引き上げられます。`0`(デフォルト)はテンプレートのデフォルト 30 を使います。 + +### 一部のサブエージェントが失敗しても、実行は 0 で終了する + +意図的なものです。OCR はファイルごとの失敗を隔離し、1 つの問題のあるファイルが 20 ファイルの +レビュー全体を巻き込まないようにします。成功したものが*1 つでもあれば*、集計終了コードは `0` です。 +完全に失敗した場合(成功したサブエージェントがゼロ)のみ非ゼロで終了します。どのファイルが +失敗したかは、JSON モードの `warnings` 配列またはテキストモードの stderr を確認してください。 + +### CI での実行がローカルよりずっと遅い + +よくある原因は 2 つです。 + +- **モデルのレート制限**——制限がかかると、LLM client はバックオフしてリトライします。最初から + 制限に触れないよう、`--concurrency` を下げてください(`4` など)。 +- **コールドキャッシュ**——プロバイダーが prompt キャッシュをサポートしている場合、デプロイ後の + 初回実行は恩恵を受けられません。同じウィンドウ内の後続実行はより高速になります。 + +## 出力と統合 + +### `--audience agent` でも進捗行が出る + +見ているのが **stderr** でないことを確認してください。進捗メッセージは時々 stderr に出ます +(警告、エラー)。`--audience agent` が保証するクリーンな stdout は*パーサーに優しい*ものです—— +すべてを遮断するにはリダイレクトしてください: `ocr review --audience agent 2>/dev/null`。 + +### JSON 出力が `{ "files_reviewed": 0, "comments": [] }` + +ワークスペースに対象ファイルがありません。これは意図的なものです——明示的な形により、呼び出し側は +「レビュー対象がない」ことと「レビューしたファイルに指摘がない」ことを区別できます。コメントが +ゼロの正常なレビューは、通常の空配列 `[]` を返します。 + +### セッション JSONL はどこにある? + +``` +~/.opencodereview/sessions//.jsonl +``` + +リポジトリパスは、`/` と `\` を `-` に、`:` を `_` に置き換えてエンコードされます +(例: `/Users/foo/my-repo` → `Users-foo-my-repo`)。`ocr viewer` でセッションを閲覧できます。 +このディレクトリを削除すると履歴が消えます。OCR は次回実行時にエンコード済みパスを再生成します。 + +## パフォーマンスとコスト + +### どの token にどれだけ費やしたかを知るには? + +テレメトリを有効にします。 + +```bash +ocr config set telemetry.enabled true +ocr config set telemetry.exporter console +ocr review +``` + +LLM 呼び出しには独自の span がありません——metric として記録されます。`ocr.llm.tokens_used` +(counter、`model` + `type` でラベル付け)、`ocr.llm.requests_total`(counter、`model` ++ `status` でラベル付け)、`ocr.llm.request_duration_seconds`(histogram、`model` でラベル付け)に +注目してください。console exporter はこれらの集計をインラインで出力します。ダッシュボードが必要な場合は、 +OTLP exporter に切り替えて metrics 基盤に送ってください——[テレメトリ](../telemetry/)を参照。 + +### なぜ私のレビューはこんなに高価なのか? + +よくある要因: + +- ファイルが 50 行以上のとき plan フェーズが起動します。これはファイルごとに LLM 呼び出しを + 1 回追加します。閾値を下げるとコストを削減でき、上げると小さな PR の速度を向上できます。 +- `MAX_TOOL_REQUEST_TIMES = 30` はかなり緩やかです。ラウンドを使い切るモデルは、3 ラウンドで + 終わるモデルより長い(トークンの多い)対話を生みます。より強力なモデルはより速く終える傾向が + あります。逆に、"max tool requests reached" に対処するため `--max-tools` を上げると、ファイルごとの + コストはおおむね線形に増加すると考えてください。 +- メモリ圧縮それ自体が 1 回の LLM 呼び出しです。長いサブタスクは、レビューのラウンドに加えて、 + 圧縮のラウンド分も支払うことになります。 + +### LLM 呼び出しを減らすには? + +- `include` リストを追加して、OCR が気にしないファイルをレビューしないようにします。 +- アカウントに burst-mode の課金がある場合は、`--concurrency` を下げます。 +- `--background` を渡します——十分な事前コンテキストがあれば、モデルが `file_read` / + `code_search` の往復なしに完了できることがあります。 + +## プライバシーとセキュリティ + +### OCR は私のコードをどこかに送るのか? + +OCR はあなたの **diff**(および任意の read-tool スニペット)を、設定した LLM エンドポイントに送ります。 +それ以外のものは一切あなたのマシンから出ません——セッション JSONL とルールファイルはローカルにのみ +存在します。 + +テレメトリを有効にしている場合、`content_logging` フラグは設定レイヤーに接続されていますが、 +現時点ではどのコードパスも**制御しません**——このフラグの値にかかわらず、prompt と応答の内容が +collector にエクスポートされることは決してありません。予約項目とみなしてください。本番環境では +`false` のままにしてください。詳細は[テレメトリ](../telemetry/#content-logging)を参照してください。 + +### LLM に送る前に secret をマスクできるか? + +組み込み機能ではありません。推奨のワークフロー: + +1. secret をリポジトリにコミットしない(一般的なルール)。 +2. 機密情報を含むと分かっているファイルを `exclude` に追加する。 +3. `git diff --no-textconv` フィルターまたは pre-commit でマスクし、secret が diff に入らないようにする。 + +「マスクルール」機能はロードマップにあります。 +[issue トラッカー](https://github.com/alibaba/open-code-review/issues)をご覧ください。 + +## その他 + +### changelog はどこにある? + +[GitHub Releases](https://github.com/alibaba/open-code-review/releases) +——各 release には Conventional Commits から生成された notes が付属しています。 + +### OCR は Git 以外の VCS をサポートするか? + +しません。diff provider は shell 経由で `git` を呼び出します。SVN / Mercurial などには新しい +provider が必要です。Hg サポートの issue は[こちら](https://github.com/alibaba/open-code-review/issues)で +オープンになっています。 + +### なぜバイナリは `opencodereview` なのに CLI は `ocr` なのか? + +release で配布される静的バイナリはプロジェクト名(`opencodereview`)を持ちます。NPM wrapper は +使いやすさのため `ocr` としてインストールされます。ソースからビルドすると `dist/opencodereview` が +得られます——`$PATH` 上の `ocr` としてコピーしてください。 + +### アンインストールするには? + +```bash +npm uninstall -g @alibaba-group/open-code-review # NPM install +sudo rm /usr/local/bin/ocr # binary install +rm -rf ~/.opencodereview # all state +``` + +OCR は `~/.opencodereview` の外には書き込みません(NPM がダウンロードするバイナリを除く)。 +したがってこのディレクトリを削除すれば、履歴、設定、ユーザーごとのルールが消去されます。 + +## 関連項目 + +- [設定](../configuration/)——LLM エンドポイント解決と config key。 +- [レビュールール](../review-rules/)——ファイルフィルターとルール解決チェーン。 +- [セッションビューア](../viewer/)——過去のレビューセッションを表示する。 +- [テレメトリ](../telemetry/)——token 使用量と LLM メトリクス。 diff --git a/pages/src/content/docs/ja/installation.md b/pages/src/content/docs/ja/installation.md new file mode 100644 index 0000000..9230e7e --- /dev/null +++ b/pages/src/content/docs/ja/installation.md @@ -0,0 +1,177 @@ +--- +title: インストール +sidebar: + order: 4 +--- + +`ocr` CLI をインストールするには、サポートされた 4 つの方法があります。いずれも生成されるのは同じバイナリなので、 +環境に合わせて選んでください。 + +## NPM(推奨) + +```bash +npm install -g @alibaba-group/open-code-review +``` + +NPM パッケージには、小さな wrapper スクリプト(`bin/ocr.js`)と +[postinstall hook](https://github.com/alibaba/open-code-review/blob/main/scripts/install.js) +が付属しており、以下を行います。 + +1. お使いのプラットフォームを検出します(`darwin-amd64`、`darwin-arm64`、`linux-amd64`、 + `linux-arm64`、`windows-amd64`、`windows-arm64`)。 +2. GitHub Releases から一致するバイナリをダウンロードします。 +3. (チェックサムデータが存在する場合は)検証し、wrapper の隣に配置します。 + +プラットフォーム固有の npm パッケージ(例:`@alibaba-group/ocr-darwin-arm64`)が +optional dependency としてインストールされている場合は、そのバイナリを直接使用し、ダウンロードをスキップします。 + +`ocr` を実行すると、wrapper はダウンロード済みのバイナリを単に `exec` するだけなので、初回実行後の実際のオーバーヘッド +はゼロです。 + +### 更新 + +```bash +npm update -g @alibaba-group/open-code-review +# または特定のバージョンに固定: +npm install -g @alibaba-group/open-code-review@ +``` + +### アンインストール + +```bash +npm uninstall -g @alibaba-group/open-code-review +``` + +## GitHub Release バイナリ + +Node.js をインストールしたくない場合は、 +[releases ページ](https://github.com/alibaba/open-code-review/releases) から +静的バイナリを直接取得できます。 + +```bash +# macOS (Apple Silicon) +curl -Lo ocr https://github.com/alibaba/open-code-review/releases/latest/download/opencodereview-darwin-arm64 +chmod +x ocr && sudo mv ocr /usr/local/bin/ocr + +# macOS (Intel) +curl -Lo ocr https://github.com/alibaba/open-code-review/releases/latest/download/opencodereview-darwin-amd64 +chmod +x ocr && sudo mv ocr /usr/local/bin/ocr + +# Linux x86_64 +curl -Lo ocr https://github.com/alibaba/open-code-review/releases/latest/download/opencodereview-linux-amd64 +chmod +x ocr && sudo mv ocr /usr/local/bin/ocr + +# Linux ARM64 +curl -Lo ocr https://github.com/alibaba/open-code-review/releases/latest/download/opencodereview-linux-arm64 +chmod +x ocr && sudo mv ocr /usr/local/bin/ocr + +# Windows (AMD64) +curl -Lo ocr.exe https://github.com/alibaba/open-code-review/releases/latest/download/opencodereview-windows-amd64.exe + +# Windows (ARM64) +curl -Lo ocr.exe https://github.com/alibaba/open-code-review/releases/latest/download/opencodereview-windows-arm64.exe +``` + +各 release では、バイナリの隣に `sha256sum.txt` も公開されており、完全性を検証できます。 + +```bash +curl -LO https://github.com/alibaba/open-code-review/releases/latest/download/sha256sum.txt +shasum -a 256 -c sha256sum.txt --ignore-missing +``` + +## インストールスクリプト(curl | sh) + +GitHub Release バイナリのダウンロード(検証付き)をラップした便利なインストーラーです——CI のベース +イメージやヘッドレス環境に適しています。 + +```bash +curl -fsSL https://raw.githubusercontent.com/alibaba/open-code-review/main/install.sh | sh +``` + +2 つの環境変数を認識します。 + +| 変数 | デフォルト値 | 用途 | +|---|---|---| +| `OCR_INSTALL_DIR` | `/usr/local/bin` | `ocr` バイナリを配置する場所。 | +| `OCR_VERSION` | 最新 release | 特定の release tag に固定します(例:`v1.2.3`)。 | + +このスクリプトは `darwin` と `linux` の `amd64` / `arm64` をサポートします。Windows では +[GitHub Release バイナリ](#github-release-binary) または [NPM](#npm-recommended) +の方法を使用してください。 + +## ソースからビルドする + +OCR 自体を変更する場合、またはプリコンパイル済みバイナリのないプラットフォームで実行する場合にのみ、この方法が必要です。 + +### 前提条件 + +- [Go ≥ 1.25](https://go.dev/dl/) +- [Git](https://git-scm.com/) +- [Make](https://www.gnu.org/software/make/) + +### ビルド + +```bash +git clone https://github.com/alibaba/open-code-review.git +cd open-code-review +make build # dist/opencodereview を生成 +sudo cp dist/opencodereview /usr/local/bin/ocr +``` + +### 他のプラットフォーム向けにビルドする + +```bash +make build-linux-amd64 +make build-linux-arm64 +make build-darwin-amd64 +make build-darwin-arm64 +make build-windows-amd64 # Windows (x86_64) +make build-windows-arm64 # Windows (ARM64) +make build-all # 6 つすべてを一括ビルド +make sha256sum # sha256sum.txt も生成 +``` + +`make dist` は `clean → build-all → sha256sum` を実行し、バイナリの隣に +`VERSION` ファイルを書き込みます——これはまさに release パイプラインが実行するステップです。 + +### テストの実行 + +```bash +make test # LC_ALL=C go test -v -race -count=1 ./... +``` + +## インストールの検証 + +バイナリがどこから来たものであっても: + +```bash +ocr version # バージョン + git commit + ビルド日時を出力 +ocr --help # トップレベルの使い方 +ocr review --help # review コマンドの完全な引数リスト +``` + +"command not found" エラーが出る場合は、インストール先が `$PATH` 上にあることを確認してください。 + +```bash +which ocr +echo $PATH +``` + +## OCR が状態を保存する場所 + +| パス | 保存内容 | +|---|---| +| `~/.opencodereview/config.json` | LLM エンドポイント、言語、テレメトリ設定(`ocr config set` で管理)。 | +| `~/.opencodereview/rule.json` | オプションのグローバルレビュールール。 | +| `~/.opencodereview/sessions//.jsonl` | レビューセッションごとのストリーミング JSONL トランスクリプト。`ocr viewer` で使用します。 | +| `~/.opencodereview/{last-update-check,update.lock,update-available}` | NPM wrapper のバックグラウンド更新チェックの状態。wrapper はより新しい release があるかをポーリングし(デフォルトで約 18 分ごと)、アップグレードの案内を表示します。`OCR_NO_UPDATE=1` で無効化するか、`OCR_UPDATE_INTERVAL`(秒)で間隔を調整します。静的バイナリはこれらのファイルを書き込みません。 | +| `/.opencodereview/rule.json` | オプションのプロジェクトレベルのレビュールール——安全にコミットできます。 | + +OCR は `~/.opencodereview/` の外に書き込むことは決してありません(NPM が一時的にバイナリをダウンロードする場合を除く)。 +このディレクトリを削除すれば、クリーンなアンインストールが完了します。 + +## 関連項目 + +- [クイックスタート](../quickstart/)——LLM を設定して初回のレビューを完了します。 +- [設定](../configuration/)——OCR が受け入れる各環境変数と config key。 +- [コントリビュート](../contributing/)——ソースからビルドし、テストを実行して開発に参加します。 diff --git a/pages/src/content/docs/ja/integrations.md b/pages/src/content/docs/ja/integrations.md new file mode 100644 index 0000000..cbd6540 --- /dev/null +++ b/pages/src/content/docs/ja/integrations.md @@ -0,0 +1,60 @@ +--- +title: 統合 +sidebar: + order: 12 +--- + +OCR は CLI であり、プロセスを派生できるあらゆる環境と組み合わせられます。本セクションでは、 +エージェント型ワークフローや CI に組み込むための主要な方法を、統合方式ごとに 1 ページずつ扱います。 + +## なぜこれらの統合なのか? + +OCR の `--audience agent` モードは、別のエージェントから駆動されることを前提に設計されています。 +stdout には JSON / 最終サマリーのみが流れ、進捗 UI はありません。これにより、次の 3 つの組み合わせ方が +自然に成り立ちます。 + +1. **Agent skill**——呼び出し側のエージェントが呼べる skill として OCR を登録します(Anthropic + Agent SDK など)。 +2. **Command(Claude Code plugin)**——パッケージ化されたコマンドをインストールし、 + `/open-code-review:review` で `ocr review` をエンドツーエンドに実行します。Claude-Code スタイルの + コマンド規約をサポートする他のエージェントでも利用できます。 +3. **Direct subprocess**——`subprocess.run` を呼べるあらゆるフレームワーク(LangChain ツール、 + カスタムシェル、CI ステップ)から直接 shell 経由で呼び出します。 + +これらは組み合わせて使えます。skill も plugin も、最終的には同じバイナリを呼び出します。 + +## モードを選ぶ + +| 方式 | 最適なケース | ページ | +|---|---|---| +| Agent skill | Anthropic Agent SDK、または `SKILL.md` を消費する他のフレームワーク上で構築している。 | [Agent Skill](agent-skill/) | +| Command(Claude Code plugin) | Claude Code(または Claude-Code スタイルのコマンド規約を持つ任意のエージェント)を使っていて、`/open-code-review:review` に正しい動作をさせたい。 | [Command(Claude Code Plugin)](claude-code/) | +| Direct subprocess | カスタムスクリプト、LangChain ツール、あるいは Anthropic 以外のエージェントから OCR を呼び出す必要がある。 | [Direct Subprocess](subprocess/) | +| CI/CD | すべての PR や pre-commit のたびに OCR を実行したい。 | [CI/CD](ci/) | + +## MCP はどうなのか? + +OCR は現在、Model Context Protocol server を公開していません。想定している統合方式は +「エージェントが CLI を呼び出す」というもので、よりシンプルであり、MCP server が持ち込む +長時間稼働プロセスの問題を避けられます。エージェントプラットフォームがどうしても MCP を +必要とする場合は、CLI を薄い shim でラップしてください。単一の `review` ツールを公開する +30 行程度の Node スクリプトで十分です。 + +## すべてのモードに共通するヒント + +- **呼び出し側が人間でない場合は、常に `--audience agent` を渡してください。** そうしないと、 + 進捗行が解析対象の出力を汚染します。 +- **PR / 要件のコンテキストがある場合は、常に `--background` を渡してください。** 品質が大きく + 向上し、コストはツール引数 1 つ分にすぎません。 +- **CI では `--concurrency` を低めに設定してください**(`--concurrency 4`)。プロバイダーの + レート制限に触れないようにするためです。デフォルトは 8 です。 +- **CI では `--commit HEAD` よりも `--from origin/main --to HEAD` を優先してください。** + merge-base 計算により、ブランチを切った後に `main` に取り込まれた無関係な変更を除外できます。 +- **`OCR_LLM_TOKEN` を stdout/logs から遠ざけてください。** OCR はこれを出力しませんが、 + 設定を誤った shell が漏らす可能性があります。CI の secret マスクを使ってください。 + +## 関連項目 + +- [CLI リファレンス](../cli-reference/)——review コマンドの各引数。 +- [設定](../configuration/)——環境変数と config key。 +- [クイックスタート](../quickstart/)——初回レビューのための最小構成。 diff --git a/pages/src/content/docs/ja/integrations/agent-skill.md b/pages/src/content/docs/ja/integrations/agent-skill.md new file mode 100644 index 0000000..6086eb8 --- /dev/null +++ b/pages/src/content/docs/ja/integrations/agent-skill.md @@ -0,0 +1,85 @@ +--- +title: Agent Skill +sidebar: + order: 1 +--- + +OCR を呼び出し可能な skill として登録することで、agent フレームワークが正しい引数、事前チェック、分類基準を用いて OCR を呼び出せるようになります。呼び出し側でこれらを改めて導き出す必要はありません。 + +## リポジトリに含まれるもの + +リポジトリには +[`skills/open-code-review/SKILL.md`](https://github.com/alibaba/open-code-review/blob/main/skills/open-code-review/SKILL.md) +に SKILL マニフェストが用意されています。これは OCR を呼び出し可能な skill として宣言し、事前チェック、呼び出しワークフロー、コメントの分類基準(High/Medium/Low)を含みます。 + +## インストール + +### 方法 1:`npx skills add`(推奨) + +skill を使いたいプロジェクト内で実行します。 + +```bash +npx skills add alibaba/open-code-review --skill open-code-review +``` + +これは +[skills registry](https://github.com/alibaba/open-code-review/blob/main/skills/open-code-review/SKILL.md) +からマニフェストを取得してプロジェクトに配置し、skills の規約を尊重するコーディング agent が次回の呼び出し時にそれをロードするようにします。skill を最新版に更新するには、このコマンドを再実行してください。 + +> **前提条件:** 初回実行時、バイナリが `PATH` 上に存在しない場合、skill は +> (`npm install -g @alibaba-group/open-code-review` を通じて)`ocr` CLI を自動的にインストールします——[skill が行うこと](#what-the-skill-does)を参照してください。ただし、LLM は事前に設定しておく**必要があります**。skill が代わりに行うことはできず、処理を止めて尋ねます。[設定](../../configuration/)を参照してください。 + +### 方法 2:手動コピー(システムレベル) + +プロジェクトごとではなくグローバルに skill をインストールしたい場合は、フォルダを skills ディレクトリにコピーします。 + +```bash +mkdir -p ~/.claude/skills +cp -R /path/to/open-code-review/skills/open-code-review ~/.claude/skills/ +``` + +これにより、マシン上のすべてのプロジェクトで skill が利用可能になります。 + +## skill が行うこと + +SKILL.md は一つの prompt です。呼び出し側の agent がそれをロードすると、agent 自身が手順を実行します。完全な `/open-code-review`(または同等)のリクエストは、次のように展開されます。 + +1. **事前チェック。** `which ocr` を実行して CLI が `PATH` 上にあることを確認し、続いて `ocr llm test` で LLM に到達可能であることを確認します。 +2. **CLI が無ければ自動インストール。** `which ocr` が "NOT INSTALLED" を報告した場合、agent は `npm install -g @alibaba-group/open-code-review` を実行して続行します。ユーザーへの確認は行いません——これは通常のセットアップ手順とみなされます。 +3. **LLM 設定が無ければ止めて尋ねる。** `ocr llm test` が失敗した場合、agent は認証情報を*でっち上げません*。サポートされている 2 つの方法(環境変数または `ocr config set …`)をユーザーに提示し、API key の提供を待ちます。 +4. **業務コンテキストの抽出。** レビュー対象(commit、ブランチ、作業コピー)を確認し、短い `--background` 文字列を生成します。 +5. **レビューの実行。** + `ocr review --audience agent --background "…" [--commit | --from/--to]` + を呼び出します。ユーザーが作業コピー、特定の commit、ブランチ区間のいずれをレビューしたいかに応じて引数を選択します。 +6. **分類とレポート。** SKILL.md の基準を用いて JSON コメントを **High** / + **Medium** / **Low** に分類し(bug とセキュリティ問題は High、些細な指摘や誤検知と疑われるものは黙って破棄)、Markdown 形式のサマリーをレンダリングします。 +7. **必要に応じて修正。** ユーザーが「レビュー**して**修正して」(または類似)と指示した場合、High/Medium 項目に対して安全な修正をインラインで適用します。そうでない場合はコードを変更する前に尋ねます。 + +完全な prompt——正確な分類基準、出力テンプレート、注意事項を含む——は +[`skills/open-code-review/SKILL.md`](https://github.com/alibaba/open-code-review/blob/main/skills/open-code-review/SKILL.md) +にあります。上記のいずれかを厳しくしたい場合(例えばデフォルトの動作を、修正前に常に尋ねるように変更するなど)は、ローカルのコピーを編集してください。 + +## Anthropic Agent SDK + +SDK の init をインストール済みの skill パスに向けます。 + +```python +from anthropic_agent_sdk import Agent + +agent = Agent( + skill_paths=["/path/to/open-code-review/skills/open-code-review"], +) + +agent.run("Review my staged changes — focus on race conditions.") +``` + +SDK は SKILL.md prompt をロードし、agent が[skill が行うこと](#what-the-skill-does)で述べられているワークフローを実行します——`npm install` のフォールバックや、LLM 設定が無いときに認証情報の入力を促す手順を含みます。 + +## その他の agent フレームワーク + +「外部 skill を登録する」インターフェースを持つフレームワークであれば、SKILL.md を取り込めます——それは frontmatter 付きの markdown に過ぎません。フレームワークが異なるスキーマを期待する場合でも、markdown 本文は prompt テンプレートとして利用できます。 + +## 関連項目 + +- [Command(Claude Code Plugin)](../claude-code/)——同じ skill の slash-command 版。 +- [Direct Subprocess](../subprocess/)——マニフェストを介さず、自分で CLI を呼び出す方法。 diff --git a/pages/src/content/docs/ja/integrations/ci.md b/pages/src/content/docs/ja/integrations/ci.md new file mode 100644 index 0000000..1480a28 --- /dev/null +++ b/pages/src/content/docs/ja/integrations/ci.md @@ -0,0 +1,366 @@ +--- +title: CI/CD +sidebar: + order: 4 +--- + +すべての Pull Request または Merge Request で OCR を実行します。上流リポジトリは、コピーして設定するだけのすぐ使える 2 つのパイプラインを提供しています——1 つは GitHub Actions、もう 1 つは GitLab CI です。どちらも +[Direct Subprocess](../subprocess/)にある中核コマンドの薄いラッパーです。 + +## CI/CD 統合の仕組み + +本ページの各レシピは同じパターンに従います——以下の GitHub Actions と GitLab CI のセクションは、その具体的な実装に過ぎません。 + +1. **PR / MR イベントでトリガー。** 新しい pull request、更新された merge request、または手動の + `/open-code-review` コメントがジョブをトリガーします。 +2. **runner に `ocr` をインストール**します。通常は + `npm install -g @alibaba-group/open-code-review` です。runner は一時的なため、これは実行のたびに発生します。 +3. **CI secret から `ocr config set` 経由で LLM を設定**します(エンドポイント、token、model)。フォールバックできる永続的な `~/.opencodereview` はありません。 +4. **区間モードでレビューを実行**し、機械可読な出力を得ることで、stdout がクリーンな JSON の外殻になるようにします。 + + ```bash + ocr review \ + --from "origin/" \ + --to "origin/" \ + --format json \ + --audience agent + ``` + + `--format json` は解析可能なペイロードを提供し、`--audience agent` は進捗行を抑制します。各レシピが消費する外殻は [JSON の構造](../subprocess/#json-shape)を参照してください。 +5. **JSON を解析**し、`comments[]` を反復処理します。 +6. **プロバイダーの review API を通じてコメントを PR / MR に貼り戻します。** 有効な行情報を持たない項目(ファイルレベルの発見)はインラインで貼り付けるのではなくサマリーの注記にまとめられます。インラインの一括 API がリクエストを拒否した場合、貼り付け手順も通常のサマリーコメントにフォールバックします。 + +常に 2 種類の認証情報が関わります。OCR が発見を生成するために使う **LLM 認証情報**と、貼り付け手順がコメントを貼り戻すために使う **PR/MR 書き込み token** です。GitHub のレシピは `GITHUB_TOKEN` を通じて後者を自動的に提供します。GitLab では `GITLAB_API_TOKEN` を明示的に設定することを推奨しますが、fork MR に対しては組み込みの `CI_JOB_TOKEN` にフォールバックします(これは `/discussions` を通じてディスカッションを開始できます)——信頼性のためには専用の token の使用を推奨します。 + +## GitHub Actions + +上流のワークフローは +[`examples/github_actions/ocr-review.yml`](https://github.com/alibaba/open-code-review/blob/main/examples/github_actions/ocr-review.yml) +にあります。 + +### 何をするか + +- `pull_request_target`(`opened`)**および** `issue_comment` イベント(本文が + `/open-code-review` または `@open-code-review` で始まるもの)でトリガーします。後者はレビュアーが PR にコメントすることで OCR をオンデマンドで再実行できるようにします。(`pull_request` ではなく `pull_request_target` を使うことで、fork から提出された PR でも secret を利用できます。OCR は diff を読むだけで、PR 内のコードは実行しません。) +- `npm install -g @alibaba-group/open-code-review` で OCR をインストールし、`ocr config set` で設定を書き込み、ブランチ区間モードで中核コマンドを実行します。 +- JSON の外殻を解析し、GitHub Pull Request Review API を通じて各発見をインラインのレビューコメントとして貼り付けます。行情報を持たないコメントはサマリー本文にまとめられます。一括送信が失敗した場合は 1 件ずつの貼り付けにフォールバックし、統計をサマリーコメントに表示します。 + +### インストール + +ワークフローをリポジトリに配置します。 + +```bash +mkdir -p .github/workflows +curl -o .github/workflows/ocr-review.yml \ + https://raw.githubusercontent.com/alibaba/open-code-review/main/examples/github_actions/ocr-review.yml +``` + +### 必須の secret + +**Settings → Secrets and variables → Actions** で設定します。 + +| Secret | 必須 | 説明 | +|---|---|---| +| `OCR_LLM_URL` | はい | LLM API エンドポイント(例:`https://api.openai.com/v1/chat/completions`)。 | +| `OCR_LLM_AUTH_TOKEN` | はい | LLM API の認証 token。この CI secret は `ocr config set llm.auth_token` に渡されます。(OCR の直接の環境変数は `OCR_LLM_TOKEN` であり、`OCR_LLM_AUTH_TOKEN` ではありません。) | +| `OCR_LLM_MODEL` | いいえ | モデル名。デフォルトはありません——明示的に設定する必要があります。 | +| `OCR_LLM_USE_ANTHROPIC` | いいえ | Anthropic Claude モデルの場合は `true` に設定します。 | + +`GITHUB_TOKEN` は自動的に提供されます。ワークフローはレビューコメントを貼り付けるために `pull-requests: write` を宣言しています。 + +> ワークフロー起動時には +> `ocr config set llm.extra_body '{"thinking": {"type": "disabled"}}'` +> も実行され、このフィールドをサポートしない LLM プロバイダー向けに thinking-mode リクエストをオフにします。プロバイダーが thinking-mode を維持する必要がある場合は、その行を削除してください。 + +### カスタマイズ + +以下はすべて、あなたがコピーしたばかりのワークフローファイル +(`.github/workflows/ocr-review.yml`)への編集です。 + +#### 背景コンテキスト + +`--background` は最も効果の大きい単一の引数です——[すべてのモードに適用されるヒント](../#tips-that-apply-to-every-pattern)を参照してください。PR タイトルを渡します(タイトルが `feat(auth): add OAuth2 support` のようなセマンティックな規約に従っている場合、より効果的です)。 + +```yaml +- name: Run OCR review + run: | + ocr review \ + --background "${{ github.event.pull_request.title }}" \ + --from "origin/${{ github.base_ref }}" \ + --to "origin/${{ github.head_ref }}" \ + --format json --audience agent +``` + +#### カスタムルール + +`--rule` でプロジェクト固有のルールファイルを渡します。 + +```yaml +- name: Run OCR review + run: | + ocr review --rule ./my-rules.json \ + --from "origin/${{ github.base_ref }}" \ + --to "origin/${{ github.head_ref }}" +``` + +スキーマは[レビュールール](../../review-rules/)を参照してください。 + +#### 並行数 + +デフォルトはファイルごとに 8 つの並行サブ agent です。大きな PR では、LLM プロバイダーのレート制限に抵触しないよう下げてください。 + +```yaml +- name: Run OCR review + run: | + ocr review --concurrency 5 \ + --from "origin/${{ github.base_ref }}" \ + --to "origin/${{ github.head_ref }}" +``` + +#### トリガーモード + +デフォルトのワークフローは、PR が **opened** されたとき、および `/open-code-review` または +`@open-code-review` で始まる PR コメントのときにトリガーします。よくある 2 つの調整があります。 + +より多くの PR ライフサイクルイベントで実行する(例:新しい commit がプッシュされたときに再レビュー): + +```yaml +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] +``` + +異なるコメントキーワードを使う: + +```yaml +if: | + github.event_name == 'pull_request' || + (github.event_name == 'issue_comment' + && github.event.issue.pull_request + && startsWith(github.event.comment.body, '/review')) +``` + +`github.event.issue.pull_request` のチェックは、コメントが通常の issue ではなく PR 上のものであることを保証します。 + +#### OCR のバージョン固定 + +デフォルトのワークフローは最新のリリース版をインストールします。固定するには: + +```yaml +- name: Install OpenCodeReview + run: npm install -g @alibaba-group/open-code-review@1.0.0 +``` + +#### GitHub App として投稿する + +デフォルトではレビューコメントは `github-actions[bot]` から投稿されます。`OpenCodeReview Bot` のようなカスタムブランドの bot として投稿するには、`GITHUB_TOKEN` を GitHub App の installation token に置き換えます。 + +1. *Settings → Developer settings → GitHub Apps → New GitHub App* で **app を作成**します。webhook は無効にします(このユースケースでは不要)。*Repository permissions* で次を付与します。 + - **Pull requests**:Read and write + - **Contents**:Read-only(diff の取得用) + - **Metadata**:Read-only(必須) + +2. app 設定ページから**秘密鍵を生成**し、`.pem` ファイルをダウンロードします。同じページの **App ID** を控えておきます。 + +3. app を OCR にレビューさせたいリポジトリに**インストール**します。Installation ID はインストール後の URL に現れます。例:`https://github.com/settings/installations/12345` → ID は `12345`。 + +4. *Settings → Secrets and variables → Actions* で**3 つの secret を追加**します。 + + | Secret | 値 | + |---|---| + | `GITHUB_APP_ID` | App ID。 | + | `GITHUB_APP_PRIVATE_KEY` | `.pem` ファイルの全内容。`-----BEGIN RSA PRIVATE KEY-----` と `-----END RSA PRIVATE KEY-----` の行を含みます。 | + | `GITHUB_APP_INSTALLATION_ID` | Installation ID。 | + +5. コメント貼り付け手順で **token を生成して使用**します。 + + ```yaml + - name: Get GitHub App Token + id: app-token + uses: actions/create-github-app-token@v1 + 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 + with: + github-token: ${{ steps.app-token.outputs.token }} + script: | + # ...existing post script... + ``` + +レビューは `github-actions[bot]` ではなく、あなたの app の名前で投稿されるようになります。 + +### トラブルシューティング + +| 症状 | 原因 / 修正 | +|---|---| +| `Cannot find merge-base` | checkout 手順が浅いクローンを使っていますが、区間モードのレビューには完全な履歴が必要です。上流のワークフローは `actions/checkout` に `fetch-depth: 0` を設定しています——ファイルを編集する際はこの設定を保持してください。 | +| `Failed to parse OCR output` | `OCR_LLM_URL` または `OCR_LLM_AUTH_TOKEN` が欠落しているか誤っています。*Settings → Secrets and variables → Actions* で値を再確認してください。 | +| レビューコメントが誤った行に付く | 通常、レビュー開始からコメント貼り付けの間に diff がずれたことを意味します。貼り付けスクリプトはこの場合、通常の issue コメントにフォールバックします——対処は不要です。 | + +> **注意。** `OCR_DEBUG` 環境変数は現在 OCR で**未実装**です——`OCR_DEBUG: "1"` を設定しても効果はありません。将来の対応に備えてここに記載しています。現時点で詳細な出力が必要な場合は、ワークフローが `/tmp/ocr-result.json` と `/tmp/ocr-stderr.log` に書き込む生のレビュー JSON と stderr を確認するか(下記のトラブルシューティングを参照)、ローカルで `ocr review` を実行してください。 + +## GitLab CI + +上流のパイプラインは +[`examples/gitlab_ci/.gitlab-ci.yml`](https://github.com/alibaba/open-code-review/blob/main/examples/gitlab_ci/.gitlab-ci.yml) +にあります。 + +### 何をするか + +- `merge_requests` イベント(作成、更新、再オープンといったすべての MR イベント)でトリガーします。 +- `node:20` イメージで実行し、OCR をインストールし、`ocr config set` で設定し、MR diff モードで中核コマンドを実行します。 +- インラインの Python スクリプトで JSON の外殻を解析し、各発見を GitLab Discussion として(diff 上にインラインで)投稿します。MR の `versions` エンドポイントを使って正しい `base_sha` / `start_sha` / + `head_sha` を計算し、正確に位置決めします。インラインで投稿できないコメントは通常の MR note にフォールバックし、最後にサマリー note で締めくくります。 + +### インストール + +パイプラインをリポジトリのルートに配置します。 + +```bash +curl -o .gitlab-ci.yml \ + https://raw.githubusercontent.com/alibaba/open-code-review/main/examples/gitlab_ci/.gitlab-ci.yml +``` + +すでに `.gitlab-ci.yml` があり、それを保持したい場合は、レシピを別のパスに配置して `include:` +で取り込みます。 + +```yaml +include: + - local: 'ci/ocr-review.gitlab-ci.yml' +``` + +### 必須の CI/CD 変数 + +**Settings → CI/CD → Variables** で設定します。 + +| 変数 | 必須 | マスク | 説明 | +|---|---|---|---| +| `OCR_LLM_URL` | はい | いいえ | LLM API エンドポイント URL。 | +| `OCR_LLM_AUTH_TOKEN` | はい | はい | API 認証 token。この CI 変数は `ocr config set llm.auth_token` に渡されます。(OCR の直接の環境変数は `OCR_LLM_TOKEN` であり、`OCR_LLM_AUTH_TOKEN` ではありません。) | +| `OCR_LLM_MODEL` | いいえ | いいえ | モデル名。デフォルトはありません——明示的に設定する必要があります。 | +| `GITLAB_API_TOKEN` | いいえ | はい | `api` scope を持つ project / personal / group access token。オプションです——欠落時は組み込みの `CI_JOB_TOKEN` にフォールバックします(fork MR など)。信頼性のためには専用の `GITLAB_API_TOKEN` を推奨します。 | + +> GitLab は 8 文字未満の変数を拒否するため、パイプライン内で `llm.use_anthropic` は +> `false` にハードコードされています。Anthropic Claude モデルを使うには、スクリプトを直接編集してください。 + +> パイプライン起動時には +> `ocr config set llm.extra_body '{"thinking": {"type": "disabled"}}'` +> も実行され、このフィールドをサポートしない LLM プロバイダー向けに thinking-mode リクエストをオフにします。プロバイダーが thinking-mode を維持する必要がある場合は、その行を削除してください。 + +> **手軽な bot 命名のヒント。** Project Access Token と Group Access Token では、 +> token の**名前**が MR ディスカッションの横に表示されます。token を `OpenCodeReview Bot` と命名すれば、追加設定なしでレビューディスカッションにブランド名を付けられます——[サービスアカウント名義で投稿する](#post-under-a-service-account-identity)に記載のより永続的なサービスアカウント設定が不要なときに便利です。 + +### カスタマイズ + +以下はすべて、あなたがコピーしたばかりの `.gitlab-ci.yml` への編集です。 + +#### 背景コンテキスト + +MR タイトルを `--background` に渡します——タイトルが `feat(auth): add OAuth2 support` +のようなセマンティックな規約に従っている場合、より効果的です。 + +```yaml +script: + - | + ocr review \ + --background "$CI_MERGE_REQUEST_TITLE" \ + --from "origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME" \ + --to "${CI_COMMIT_SHA}" \ + --format json --audience agent +``` + +#### カスタムルールと並行数 + +GitHub Actions のレシピと同じ引数です——`--rule` でプロジェクト固有のルールファイルを渡し、 +`--concurrency` で並行サブ agent を制限します(デフォルトは 8)。 + +```yaml +script: + - | + ocr review --rule ./my-rules.json --concurrency 5 \ + --from "origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME" \ + --to "${CI_COMMIT_SHA}" +``` + +ルールのスキーマは[レビュールール](../../review-rules/)を参照してください。 + +#### OCR のバージョン固定 + +```yaml +script: + - npm install -g @alibaba-group/open-code-review@1.0.0 +``` + +#### プッシュのたびの再レビューを避ける + +`only: [merge_requests]` は **MR の更新のたびに**トリガーするため、長期にわたる MR では大量の LLM token を消費します。GitLab にはネイティブの「作成時のみ」イベントがないため、推奨されるパターンは、レビューを実行する前に既存の OCR note を検出し、あればスキップすることです。`ocr review` の呼び出しを Python wrapper に置き換えます。 + +```python +import json, os, sys, urllib.request + +GITLAB_URL = os.environ.get("CI_SERVER_URL", "https://gitlab.com") +PROJECT_ID = os.environ["CI_PROJECT_ID"] +MR_IID = os.environ["CI_MERGE_REQUEST_IID"] +API_TOKEN = os.environ["GITLAB_API_TOKEN"] + +url = ( + f"{GITLAB_URL}/api/v4/projects/{PROJECT_ID}" + f"/merge_requests/{MR_IID}/notes?per_page=100" +) +req = urllib.request.Request(url, headers={"PRIVATE-TOKEN": API_TOKEN}) +with urllib.request.urlopen(req) as resp: + notes = json.loads(resp.read().decode()) + +if any("OpenCodeReview" in n.get("body", "") for n in notes): + print("OCR already reviewed this MR. Skipping to save tokens.") + sys.exit(0) + +# ...otherwise call `ocr review ...` as usual and write the JSON to +# the file the posting step expects. +``` + +この後に再レビューを強制するには、MR から以前の OCR note を削除してください——次のパイプライン実行では OCR note が見当たらなくなり、処理を続行します。 + +#### セルフホスト GitLab + +コードの変更は不要です。貼り付けスクリプトは `CI_SERVER_URL`(GitLab が各 runner で自動的に設定します)を読むため、そのままで自分のインスタンスと通信できます。`GITLAB_API_TOKEN` が `gitlab.com` ではなく、あなたのセルフホストインスタンスによって発行されていることだけ確認してください。 + +#### サービスアカウント名義で投稿する + +デフォルトではレビューディスカッションは `GITLAB_API_TOKEN` が属するユーザー名で表示されます。プロジェクトレベルのサービスアカウントに切り替えると、`OpenCodeReview Bot` のようなカスタムブランドの bot 名義が得られます。 + +1. *Project → Settings → Service Accounts → New service account* で**サービスアカウントを作成**します。選んだ名前(例:`OpenCodeReview Bot`)が MR ディスカッションの横に表示されます。 + +2. *Settings → Members → Invite member* で**プロジェクトに招待**します。サービスアカウント名を検索し、`Developer` または `Maintainer` を割り当てます——どちらもディスカッションの投稿に必要な権限を持ちます。 + +3. *Settings → Service Accounts →(該当アカウント)→ Add new token* で **access token を発行**します。必要な scope は `api` です。token はすぐにコピーしてください——GitLab は一度しか表示しません。 + +4. *Settings → CI/CD → Variables* で **token の値を置き換え**ます——既存の `GITLAB_API_TOKEN` の値をサービスアカウントの token で置き換えます(変数名は変えません)。 + +ディスカッションは、最初に token を作成したユーザー名ではなく、サービスアカウント名で投稿されるようになります。 + +### トラブルシューティング + +| 症状 | 原因 / 修正 | +|---|---| +| `Cannot find merge-base` | runner が浅いクローンを使っています。上流のパイプラインは `GIT_DEPTH: 0` を設定して完全なクローンを強制します——ファイルを編集する際はこの設定を保持してください。 | +| 投稿時の `API error 403` | `GITLAB_API_TOKEN` に `api` scope が無い、プロジェクトのメンバーでない、または——セルフホストの場合——別のインスタンスによって発行されています。`api` scope で再発行し、*Settings → CI/CD → Variables* で再登録してください。 | +| `Failed to parse OCR output` | `OCR_LLM_URL` または `OCR_LLM_AUTH_TOKEN` が誤っています。*Settings → CI/CD → Variables* で値を再確認してください。 | +| インラインコメントが誤った行に付く | GitLab のインラインディスカッションは正確な SHA の一致を要求します。貼り付けスクリプトは `versions` メタデータを取得して正しい `base_sha` / `start_sha` / `head_sha` を得ます。それでも発見をアンカーできない場合は、通常の MR note にフォールバックします。 | + +パイプラインは生のレビュー JSON を `/tmp/ocr-result.json` に、stderr を +`/tmp/ocr-stderr.log` に書き込みます。debug 手順でそれらを cat して、OCR が何を返したか確認できます。 + +```yaml +script: + - cat /tmp/ocr-result.json + - cat /tmp/ocr-stderr.log +``` + +## 関連項目 + +- [Direct Subprocess](../subprocess/)——2 つのパイプラインが消費する JSON の構造。ゼロから CI スクリプトを書くときに役立ちます。 +- [設定](../../configuration/)——OCR が受け付けるすべての環境変数と config key。 diff --git a/pages/src/content/docs/ja/integrations/claude-code.md b/pages/src/content/docs/ja/integrations/claude-code.md new file mode 100644 index 0000000..77d2d2d --- /dev/null +++ b/pages/src/content/docs/ja/integrations/claude-code.md @@ -0,0 +1,87 @@ +--- +title: Command(Claude Code Plugin) +sidebar: + order: 2 +--- + +パッケージ化されたコマンドをインストールすることで、OCR を [Claude Code](https://docs.anthropic.com/en/docs/claude-code) +内でエンドツーエンドに実行できます——diff をレビューし、発見を分類し、採用すべき修正を自動的に適用します。 + +## リポジトリに含まれるもの + +リポジトリには +[`plugins/open-code-review/`](https://github.com/alibaba/open-code-review/tree/main/plugins/open-code-review) +配下に Claude Code plugin が用意されています。コマンドの prompt 本体は +[`plugins/open-code-review/commands/review.md`](https://github.com/alibaba/open-code-review/blob/main/plugins/open-code-review/commands/review.md) +にあり、以下で述べるワークフローの正式な拠り所です。 + +## インストール + +### 方法 1:plugin marketplace(推奨) + +**Claude Code 内で**次の 2 つのコマンドを実行します。 + +```bash +/plugin marketplace add alibaba/open-code-review +/plugin install open-code-review@open-code-review +``` + +これにより `/open-code-review:review` slash コマンドが登録され、`/plugin` を通じて更新可能な状態が保たれます。 + +### 方法 2:コマンドファイルを直接コピー + +plugin marketplace をスキップしたい場合は、コマンドファイルを直接 `.claude/commands/` に配置します。これは `/open-code-review`(`:review` サフィックス無し)として登録されます。 + +**プロジェクトレベル**(リポジトリにコミットし、チームで共有): + +```bash +mkdir -p .claude/commands +curl -o .claude/commands/open-code-review.md \ + https://raw.githubusercontent.com/alibaba/open-code-review/main/plugins/open-code-review/commands/review.md +``` + +**ユーザーレベル**(マシン上のすべてのプロジェクトで利用可能): + +```bash +mkdir -p ~/.claude/commands +curl -o ~/.claude/commands/open-code-review.md \ + https://raw.githubusercontent.com/alibaba/open-code-review/main/plugins/open-code-review/commands/review.md +``` + +### コマンドをサポートするその他の agent + +コマンドファイルは単一の frontmatter フィールドを持つ純粋な markdown です——Claude Code 固有の内容は一切含まれていません。あなたの agent が同様の **command** 規約(ディレクトリから呼び出し可能なコマンドとしてロードされる markdown prompt)をサポートしている場合、上記のファイルコピー方法がインストール経路になります。`open-code-review.md` を agent がコマンドを読み込むディレクトリに配置し、agent のコマンド呼び出し方法に従って呼び出してください。prompt 本文は agent に依存しません——モデルに対して、どの `ocr` 引数を選び、出力をどのように分類するかを伝えるだけです。 + +> **前提条件:** 初回実行時、バイナリが `PATH` 上に存在しない場合、コマンドは +> (`npm install -g @alibaba-group/open-code-review` を通じて)`ocr` CLI を自動的にインストールします。ただし、LLM は事前に設定しておく**必要があります**——`ocr llm test` が接続できない場合、コマンドは失敗します。[設定](../../configuration/)を参照してください。 + +## 使い方 + +Claude Code でコマンドを名前で呼び出します。plugin marketplace 経由でインストールした場合は `/open-code-review:review` を、ファイルを直接コピーした場合は `/open-code-review` を使います。 + +``` +/open-code-review:review +/open-code-review:review review this PR against main +/open-code-review:review focus on race conditions in commit abc123 +``` + +prompt はあなたのリクエストを解析し、正しい `ocr review` 引数を選択します。引数なし → 作業領域モード(staged + unstaged + untracked)、commit の言及 → `--commit`、ブランチ区間の言及 → +`--from` / `--to`。OCR の引数を直接透過的に渡すこともできます +(例:`/open-code-review:review --commit abc123` や `--from main --to feature`)。 + +## コマンドが行うこと + +コマンドの prompt はとても短く、3 ステップです。 + +1. **レビューの実行。** あなたのリクエストから推論した引数を用いて `ocr review --audience agent` + を呼び出します(要件コンテキストが記述されている場合はオプションの `--background` を追加)。`ocr` バイナリが `PATH` 上に無い場合、コマンドは `npm i -g @alibaba-group/open-code-review` を通じて自動インストールして続行します。出力は 5 分のタイムアウト内で取得されます。 +2. **フィルタリングと評価。** 各コメントを **High** / **Medium** / **Low** に分類します。低信頼度(誤検知の疑い、些細な指摘、コンテキスト不足)のコメントは黙って破棄され、その他は表示されます。 +3. **修正。** 採用すべき High/Medium 項目に対して修正を自動的に適用します。 + [Agent Skill](../agent-skill/) と異なり、このコマンドは**デフォルトで自動修正します**——「レビューして片付ける」ワークフローには適した選択であり、「diff を見せてほしい」ワークフローには向きません。 + +コマンドがコードを変更する前に尋ねるようにしたい場合や、分類基準を厳しくしたい場合は、ローカルの prompt コピーを編集してください。Claude Code は呼び出しのたびにコマンドを読み直すため、再起動は不要です。 + +## 関連項目 + +- [Agent Skill](../agent-skill/)——SDK レベルの同等物。同じ底層の CLI で、デフォルト値が異なります(修正前に尋ねる)。 +- [Direct Subprocess](../subprocess/)——slash コマンドを介さず、自分で CLI を呼び出す方法。 diff --git a/pages/src/content/docs/ja/integrations/subprocess.md b/pages/src/content/docs/ja/integrations/subprocess.md new file mode 100644 index 0000000..4f3427b --- /dev/null +++ b/pages/src/content/docs/ja/integrations/subprocess.md @@ -0,0 +1,155 @@ +--- +title: Direct Subprocess +sidebar: + order: 3 +--- + +shell を通じて `ocr` を呼び出し、JSON を解析します。これは最も低レベルの統合経路です——本サイトの他の方法はすべて最終的にこれに帰着します。[Agent Skill](../agent-skill/) と [Command](../claude-code/) の方法は、呼び出し側の agent にこの作業を行わせるための prompt テンプレートです。[CI/CD](../ci/) のレシピは、スクリプトから同じことを行う GitHub Actions と GitLab CI のパイプラインです——agent のオーケストレーションは関与せず、サブプロセス呼び出し、JSON 解析、コメントの PR / MR への貼り戻しのみです。カスタムスクリプト、LangChain ツール、その他まだカバーされていないフレームワークから OCR を呼び出す場合は、このページを直接使ってください。 + +## Bash + +```bash +result=$(ocr review --format json --audience agent) +status=$(echo "$result" | jq -r '.status') +total=$(echo "$result" | jq '.comments | length') +echo "Status: $status — $total comments" +echo "$result" | jq -r '.comments[] | "\(.path):\(.start_line) — \(.content)"' +``` + +## Python + +```python +import json, subprocess + +proc = subprocess.run( + ["ocr", "review", "--format", "json", "--audience", "agent", + "--from", "origin/main", "--to", "HEAD", + "--background", pr_description], + capture_output=True, text=True, check=True, +) +data = json.loads(proc.stdout) +for c in data["comments"]: + if c["start_line"] > 0: + post_line_comment(c["path"], c["start_line"], c["content"]) +``` + +## JSON の構造 + +OCR は単一のトップレベル**オブジェクト**を出力します(裸の配列ではありません)。以下は、1 件の発見を含む完全な `success` の外殻です。 + +```json +{ + "status": "success", + "summary": { + "files_reviewed": 1, + "comments": 1, + "total_tokens": 12770, + "input_tokens": 12450, + "output_tokens": 320, + "elapsed": "9s" + }, + "comments": [ + { + "path": "internal/cache/store.go", + "content": "Concurrent map access without a lock — wrap reads and writes with `sync.RWMutex` to avoid a race on the shared cache.", + "start_line": 42, + "end_line": 47, + "existing_code": "func (s *Store) Get(k string) string {\n return s.m[k]\n}", + "suggestion_code": "func (s *Store) Get(k string) string {\n s.mu.RLock()\n defer s.mu.RUnlock()\n return s.m[k]\n}", + "thinking": "The struct exposes `m map[string]string` without a guarding mutex, and Get/Set are called from concurrent request handlers." + } + ] +} +``` + +### トップレベルのフィールド + +| フィールド | 型 | 常に存在 | 説明 | +|---|---|---|---| +| `status` | string | はい | `success`、`completed_with_warnings`、`completed_with_errors`、`skipped` のいずれか。 | +| `message` | string | いいえ | 短い人間可読のサマリー。空の実行やスキップ時に設定されます(例:`"No comments generated. Looks good to me."`)。 | +| `summary` | object | いいえ | 実行の集計。完了した実行時に存在し、`skipped` 時は省略されます。フィールドは下記を参照。 | +| `comments` | array | はい | 空の場合があります。各コメントのスキーマは下記を参照。 | +| `warnings` | array | いいえ | 1 つ以上のサブ agent が失敗またはスキップされた場合にのみ存在します。スキーマは下記を参照。 | + +### summary の構造(`summary`) + +| フィールド | 型 | 説明 | +|---|---|---| +| `files_reviewed` | int | すべてのフィルタを通過し、モデルに送られたファイル数。 | +| `comments` | int | すべてのファイルにわたって出力されたコメントの総数(`comments.length` と一致)。 | +| `total_tokens` | int | 実行中の各 LLM 呼び出しの prompt + completion token の合計。 | +| `input_tokens` | int | 各 LLM 呼び出しの prompt token(キャッシュ読み取り token を含む)。 | +| `output_tokens` | int | 各 LLM 呼び出しの completion token(キャッシュ書き込み token を含む)。 | +| `cache_read_tokens` | int | 各 LLM 呼び出しのキャッシュ読み取り token の総数。ゼロの場合は省略されます(`omitempty`)。 | +| `cache_write_tokens` | int | 各 LLM 呼び出しのキャッシュ書き込み token の総数。ゼロの場合は省略されます(`omitempty`)。 | +| `elapsed` | string | 経過時間(実時間)。秒単位に丸められ、Go の `time.Duration.String()` によってフォーマットされます(例:`"1m12s"`)。 | + +### 各コメントのフィールド(`comments[]`) + +| フィールド | 型 | 常に存在 | 説明 | +|---|---|---|---| +| `path` | string | はい | リポジトリ相対のファイルパス。 | +| `content` | string | はい | レビューコメント(Markdown)。 | +| `start_line` | int | はい | 影響範囲の先頭行。値が `< 1` の場合、コメントに行アンカーが無いこと(ファイルレベル)を意味します——これらはインラインで貼り付けようとせず、サマリーにまとめるべきです。 | +| `end_line` | int | はい | 影響範囲の末尾行。単一行コメントの場合は `start_line` と等しくなります。 | +| `existing_code` | string | いいえ | 置き換え対象の元のコード片。diff を伴わない提案的コメントの場合は省略されます。 | +| `suggestion_code` | string | いいえ | `existing_code` の提案された置き換え。存在する場合は常に `existing_code` とペアになります。 | +| `thinking` | string | いいえ | モデルの推論の軌跡。分類やデバッグに有用です。ユーザーに表示する前に安全に破棄できます。 | + +### warnings の構造(`warnings[]`) + +スキップまたは一部のファイルが失敗した実行は、次のような形になります。 + +```json +{ + "status": "completed_with_errors", + "message": "Some files could not be reviewed due to errors.", + "comments": [], + "warnings": [ + { + "file": "src/very_long_file.go", + "message": "diff size exceeds 80% of MAX_TOKENS; skipped", + "type": "token_threshold_exceeded" + }, + { + "file": "src/broken.py", + "message": "sub-agent failed: context deadline exceeded", + "type": "subtask_error" + } + ] +} +``` + +| フィールド | 型 | 説明 | +|---|---|---| +| `file` | string | 警告を引き起こしたファイルのリポジトリ相対パス。 | +| `message` | string | 短い人間可読の説明。 | +| `type` | string | フィルタリング用の安定した型。現在出力されるもの:`subtask_error`(サブ agent の実行失敗)と `token_threshold_exceeded`(diff がモデルにとって大きすぎる)。 | + +`warnings` が少なくとも 1 つの `subtask_error` を含む場合、`status` は +`completed_with_errors` になります。そうでない場合は `completed_with_warnings` です。 + +### severity / priority フィールドは無い + +OCR は `severity` や `priority` フィールドを**出力しません**。[Agent Skill](../agent-skill/) +と [Command](../claude-code/) のドキュメントで見られる High/Medium/Low の分類は、呼び出し側の agent が生のコメントを受け取った後に追加したものです——`jq '.comments[].severity'` を試みないでください。それは存在しません。 + +## 空の結果の扱い + +**対象となるファイルが無い**作業領域は `status` で報告されるため、呼び出し側は「変更なし」と「発見なし」を区別できます。 + +```json +{ + "status": "skipped", + "message": "No supported files changed.", + "comments": [] +} +``` + +「すべてクリーン」と判断する前に、必ず `status == "skipped"` を確認してください。 + +## 関連項目 + +- [CI/CD](../ci/)——サブプロセス呼び出しの上に構築された、すぐ使える GitHub Actions と pre-commit のレシピ。 +- [Agent Skill](../agent-skill/)——呼び出し側が通常のスクリプトではなく Anthropic SDK agent の場合。 diff --git a/pages/src/content/docs/ja/overview.md b/pages/src/content/docs/ja/overview.md new file mode 100644 index 0000000..526149f --- /dev/null +++ b/pages/src/content/docs/ja/overview.md @@ -0,0 +1,126 @@ +--- +title: 概要 +sidebar: + order: 2 +--- + +## Open Code Review とは? + +Open Code Review(略称 **OCR**。光学式文字認識 Optical Character +Recognition とは異なります)は、AI 駆動のコードレビュー CLI であり、 +[`@alibaba-group/open-code-review`](https://www.npmjs.com/package/@alibaba-group/open-code-review) +NPM パッケージおよびスタンドアロンの Go バイナリとして配布されています。CLI バイナリ名は `ocr` です。 + +たった 1 つのコマンド(`ocr review`)で、以下を実行します。 + +1. Git diff を解析します——ワークスペース、ブランチ区間、または単一の commit。 +2. システムのデフォルトルールとユーザールールを組み合わせて変更ファイルをフィルタリングします。 +3. 変更ファイルごとに **per-file サブ agent** を並行で起動します。 +4. 各サブ agent は LLM のツール呼び出しループを実行します。大きな diff に対しては、オプションでまず + **plan フェーズ** を実行できます。 +5. モデルは `code_comment` を呼び出して発見事項を記録し、オプションで `file_read`、 + `code_search`、`file_find`、`file_read_diff` を呼び出してコンテキストを収集し、完了後に + `task_done` を呼び出します。 +6. OCR は各コメントを正確な行番号に解決し、正確にマッチできなかったコメントに対しては + オプションの再配置フローを実行し、最終的なリストを出力(または JSON で出力)します。 + +## 汎用 agent の問題 + +汎用のコーディング agent(Claude Code の Skill、Cursor、Cline など)でコード +レビューを行ったことがあるなら、次のような経験をしたことがあるでしょう。 + +- **カバレッジ不足**——大きな変更セットでは、agent がひそかに手を抜き、一部のファイルしかレビューしません。 +- **位置ずれ**——コメントが指し示すコードと一致しません。行番号やファイルパスが対象からずれます。 +- **品質が不安定**——自然言語ベースの Skill はデバッグが難しく、prompt のわずかな変更で + 出力品質が変動します。 + +根本的な原因は、純粋な言語駆動のアーキテクチャにはレビュープロセスに対する **ハードな制約** が欠けていることです。 + +## コア設計:決定論的エンジニアリング × agent + +OCR のコアとなる考え方は、**決定論的エンジニアリング** と **agent** を組み合わせ、それぞれが最も得意とすることを担わせることです。 + +### 決定論的エンジニアリング——ハードな制約 + +*絶対に間違えてはならない* ステップについては、モデルではなくエンジニアリングロジックによって正しさを保証します。 + +- **正確なファイル選択**——[5 段階のフィルタゲート](../review-rules/#how-files-are-filtered) + がどのファイルをレビューするかを決定し、明示的な `include`/`exclude` 制御を提供します。 +- **スマートなファイルのパッケージング**——関連するファイル(例:`message_en.properties` と + `message_zh.properties`)を 1 つのレビュー単位にまとめられます。各パッケージは独立したコンテキストとして + サブ agent に渡されて実行されます——分割統治により、超大規模な変更セットでも安定し、並行レビューを自然にサポートします。 +- **きめ細かなルールマッチング**——レビュールールはファイルパスでマッチし、最初にマッチしたものが有効になります。これによりモデルの注意を + 高度に集中させ、ノイズを排除します。テンプレートベースのマッチングは、純粋な言語駆動のルール誘導よりも安定しています。 +- **外部の配置・リフレクションモジュール**——独立したコメント配置 + ([`internal/diff/relocation.go`](https://github.com/alibaba/open-code-review/blob/main/internal/diff/relocation.go)) + と再配置フローにより、位置と内容の正確性を体系的に向上させます。 + +### Agent——動的な意思決定 + +agent の強みは、最も重要な部分に集約されます。 + +- **シーン特化でチューニングされた prompt**——コードレビューのシーンに向けて深くチューニングされた prompt + テンプレートにより、token 消費を抑えつつ効果を高めます( + [`internal/config/template/task_template.json`](https://github.com/alibaba/open-code-review/blob/main/internal/config/template/task_template.json) + を参照)。 +- **シーン特化でチューニングされたツールセット**——大規模な本番データのツール呼び出し trace 分析から抽出されました + (呼び出し頻度の分布、単一ツールの繰り返し率、各ツールが呼び出しチェーン全体に与える影響)。最終的に得られた + 専用の [6 ツール](../tools/) セットは、汎用 agent のツールキットよりも安定していて予測可能です。 + +## パイプラインの連携 + +```mermaid +flowchart TD + Start["ocr review --from main --to feature"] + S1["1. Resolve LLM endpoint
config / env / shell rc"] + S2["2. Load diffs from git
workspace / commit / range"] + S3["3. Filter files
binary → user_exclude → user_include
→ ext allowlist → default path"] + S4["4. Drop diffs > 80% of MAX_TOKENS"] + S5["5. Dispatch per-file sub-agents (concurrent)

For each file:
  a. Plan phase (if changed lines ≥ 50)
  b. Main loop: LLM → tool calls → … → task_done
  c. code_comment results collected (async via worker pool)

Memory compression triggers when context
exceeds 60 % (async) or 80 % (sync) of MAX_TOKENS."] + S6["6. Resolve line numbers
from existing_code against diffs.
Re-locate via LLM if needed."] + S7["7. Emit text or JSON output
(and persist session to disk)"] + + Start --> S1 --> S2 --> S3 --> S4 --> S5 --> S6 --> S7 +``` + +## プロジェクト構成 + +``` +open-code-review/ +├── cmd/opencodereview/ # CLI エントリ:ディスパッチ、引数、コマンド +├── internal/ +│ ├── agent/ # ファイルごとのサブ agent ループ + メモリ圧縮 +│ ├── config/ +│ │ ├── allowlist/ # デフォルトのファイル拡張子ホワイトリストと除外項目 +│ │ ├── rules/ # 階層型ルールパーサー、システムルールドキュメント +│ │ ├── template/ # plan / main / memory_compression prompt +│ │ ├── testconnection/ # 組み込みの `ocr llm test` タスク +│ │ └── toolsconfig/ # モデルに送信するツール定義 +│ ├── diff/ # Git diff 解析、hunk 計算、再配置 +│ ├── gitcmd/ # Git サブプロセスランナー +│ ├── llm/ # Anthropic + OpenAI プロトコル、リトライ、BPE token +│ ├── model/ # diff / コメント のデータ構造 +│ ├── pathutil/ # パスユーティリティ +│ ├── release/ # Release notes 生成 +│ ├── session/ # レビューセッションごとの JSONL 永続化 +│ ├── stdout/ # `--audience agent` 下でミュート可能な stdout writer +│ ├── suggestdiff/ # "Apply suggestion" diff の構築 +│ ├── telemetry/ # OpenTelemetry span、metrics、exporter +│ ├── tool/ # 6 つの組み込みツール + コメントコレクター +│ └── viewer/ # `ocr viewer`——過去のセッション用のローカル Web UI +├── pages/ # React ベースのマーケティング用ランディングページ(独立) +├── plugins/ # Claude Code プラグインマニフェスト + コマンド +├── extensions/ # エディタ拡張(VS Code) +├── examples/ # CI レシピ(GitHub Actions、GitLab CI) +├── skills/ # 汎用 agent Skill マニフェスト +├── scripts/ # NPM インストール/更新ヘルパー、リリーススクリプト +├── npm/ # 各プラットフォーム向け optional dependency パッケージ +└── bin/ # NPM wrapper、シェルからバイナリを呼び出す +``` + +## 関連項目 + +- [クイックスタート](../quickstart/)——インストールして初回のレビューを完了します。 +- [アーキテクチャ](../architecture/)——agent ループ、plan フェーズ、メモリ圧縮。 +- [CLI リファレンス](../cli-reference/)——各引数とサブコマンド。 +- [インテグレーション](../integrations/)——Claude Code や任意の agent から OCR を呼び出します。 diff --git a/pages/src/content/docs/ja/quickstart.md b/pages/src/content/docs/ja/quickstart.md new file mode 100644 index 0000000..759fbbb --- /dev/null +++ b/pages/src/content/docs/ja/quickstart.md @@ -0,0 +1,235 @@ +--- +title: クイックスタート +sidebar: + order: 3 +--- + +OCR をインストールし、Anthropic Messages API または OpenAI Chat Completions API +に対応した任意の LLM に接続して、初回のコードレビューを実行しましょう。 + +## 前提条件 + +- 動作する **Git** のインストール——OCR は Git をサブプロセスとして駆動し diff を読み取ります。 +- Anthropic または OpenAI 互換の LLM の **API key**。 +- 以下のいずれか: + - **Node.js ≥ 18**(推奨。最低サポートは Node 14——NPM 経由でインストール)。 + - または `curl` + `chmod` だけで静的バイナリを `$PATH` に配置。 + - またはソースからビルドしたい場合は **Go ≥ 1.25**。 + +## ステップ 1——CLI をインストールする + +### 方法 A:NPM(推奨) + +```bash +npm install -g @alibaba-group/open-code-review +``` + +NPM パッケージは小さな wrapper をインストールし、インストール時に(postinstall hook を通じて)お使いの +OS / アーキテクチャ向けの正しいバイナリをダウンロードします。実行時にバイナリが存在しない場合、wrapper はエラーを出し、 +ダウンロードは行いません。インストール後、グローバルな `ocr` コマンドが利用できます。 + +```bash +ocr --version +``` + +### 方法 B:GitHub Release バイナリ + +[releases ページ](https://github.com/alibaba/open-code-review/releases) +から対応プラットフォームのバイナリを選び、`$PATH` に配置します。 + +```bash +# macOS (Apple Silicon) +curl -Lo ocr https://github.com/alibaba/open-code-review/releases/latest/download/opencodereview-darwin-arm64 +chmod +x ocr && sudo mv ocr /usr/local/bin/ocr + +# macOS (Intel) +curl -Lo ocr https://github.com/alibaba/open-code-review/releases/latest/download/opencodereview-darwin-amd64 +chmod +x ocr && sudo mv ocr /usr/local/bin/ocr + +# Linux x86_64 +curl -Lo ocr https://github.com/alibaba/open-code-review/releases/latest/download/opencodereview-linux-amd64 +chmod +x ocr && sudo mv ocr /usr/local/bin/ocr + +# Linux ARM64 +curl -Lo ocr https://github.com/alibaba/open-code-review/releases/latest/download/opencodereview-linux-arm64 +chmod +x ocr && sudo mv ocr /usr/local/bin/ocr + +# Windows (AMD64) +curl -Lo ocr.exe https://github.com/alibaba/open-code-review/releases/latest/download/opencodereview-windows-amd64.exe + +# Windows (ARM64) +curl -Lo ocr.exe https://github.com/alibaba/open-code-review/releases/latest/download/opencodereview-windows-arm64.exe +``` + +### 方法 C:ソースからビルドする + +```bash +git clone https://github.com/alibaba/open-code-review.git +cd open-code-review +make build +sudo cp dist/opencodereview /usr/local/bin/ocr +``` + +> 各インストール方法の詳細は [インストール](../installation/) を参照してください。NPM wrapper が +> プラットフォームバイナリをどのように解決するかも含まれています。 + +## ステップ 2——LLM を設定する + +完全な LLM エンドポイント(URL + token + model)を解決できるまで、OCR はレビューの実行を拒否します。 +以下の優先順位で 4 つの来源を探索します。 + +1. `~/.opencodereview/config.json` +2. OCR 専用の環境変数(`OCR_LLM_*`) +3. Claude Code の環境変数(`ANTHROPIC_*`) +4. シェルの rc ファイル(`~/.zshrc`、`~/.bashrc`、`~/.bash_profile`、 + `~/.profile`)から解析された `export ANTHROPIC_*` 行 + +### 最速の経路:`ocr config set` + +```bash +ocr config set llm.url https://api.anthropic.com/v1/messages +ocr config set llm.auth_token sk-ant-xxxxxxxxxx +ocr config set llm.model claude-opus-4-6 +ocr config set llm.use_anthropic true +``` + +これらの値は `~/.opencodereview/config.json` に永続化されます。 + +### 代替方法:環境変数 + +優先度が最も高く、ディスクに設定ファイルを残したくない CI / コンテナに適しています。 + +```bash +export OCR_LLM_URL=https://api.anthropic.com/v1/messages +export OCR_LLM_TOKEN=sk-ant-xxxxxxxxxx +export OCR_LLM_MODEL=claude-opus-4-6 +export OCR_USE_ANTHROPIC=true # デフォルトは true。false にすると OpenAI プロトコルを使用 +``` + +### すでに Claude Code を使っている場合 + +OCR は Claude Code が使用するのと同じ変数群を自動的に読み取るため、追加の設定は不要です。 + +```bash +export ANTHROPIC_BASE_URL=https://api.anthropic.com +export ANTHROPIC_AUTH_TOKEN=sk-ant-xxxxxxxxxx +export ANTHROPIC_MODEL=claude-opus-4-6 +``` + +`ANTHROPIC_BASE_URL` にバージョン付きのパスがない場合、OCR は自動的に +`/v1/messages` を追加します。 + +### OpenAI 互換エンドポイントを使う場合 + +`llm.use_anthropic` を `false` に設定します(または `OCR_USE_ANTHROPIC=false`)。 + +```bash +ocr config set llm.url https://api.openai.com/v1/chat/completions +ocr config set llm.auth_token sk-xxxxxxxxxx +ocr config set llm.model gpt-4o +ocr config set llm.use_anthropic false +``` + +> 完全な key のリファレンスは [設定](../configuration/) を参照してください。ベンダー固有のリクエストフィールド +> 用の `llm.extra_body` や、レビューコメントの言語を切り替える `language` も含まれています。 + +## ステップ 3——接続性をテストする + +```bash +ocr llm test +``` + +期待される出力(モデル名は異なります): + +``` +Source: OCR config file +URL: https://api.anthropic.com/v1/messages +Model: claude-opus-4-6 +Hello! … +``` + +代わりに `no valid LLM endpoint configured` のようなエラーが出た場合は、上記の +設定 key を再確認してください。401 / 403 は token が誤っているか期限切れであることを示します。 + +## ステップ 4——初回のレビューを実行する + +任意の Git リポジトリに移動して実行します。 + +```bash +cd path/to/your-repo + +# ワークスペースモード——staged + unstaged + untracked の変更をレビュー(デフォルト) +ocr review + +# ブランチ区間——`main..feature-branch` をレビュー +ocr review --from main --to feature-branch + +# 単一 commit——その commit が導入した diff をレビュー +ocr review --commit abc123 +``` + +進捗情報が継続的に出力され、最後に各ファイルに 1 つ以上のレビューコメントが表示されます。 + +> ワークスペースモードには **untracked** ファイルが含まれます。すでにステージされた内容だけをレビューしたい場合は、まず +> `git add` で選択的にステージしてください。 + +> 上記の 3 つは基本的な使い方です。`ocr review` の完全な引数(並行数のチューニング、出力形式、 +> audience モード、背景コンテキストなど)と、その他すべてのサブコマンド(`config`、`rules`、 +> `llm test`、`viewer`)は [CLI リファレンス](../cli-reference/) を参照してください。 + +### 先に *何が* レビューされるか見てみたい場合 + +```bash +ocr review --preview # ワークスペース +ocr review -c abc123 -p # commit +``` + +`--preview` は各フィルタステップを実行しますが LLM は一切呼び出さないため、token を消費しません。ファイルリストと +各ファイルのステータス(`added` / `modified` / `deleted` / `renamed` / `binary`)を出力し、 +除外されたファイルについてはその理由(`binary`、`unsupported_ext`、`default_path`、 +`user_exclude`、`deleted`)も示します。 + +### ツール向けの JSON 出力 + +```bash +ocr review --format json --audience agent > review.json +``` + +- `--format json` は機械可読なコメント配列を出力します。各コメントには `path`、`content`、 + `start_line`、`end_line`、`existing_code`、`suggestion_code`、およびオプションの + `thinking` が含まれます。 +- `--audience agent` は人間向けの進捗 UI を抑制し、stdout を JSON / 最終 + サマリーだけにします——上流の agent や CI スクリプトが必要とするものです。 + +## ステップ 5——結果を確認する + +各コメントには以下が含まれます。 + +| フィールド | 意味 | +|---|---| +| `path` | そのコメントが対象とするファイル。 | +| `content` | レビューコメント本体。設定された `language` を使用します。 | +| `start_line` / `end_line` | ファイルの **新しい** バージョンにおける行範囲。両方が `0` の場合は OCR がコメントを正確に配置できなかったことを意味します——問題は本物ですが、正確な位置は自分で特定する必要があります。 | +| `existing_code` | コメントが指す diff の断片。内部的に行解決に使われます。`start_line` が `0` のときに役立ちます。 | +| `suggestion_code` | オプションの修正断片。 | +| `thinking` | オプションのモデルの推論。一部のモデルにのみ存在します。 | + +## ステップ 6——過去のセッションを閲覧する + +各レビューは JSONL トランスクリプトとして +`~/.opencodereview/sessions/...` に永続化されます。ローカルの Web UI でそれらを閲覧できます。 + +```bash +ocr viewer # http://localhost:5483 +ocr viewer --addr :3000 +``` + +> UI の完全な紹介は [セッションビューア](../viewer/) を参照してください。 + +## 関連項目 + +- [CLI リファレンス](../cli-reference/)——各サブコマンド、引数、出力モード。 +- [レビュールール](../review-rules/)——レビュー内容をカスタマイズします。 +- [インテグレーション](../integrations/)——OCR を Claude Code、Agent skill、CI に組み込みます。 +- [テレメトリ](../telemetry/)——OTLP 経由で trace と metrics を送信します。 +- [FAQ](../faq/)——既知のエラーと対策。 diff --git a/pages/src/content/docs/ja/review-rules.md b/pages/src/content/docs/ja/review-rules.md new file mode 100644 index 0000000..bf1f8f0 --- /dev/null +++ b/pages/src/content/docs/ja/review-rules.md @@ -0,0 +1,217 @@ +--- +title: レビュールール +sidebar: + order: 7 +--- + +ルールは、各ファイルをレビューする際に OCR が**何に注目すべきか**を伝えます。ルールは 3 層の JSON ファイルに格納され、加えてバイナリに同梱される埋め込みのシステムデフォルトルールがあります。 + +## 優先順位チェーン + +OCR は**4 層の優先順位チェーン**でルールを解決します。各ファイルパスについて、層を順に試し、最初に一致したパターンが有効になります。 + +| 優先順位 | 出所 | パス | 説明 | +|---|---|---|---| +| 1(最高) | `--rule` 引数 | ユーザー指定 | CLI による上書き。指定されている限り常に有効になります。 | +| 2 | プロジェクト設定 | `/.opencodereview/rule.json` | プロジェクトレベルのルール。安全に commit できます。 | +| 3 | グローバル設定 | `~/.opencodereview/rule.json` | ユーザーレベルの好み。 | +| 4(最低) | システムデフォルト | 埋め込み `system_rules.json` | 一般的な言語をカバーする組み込みルール。 | + +より高い優先順位の層のファイルが存在しない場合は静かにスキップされます。エラーではありません。したがって `.opencodereview/rule.json` を一度も追加していないプロジェクトは、そのままグローバル / システム層に落ちます。 + +システム層は**常に**存在するため(バイナリに同梱)、必ず*何らかの*ルールが解決されます。 + +## ルールファイル形式(層 1〜3) + +```json +{ + "include": ["src/**/*.{ts,tsx}", "src/**/*.go"], + "exclude": ["**/*.test.ts", "**/generated/**"], + "rules": [ + { + "path": "src/api/**/*.go", + "rule": "All exported handlers must validate request bodies before use." + }, + { + "path": "**/*mapper*.xml", + "rule": "Check SQL for injection risks, parameter errors, and missing closing tags." + } + ] +} +``` + +3 つの独立したフィールドがあります: + +- `include`: 任意。組み込みのデフォルト除外パターン(テストファイルの除外。下記参照)を*バイパス*するための glob パターンです。ホワイトリストではありません。どの `include` パターンにも一致しないファイルも、依然として `unsupported_ext` と `default_path` のチェックを通過し、レビューされる可能性があります。 +- `exclude`: 任意。OCR がレビューしないファイルの glob パターンです。フィルタリングで最も優先されます。 +- `rules`: `{path, rule}` エントリの配列で、**宣言順**に評価されます。そのファイルに最初に一致した `path` glob のエントリが、OCR がモデルに送る prompt を決定します。 + +### glob の機能 + +OCR は [`bmatcuk/doublestar/v4`](https://pkg.go.dev/github.com/bmatcuk/doublestar/v4) でマッチングを行います: + +- `*`: `/` 以外の任意の文字に一致します。 +- `**`: ディレクトリ境界をまたいで一致します(`src/**/*.go` は任意の深さをカバー)。 +- `{a,b,c}`: 波括弧の展開。`*.{ts,tsx,js,jsx}` は 4 つのパターンに展開され、順に一致が試されます。 +- `?`: 単一の文字に一致します。 +- `[abc]`: 文字クラス。 + +> パターンマッチングは**大文字小文字を区別しません**(マッチング前にファイルパスは小文字化されます)。確信が持てないときは `ocr rules check ` で確認してください。 + +## ファイルがどのようにフィルタリングされるか + +フィルタリングは 5 段階のゲートアルゴリズムで、[`internal/agent/preview.go`](https://github.com/alibaba/open-code-review/blob/main/internal/agent/preview.go) にあります。各 diff について、OCR は順に次を問います: + +1. **`binary`**: ファイルはバイナリか? 除外します。 +2. **`user_exclude`**: パスがいずれかのユーザー `exclude` パターンに一致するか? 除外します。 +3. **`user_include`**: ユーザーが `include` を定義している場合、パスは一致するか? 一致するなら**即座に保持**します(下記の `unsupported_ext` と `default_path` のゲートをバイパス)。 +4. **`unsupported_ext`**: ファイルの拡張子は[ホワイトリスト](https://github.com/alibaba/open-code-review/blob/main/internal/config/allowlist/supported_file_types.json)にあるか? なければ除外します。 +5. **`default_path`**: パスがいずれかの組み込みテストファイル除外パターン(`**/*_test.go`、`**/*.test.{js,jsx,ts,tsx}`、`**/*_spec.rb`……)に一致するか? 除外します。 + +5 つのゲートをすべて通過したファイルだけが LLM に送られます。`deleted` の理由(これはゲートではなく、`Preview()` の中で個別に計算されます)は、新しいパスが `/dev/null` であるファイルを示します。レビューすべき新しい内容がありません。`ocr review --preview` を使えば、token を消費せずにこのフィルタリング結果を出力できます。 + +### デフォルトパスの除外 + +組み込みの除外リスト([`internal/config/allowlist/default_exclude_patterns.json`](https://github.com/alibaba/open-code-review/blob/main/internal/config/allowlist/default_exclude_patterns.json) を参照)は、テストファイルのパターンに一致します: + +- `**/*_test.go` +- `**/src/test/java/**/*.java` +- `**/src/test/**/*.kt` +- `**/*.test.{js,jsx,ts,tsx}` +- `**/*.spec.{js,jsx,ts,tsx}` +- `**/__tests__/**` +- `**/test/**/*_test.py` +- `**/tests/**/*_test.py` +- `**/*_test.py` +- `**/*_spec.rb` +- `**/spec/**/*_spec.rb` +- `**/*Test.java` +- `**/*Tests.java` +- `**/*_test.rs` +- `**/oh_modules/**` +- `**/*.test.ets` + +ノイズディレクトリのフィルタリング(`vendor/`、`node_modules/`、`target/`……)は、より早い段階、[`internal/diff/git.go`](https://github.com/alibaba/open-code-review/blob/main/internal/diff/git.go) の diff 層で発生し、ファイルごとのフィルタリングより先に実行されます。 + +これらのテストファイルパターンに一致するファイルを**レビューする**には、それをユーザー `include` リストに追加してください。それが default-path ゲートを上書きします。 + +## ファイルごとのルール解決 + +フィルタリングによってあるファイルが*レビューされる*と決まったあと、OCR は agent が従うべきルールテキストを選びます: + +1. 宣言順に `--rule`(custom)層を試します。 +2. 宣言順に `/.opencodereview/rule.json` を試します。 +3. 宣言順に `~/.opencodereview/rule.json` を試します。 +4. 埋め込みのシステムルール層にフォールバックします。 + +埋め込みの `system_rules.json` には次のパターンが同梱されています(順序どおり): + +| パターン | ルールドキュメント | +|---|---| +| `**/*.properties` | `properties.md`: i18n / 設定ファイル。 | +| `**/*{mapper,dao}*.xml` | `mapper_dao_xml.md`: MyBatis 形式の mapper SQL。 | +| `**/pom.xml` | `pom_xml.md`: Maven 依存関係。 | +| `**/build.gradle` | `build_gradle.md`: Gradle 依存関係。 | +| `**/package.json` | `package_json.md`: NPM 依存関係 / スクリプト。 | +| `**/Cargo.toml` | `cargo_toml.md`: Rust manifest。 | +| `**/*.{json,json5}` | `json.md`: 汎用 JSON(`.json5` にも一致)。 | +| `.github/workflows/**/*.{yaml,yml}` | `github_workflows.md`: GitHub Actions ワークフロー YAML。 | +| `.github/**/*.{yaml,yml}` | `github_config.md`: その他の `.github` 設定 YAML。 | +| `**/*.{yaml,yml}` | `yaml.md` | +| `**/*.java` | `java.md` | +| `**/*.ets` | `arkts.md`: ArkTS / HarmonyOS。 | +| `**/*.{ts,js,tsx,jsx}` | `ts_js_tsx_jsx.md` | +| `**/*.{kt}` | `kotlin.md` | +| `**/*.rs` | `rust.md` | +| `**/*.{cpp,cc,hpp}` | `cpp.md` | +| `**/*.c` | `c.md` | +| *(fallback)* | `default.md` | + +解決されたルール本文は、plan および main task prompt 内の `{{system_rule}}` プレースホルダーの内容になります。 + +## どのルールが有効かを確認する: `ocr rules check` + +```bash +$ ocr rules check src/main/java/com/example/UserService.java +File: src/main/java/com/example/UserService.java +Source: System built-in +Pattern: **/*.java +Rule: +──────────────────────────────────────── +…contents of java.md… +──────────────────────────────────────── +``` + +```bash +$ ocr rules check --rule custom.json src/main/resources/mapper/UserMapper.xml +File: src/main/resources/mapper/UserMapper.xml +Source: Custom (--rule) +Pattern: **/*mapper*.xml +Rule: +──────────────────────────────────────── +…contents of your custom rule… +──────────────────────────────────────── +``` + +あるルールが期待どおりに有効にならないときに使用してください。有効な**層**と**パターン**を表示します。 + +## レシピ + +### プロジェクトレベル: コーディング規約を強制する + +`/.opencodereview/rule.json` として保存し、commit します: + +```json +{ + "rules": [ + { + "path": "src/api/**/*.go", + "rule": "Every public handler must `defer tx.Rollback()` immediately after starting a transaction." + }, + { + "path": "**/*mapper*.xml", + "rule": "Check SQL for injection risks, missing parameter binding, and unclosed XML tags." + } + ] +} +``` + +### プロジェクトレベル: 生成コードをスキップし、src に集中する + +```json +{ + "include": ["src/**/*.{ts,tsx,js,jsx}"], + "exclude": ["**/*.gen.ts", "**/generated/**"] +} +``` + +`include` を設定すると、`src/` 内のファイルは、本来は組み込みのデフォルト除外パターン(テストファイルなど)で除外されるものであっても保持されます。`src/` 以外のファイルは依然として通常の ext / default チェックを通ります。`include` はバイパスの仕組みであり、ホワイトリストではありません。 + +### PR ごとの上書き + +```bash +ocr review --rule ./.review-rules-only-for-this-pr.json +``` + +プロジェクト層とグローバル層の両方を同時にバイパスします。単一の PR が完全に異なるレビューチェックリスト(例: セキュリティレビューのみ)を必要とするときに便利です。 + +### グローバルな個人設定 + +`~/.opencodereview/rule.json` に置くと、自分のマシン上のすべてのリポジトリが継承します: + +```json +{ + "rules": [ + { + "path": "**/*.{ts,tsx,js,jsx}", + "rule": "Always check for unhandled promise rejections; warn on `// eslint-disable` without a reason comment." + } + ] +} +``` + +## 関連項目 + +- [CLI リファレンス](../cli-reference/): `ocr review --rule`、`--preview`、`ocr rules check`。 +- [設定](../configuration/): config ファイルの場所と階層的な解決チェーン。 +- [アーキテクチャ](../architecture/): 解決されたルールがどのように agent prompt に供給されるか。 diff --git a/pages/src/content/docs/ja/telemetry.md b/pages/src/content/docs/ja/telemetry.md new file mode 100644 index 0000000..c999e25 --- /dev/null +++ b/pages/src/content/docs/ja/telemetry.md @@ -0,0 +1,258 @@ +--- +title: テレメトリ +sidebar: + order: 11 +--- + +OCR には第一級の **OpenTelemetry** サポートが付属しています。各レビュー実行は、構造化された span、 +metric、event を生成します。collector に接続すれば、これらのデータは「agent は時間をどこに費やしたか?」、 +「各モデルのコストはどうか?」、「なぜこの実行は失敗したか?」に答えるのに十分です。 + +## 概要 + +テレメトリは**デフォルトで無効**です。有効にすると、OCR は以下をエクスポートします: + +- **Span**——3 つのパイプラインレベルの span(`review.run`、`diff.parse`、 + `subtask.execute.`)に加え、各決定ポイントのイベントごとに短命な `event.*` span を 1 つ。 +- **Metric**——レビュー所要時間、レビューされたファイル数、生成されたコメント数、LLM リクエスト / token / レイテンシ、 + ツール呼び出し / レイテンシの集約カウントとヒストグラム。 +- **Event**——span 内の離散的なイベント。`plan.skipped`、 + `token.threshold.exceeded`、`review.started` など。 + +2 種類の exporter がサポートされています: + +| Exporter | 使用する場面 | +|---|---| +| `console` | 個人利用 / デバッグ。span を整形して stdout に出力します。 | +| `otlp` | システム統合。任意の OTLP 互換 collector(Jaeger、Tempo、OTel Collector、Datadog Agent……)に送信します。 | + +## テレメトリを有効にする + +LLM エンドポイントと同様に、テレメトリは永続化された config または環境変数で設定できます——競合する場合は環境変数が優先されます。 + +### 設定ファイルによる方法 + +```bash +ocr config set telemetry.enabled true +ocr config set telemetry.exporter otlp +ocr config set telemetry.otlp_endpoint localhost:4317 +ocr config set telemetry.content_logging false +``` + +`~/.opencodereview/config.json` での結果: + +```json +{ + "telemetry": { + "enabled": true, + "exporter": "otlp", + "otlp_endpoint": "localhost:4317", + "content_logging": false + } +} +``` + +### 環境変数による方法 + +```bash +export OCR_ENABLE_TELEMETRY=1 +export OTEL_EXPORTER_OTLP_ENDPOINT=localhost:4317 # implies exporter=otlp +export OTEL_EXPORTER_OTLP_PROTOCOL=grpc # default. NOTE: only grpc is currently + # implemented; http/protobuf and http/json + # are accepted but not yet wired up. +export OTEL_SERVICE_NAME=open-code-review-prod # optional; default: open-code-review +export OCR_CONTENT_LOGGING=0 # reserved / currently a no-op (see Content logging) +``` + +`OTEL_EXPORTER_OTLP_ENDPOINT` を設定すると `exporter=otlp` も強制されます——一度きりの +`OTEL_EXPORTER_OTLP_ENDPOINT=… ocr review` 実行に適しています。 + +## 何がエクスポートされるか + +### Span + +1 回のレビューの完全な span ツリー: + +``` +review.run +├── diff.parse +├── event.review.started (decision-point event) +├── subtask.execute. +│ ├── event.plan.skipped (when changes are below threshold) +│ ├── event.plan.failed (when plan phase errored) +│ ├── event.token.threshold.exceeded (when prompt > 80% of max_tokens) +│ └── event.subtask.error (when the subtask errored) +├── subtask.execute. +└── … +``` + +LLM の往復とツールの実行は個別の span としては発行**されません**——metric(下記参照)にのみ現れます。 +決定ポイントのイベントは、短命な `event.` span として現在の context にアタッチされます。 + +各 span は有用な属性を保持します: + +| Span | 主要な属性 | +|---|---| +| `review.run` | `error`(実行失敗時に設定) | +| `diff.parse` | `files.changed`、`lines.inserted`、`lines.deleted` | +| `subtask.execute.` | `file.path`、`lines.changed`、`lines.inserted`、`lines.deleted` | +| `event.review.started` | `file.count`、`review.count`、`repo.dir` | +| `event.plan.skipped` | `file.path`、`lines.changed`、`threshold` | +| `event.plan.failed` | `file.path`、`message` | +| `event.token.threshold.exceeded` | `file.path`、`tokens`、`max_tokens` | +| `event.subtask.error` | `file.path`、`error` | + +### Metric + +OCR は OTel meter を通じて数値 metric を記録します——カウントとヒストグラムで、collector が下流で集約します: + +| Metric | 種類 | 単位 | ラベル | +|---|---|---|---| +| `ocr.review.duration_seconds` | histogram | `s` | — | +| `ocr.files_reviewed_total` | counter | — | — | +| `ocr.comments_generated_total` | counter | — | — | +| `ocr.llm.requests_total` | counter | — | `model`、`status`(`ok` / `error`) | +| `ocr.llm.request_duration_seconds` | histogram | `s` | `model` | +| `ocr.llm.tokens_used` | counter | — | `model`、`type`(現在は常に `total`) | +| `ocr.tool.calls_total` | counter | — | `tool.name`、`status`(`ok` / `error`) | +| `ocr.tool.execution_duration_seconds` | histogram | `s` | `tool.name` | + +### Event + +イベントは決定ポイントで短命な `event.` span としてトリガーされます。完全な一覧: + +| イベント | 意味 | +|---|---| +| `review.started` | diff がロードされた。何ファイルをレビューするか判明している。 | +| `no.files.changed` | diff の解析で 0 ファイルとなった。 | +| `plan.skipped` | あるファイルが `PLAN_MODE_LINE_THRESHOLD` を下回った。 | +| `plan.failed` | plan フェーズでエラー。main ループは plan なしで実行される。 | +| `token.threshold.exceeded` | 初期 prompt token が `MAX_TOKENS` の 80 % を超えた。ファイルはスキップされる。 | +| `subtask.error` | あるファイルごとのサブタスクでエラー——`Error` span ステータスとして発行される。 | + +これにより、ユーザーが気づく前に、レビュー品質の低下を早期に検出してアラートを出すことができます。 + +## コンテンツログ + +テレメトリは LLM トラフィックの**形状**(カウント、所要時間、ステータス)をエクスポートしますが、実際の prompt や +レスポンスは**決して**エクスポートしません。OCR は LLM のメッセージ内容を span や event に付加しようとはしません——プロセスを離れるデータは上記に +記載された metric / event schema に限られ、それ以外は一切ありません。 + +`content_logging` の config key(および `OCR_CONTENT_LOGGING=1` の環境上書き)は設定レイヤーに接続されていますが、 +現時点では prompt 内容を発出するいかなるコードパスも**制御しません**。このフラグは予約済みとみなしてください。 + +LLM に送信された、または LLM から返された内容を検査する必要がある場合は、[セッションビューア](../viewer/)が読み取るローカルの +JSONL トランスクリプトを使用してください。これらは完全に `~/.opencodereview/` 配下のディスク上に存在し、決して collector に送られません。 + +## レシピ + +### ローカルデバッグ用の console exporter + +```bash +ocr config set telemetry.enabled true +ocr config set telemetry.exporter console +ocr review --commit HEAD +``` + +span が人間可読な形式で stdout に出力されます。長い実行の出力を見るには `less` にパイプできます。 + +### OTel Collector + Tempo + Prometheus + +```yaml +# otel-collector-config.yaml +receivers: + otlp: + protocols: { grpc: { endpoint: 0.0.0.0:4317 } } + +exporters: + otlp/tempo: + endpoint: tempo:4317 + tls: { insecure: true } + prometheus: + endpoint: 0.0.0.0:9464 + +service: + pipelines: + traces: { receivers: [otlp], exporters: [otlp/tempo] } + metrics: { receivers: [otlp], exporters: [prometheus] } +``` + +その後、shell で: + +```bash +export OCR_ENABLE_TELEMETRY=1 +export OTEL_EXPORTER_OTLP_ENDPOINT=localhost:4317 +ocr review --from main --to feature/branch +``` + +Tempo を開く → `service.name=open-code-review` で検索 → 任意の trace をクリックして完全な +span ツリーを表示。 + +### Datadog + +Datadog Agent の OTLP receiver はデフォルトで OTLP/gRPC を使用します: + +```bash +export OCR_ENABLE_TELEMETRY=1 +export OTEL_EXPORTER_OTLP_ENDPOINT=localhost:4317 +export OTEL_SERVICE_NAME=open-code-review +``` + +span はその service name で APM 配下に現れます。LLM metric は上記のラベル付きで Metrics 配下に現れます。 + +### CI 実行、結果をダッシュボードへ + +パイプラインステップで環境変数を注入します: + +```yaml +- name: Code review + env: + OCR_LLM_URL: ${{ secrets.OCR_LLM_URL }} + OCR_LLM_TOKEN: ${{ secrets.OCR_LLM_TOKEN }} + OCR_LLM_MODEL: claude-opus-4-6 + OCR_ENABLE_TELEMETRY: "1" + OTEL_EXPORTER_OTLP_ENDPOINT: ${{ vars.OTEL_COLLECTOR_URL }} + OTEL_SERVICE_NAME: open-code-review-ci + run: ocr review --from origin/main --to HEAD --audience agent +``` + +`OTEL_SERVICE_NAME` により、CI の trace を手動の開発実行の trace と区別できます。 + +## 解決の優先順位 + +OCR が最終的なテレメトリ設定を構築する際: + +1. デフォルト(`enabled=false`、`exporter=console`、endpoint なし)。 +2. `~/.opencodereview/config.json` の `telemetry.*` key。 +3. 環境変数(最高優先度、ファイルを**上書き**)。 + +したがって、config では `telemetry.enabled=false` を保持したまま、実行ごとに +`OCR_ENABLE_TELEMETRY=1` で有効にできます。 + +## サンプリングとオーバーヘッド + +OCR は**すべて**をエクスポートします。サンプリングの設定はありません。OTel のサンプリングは collector の責務です。典型的なレビュー +実行の場合: + +- 1 つの `review.run` span + 1 つの `diff.parse` span + レビューされたファイルごとに 1 つの + `subtask.execute.` span + 各決定ポイントのイベントごとに 1 つの短命な `event.*` span。 +- 10 ファイルの PR で合計およそ 15〜25 個の span。LLM の往復とツール呼び出しは metric のカウントを増やしますが、 + 追加の span は作成しません。 + +エクスポートは**バッチ処理かつ非同期**です——テレメトリはレビューループをブロックしません。collector に到達できない場合、OCR は警告を記録して +続行します。レビューは引き続き通常の出力を生成します。 + +## トラブルシューティング + +| 症状 | 考えられる原因 | +|---|---| +| 何もエクスポートされない | `OCR_ENABLE_TELEMETRY` / `telemetry.enabled` が未設定。デフォルトは**無効**。 | +| OTLP がローカルでは動くが本番で失敗する | OCR は現在 OTLP/gRPC のみを実装している——`OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf`(または `http/json`)は受理されるが接続されておらず、切り替えても効果はない。endpoint と、collector が gRPC をリッスンしているかを確認すること。 | +| span は表示されるが metric がない | 一部の collector はデフォルトで traces pipeline のみを有効にする。設定に `metrics` pipeline を追加すること。 | +| span に prompt がない | OCR は prompt の内容をテレメトリに付加することは決してない——[コンテンツログ](#content-logging)を参照。代わりに[セッションビューア](../viewer/)を使ってトランスクリプトを検査すること。 | + +## 関連項目 + +- [設定](../configuration/)——`telemetry.*` 名前空間の完全な key リファレンス。 +- [アーキテクチャ](../architecture/)——各 span が実際に何を計測するか。 +- [OpenTelemetry ドキュメント](https://opentelemetry.io/docs/)——collector のセットアップと exporter。 diff --git a/pages/src/content/docs/ja/tools.md b/pages/src/content/docs/ja/tools.md new file mode 100644 index 0000000..cb87bfa --- /dev/null +++ b/pages/src/content/docs/ja/tools.md @@ -0,0 +1,353 @@ +--- +title: ツール +sidebar: + order: 9 +--- + +OCR には、レビュー中に LLM が呼び出すための **6 つのツール**が組み込まれています。本ページでは、各ツールの用途、入力 +schema、および入出力の例を記載します。完全な機械可読の定義は +[`internal/config/toolsconfig/tools.json`](https://github.com/alibaba/open-code-review/blob/main/internal/config/toolsconfig/tools.json) +にあります。 + +## 各フェーズでのツールの利用可否 + +各ツールは、**plan フェーズ**、**main task**、あるいはその両方のどこで利用できるかを宣言します: + +| ツール | Plan | Main | 用途 | +|---|---|---|---| +| `task_done` | ✗ | ✓ | 「完了しました」を示す——ループを終了します。 | +| `code_comment` | ✗ | ✓ | 行範囲 + 提案を伴うレビューコメントを 1 件発行します。 | +| `file_read` | ✗ | ✓ | 変更後のスナップショット内のあるファイルの一部を読み取ります。 | +| `file_read_diff` | ✓ | ✓ | 別のファイルの diff を読み取り、ファイル横断の懸念を確認します。 | +| `file_find` | ✓ | ✓ | ファイル名のキーワードでファイルを特定します。 | +| `code_search` | ✓ | ✓ | リポジトリ全体の grep(リテラルまたは正規表現)。 | + +`task_done` と `code_comment` は plan フェーズでは意図的に**利用できません**:plan は読み取り専用です。 + +> **コンテキストツールは読み取り専用のコンテキストであり、コメント対象ではありません。** `main_task` prompt は、 +> *他の*ファイル内の発見についてコメントすることを明確に禁止しています。`file_read`、`file_read_diff`、`file_find`、 +> `code_search` が存在するのは、モデルが現在のファイルの diff をよりよく理解するためであり——そのコンテキストを収集する際に +> 見つかったいかなる問題も、設計上無視されます。ファイル横断の懸念は、**現在のファイルの diff** で観測可能な場合にのみ +> コメントとして現れます。 + +ツールレジストリを上書きするには、組み込みの定義と同じ形式の JSON ファイルパスを `--tools ` で渡します。 +これにより、ツールを無効化したり、説明を編集したり、既存の provider をベースに新しいツールを追加したりできます。 + +## `task_done` + +main ループを終了します。 + +```json +{ + "name": "task_done", + "input": { "state": "DONE" } +} +``` + +| フィールド | 必須 | 意味 | +|---|---|---| +| `state` | はい | `DONE`(デフォルト)または `FAILED`。`FAILED` は「利用可能なツールでは本当に完了できない」ことを示します——ほとんど常に正しい選択ではありません。 | + +agent は `task_done` を受け取ると、LLM の呼び出しを停止し、累積した `code_comment` +呼び出しの処理を開始します。`task_done` は(結果がセッションログに記録される前に)即座に返されるため、`state` の値は受理されますが +**永続化されず**——終了コードにも影響しません。 + +## `code_comment` + +1 件または複数のレビューコメントを発行します。各コメントはコードスニペット(`existing_code`)にアンカーされ、 +OCR が行番号を自動計算できるようにします。 + +### Schema + +```json +{ + "name": "code_comment", + "input": { + "path": "string — optional, override the file path for this comment", + "comments": [ + { + "content": "string — the comment in the configured language", + "existing_code": "string — snippet from the diff to anchor on", + "suggestion_code": "string — optional fix snippet", + "thinking": "string — optional, the model's reasoning for this comment" + } + ] + } +} +``` + +`comments` は配列なので、モデルは 1 回のツール呼び出しで複数のコメントを発行できます。`content` と +`existing_code` は必須です。`suggestion_code` は任意ですが、提供が推奨されます。`path` はトップレベルの任意の上書きで—— +省略すると、agent は現在レビュー中のファイルを注入します。モデルが省略しても agent が自動的に `path` を注入するため、 +モデルが明示的に設定する必要はほとんどありません。`thinking`(コメントごと)はモデルの推論を捕捉し、コメントに保持されますが、 +最終的なレビュー出力には表示されません。 + +> **`thinking` はランタイム専用フィールドです。** OCR はこれを解析して保存しますが、モデルに渡す +> `code_comment` schema には意図的に**含めていません**(`tools.json` には `content`、 +> `existing_code`、`suggestion_code` のみがあります)。より高性能なモデルが依然として `thinking` ブロックを発行した場合も +> 永続化されます。ほとんどのモデルは発行しませんが、問題ありません。 + +### アンカーアルゴリズム + +OCR は**動的なスライディングウィンドウ**を使って diff 内で `existing_code` テキストを検索します。マッチは順に試行されます: + +1. **hunk の新側**——連続する **context + added** 行(deleted のみでも unchanged のみでもない)の一群で、 + 新ファイルの行番号を得ます。失敗した場合、OCR は **hunk の旧側**——context + + deleted 行——を再試行し、旧ファイルの行番号を得ます。 +2. **ファイル全体の新規スキャン**——hunk マッチがない場合、OCR は変更後のファイル全体を 1 行ずつスキャンして連続マッチを探します + (`resolveFromFileContent`)。 +3. **再位置特定タスク**——より複雑な diff でテキストマッチが依然として失敗した場合、OCR は + `RE_LOCATION_TASK` prompt を実行し、モデルにスニペットの再アンカーを依頼します。 + +マッチは**空白に対して非依存**です:比較前に行を trim し、diff の `+`/`-` マーカーを取り除くため、インデントが +正確に一致する必要はありません。最後の手段として、コメントは `start_line=0` で配信され、ユーザーに「問題は本物だが自分で位置を特定してほしい」と伝えます。 + +### 例 + +```json +{ + "comments": [ + { + "content": "`tx.Rollback()` is never deferred — early returns leak the transaction.", + "existing_code": "tx, err := db.Begin()\nif err != nil {\n return err\n}", + "suggestion_code": "tx, err := db.Begin()\nif err != nil {\n return err\n}\ndefer tx.Rollback()" + } + ] +} +``` + +## `file_read` + +ファイルの**変更後**の形式で一定範囲の行を読み取ります。 + +### Schema + +```json +{ + "name": "file_read", + "input": { + "file_path": "src/foo.go", + "start_line": 10, + "end_line": 80 + } +} +``` + +| フィールド | 必須 | デフォルト | 説明 | +|---|---|---|---| +| `file_path` | はい | — | リポジトリルートからの相対パス。 | +| `start_line` | いいえ | `1` | 1 始まりのインデックス。 | +| `end_line` | いいえ | ファイル末尾 | 端点を含む。 | + +### 出力 + +``` +File: src/foo.go (Total lines: 220) +IS_TRUNCATED: false +LINE_RANGE: 10-80 +10|package foo +11| +12|import ( +13| "fmt" +… +``` + +各行の内容には、1 始まりの行番号と `|` 区切り文字が前置され、モデルが後続の `code_comment` 呼び出しで +行番号を正確に参照できるようにします。 + +### 制限 + +- **1 回の呼び出しで最大 500 行。** より大きな範囲は切り詰められ、`IS_TRUNCATED: true` が設定され、 + `Note: Results truncated to 500 lines. Please narrow your line range.` が追記されます。 +- ファイルの**変更後バージョン**のみを読み取ります。旧バージョンを見るには `file_read_diff` を使用します。 + +モデルが周辺のコンテキストを必要とする場合(diff 内でしか見えない関数についてコメントする際など)、diff の +hunk ヘッダー `@@ -x,y +m,n @@` から範囲を計算すべきです——通常は `m-50` から `m+n+50` まで。 + +## `file_read_diff` + +同一の変更セット内の 1 つまたは複数の*他の*ファイルの diff を読み取ります——コメントが関連ファイルの +更新有無に依存する場合に有用です。 + +### Schema + +```json +{ + "name": "file_read_diff", + "input": { + "path_array": ["src/api/handler.go", "src/db/queries.go"] + } +} +``` + +### 出力 + +``` +==== FILE: src/api/handler.go ==== +--- a/src/api/handler.go ++++ b/src/api/handler.go +@@ -10,1 +10,2 @@ +- old line ++ new line 1 ++ new line 2 + +==== FILE: src/db/queries.go ==== +@@ -5,1 +5,1 @@ +- query := "SELECT *" ++ query := "SELECT id" +``` + +あるパスが変更セットに含まれていない場合、そのエントリは静かに省略されます。要求されたパスが**いずれも**変更セットに含まれていない場合、ツールは +`Error: diff not found for the requested paths` を返します。空の `path_array` は +`Error: no files found` を返します。 + +## `file_find` + +ファイル名のキーワード(部分文字列マッチ)でリポジトリ内のファイルを検索します。 + +### Schema + +```json +{ + "name": "file_find", + "input": { + "query_name": "UserService", + "case_sensitive": false + } +} +``` + +| フィールド | 必須 | デフォルト | 説明 | +|---|---|---|---| +| `query_name` | はい | — | 各ファイルの **basename**(最後の `/` より後の部分)に対して部分文字列マッチを行い、フルパスには行いません。 | +| `case_sensitive` | いいえ | `false` | `true` に設定すると大文字小文字を厳密に区別してマッチします。 | + +候補セットは、ワークスペースモードでは `git ls-files --cached --others --exclude-standard` から、 +区間 / commit モードでは `git ls-tree -r --name-only ` から得られます。拡張子のないファイルは +スキップされますが、`Makefile`、`Dockerfile`、`LICENSE`、`Vagrantfile`、 +`Containerfile` は例外です。 + +### 出力 + +改行区切りのパスのリスト: + +``` +src/main/java/com/example/UserService.java +src/test/java/com/example/UserServiceTest.java +src/main/java/com/example/internal/UserServiceImpl.java +``` + +マッチするファイルがない場合(または `query_name` が空の場合)、ツールはリテラル文字列 +`// The file was not found` を返します。 + +### 制限 + +最大 **100** 件のマッチを返します。超過分は静かに切り詰められます。モデルがより広範な検索を必要とする場合は、 +`code_search` を使用すべきです。 + +## `code_search` + +リポジトリ全体の全文検索。`git grep` によって駆動されるため、`pathspec` 構文を理解し、 +`.gitignore` に従います。 + +### Schema + +```json +{ + "name": "code_search", + "input": { + "search_text": "TODO|FIXME", + "file_patterns": ["*.go", ":(exclude)vendor/"], + "case_sensitive": false, + "use_perl_regexp": true + } +} +``` + +| フィールド | 必須 | デフォルト | 説明 | +|---|---|---|---| +| `search_text` | はい | — | リテラル文字列または PCRE パターン(`use_perl_regexp` を参照)。 | +| `file_patterns` | いいえ | リポジトリ全体 | pathspec エントリの配列。除外には `:(exclude)pat` を使用します。 | +| `case_sensitive` | いいえ | `false` | — | +| `use_perl_regexp` | いいえ | `false` | `true` の場合、`search_text` は正規表現として扱われます。 | + +### 出力 + +結果はファイルごとにグループ化されます。各グループは `File: ` と `Match lines: ` で始まり、続いて各ヒットが +`line|content` の 1 行で表されます: + +``` +File: path/to/example.java +Match lines: 2 +433| String name = toolRequest.get().getName(); +438| logToolRequest(newPath, tool, toolRequest.get()); + +File: path/to/other.java +Match lines: 1 +22| var req = new ToolRequest(); +``` + +マッチがない場合、ツールはリテラル文字列 `No matches found` を返します。 + +### pathspec クイックリファレンス + +| 目的 | `file_patterns` | +|---|---| +| 単一ファイル | `["src/main.go"]` | +| すべての Go ファイル | `["*.go"]` | +| テストを除くすべての Go | `["*.go", ":(exclude)*_test.go"]` | +| 単一のディレクトリのみ | `["src/api/"]` | +| 複数の種類、vendor を除外 | `["*.go", "*.ts", ":(exclude)vendor/", ":(exclude)node_modules/"]` | + +### 制限 + +- `git grep --max-count 100` によってファイルごとのヒット数上限を **100** に設定するため、複数ファイルにまたがる + 合計出力は 100 を超える可能性があります。ファイルごとの上限に達した場合、出力の前に + `Note: The results have been truncated. Only showing first 100 results.` が付加されます。 +- 空 / 空白のみの `search_text` は、各行に展開されるのではなく `Error: search_text is blank` を返します。 +- ワークスペースモードは**現在のワークツリー**を検索し、区間 / commit モードは解決された対象の ref を検索します + (`FileReader.Ref` が位置引数として `git grep` に渡されます)。 + +## ツールの実行とエラー + +ツールは agent ループ内で同期的に実行されますが、2 つの例外があります: + +- `code_comment` は **CommentWorkerPool** にディスパッチされ、ループが行の解析 + リフレクションでブロックしないようにします。 +- `task_done` はショートサーキットします——即座に返され、いかなる provider も呼び出しません。 + +ツールがエラーになった場合(ネットワーク障害、引数の形式エラー、ファイル未検出)、結果は通常のツール結果としてモデルに配信され、 +テキストは `"Error: file not found: src/missing.go"` のような形になります。モデルはその後、再試行するか、ファイルを変更するか、 +`task_done` を呼ぶかを決定します。 + +ツール名がレジストリに存在しない場合、OCR はクラッシュせず定数 `tool.NotAvailableMsg` を返します。これにより、 +(`--tools` を通じて)ランタイムでツールを無効化することが安全になります。 + +## ツールのカスタマイズ + +拡張方法は 2 つあります: + +### 1. ツールを無効化する + +`tools.json` をコピーし、不要なエントリを削除してから実行します: + +```bash +ocr review --tools ./my-tools.json +``` + +たとえば、追加のコンテキストを一切読み取らない「コメントのみ」のレビューアが欲しい場合は、`code_comment` と +`task_done` のみを残します。 + +### 2. ツールの説明を書き換える + +`name` は保持し(provider は内部で name で検索します)、`description` を変更してモデルを誘導します。これは +プロジェクト固有のガイダンスを注入する最も簡単な方法です——たとえば「`file_read` を使う際は、常に変更付近の少なくとも +30 行を読み取ること。」のように。 + +> **新しい**ツール*名*を追加するには Go 側での対応が必要です。`internal/tool/definitions.go` および +> `internal/tool/` 配下の provider を参照してください。JSON ファイルだけでは新しい動作を追加できません。 + +## 関連項目 + +- [アーキテクチャ](../architecture/)——agent ループがどのようにツールを駆動するか。 +- [レビュールール](../review-rules/)——LLM に何に注目すべきかを伝えます。 +- [セッションビューア](../viewer/)——過去のレビューで実際にどのツールがトリガーされたかを確認します。 diff --git a/pages/src/content/docs/ja/viewer.md b/pages/src/content/docs/ja/viewer.md new file mode 100644 index 0000000..4eea1f0 --- /dev/null +++ b/pages/src/content/docs/ja/viewer.md @@ -0,0 +1,151 @@ +--- +title: セッションビューア +sidebar: + order: 10 +--- + +`ocr viewer` は小型の組み込み HTTP サーバーで、過去のレビューセッションをブラウザで扱いやすい UI でレンダリングします。 +外部依存はありません——セッションは、OCR が各レビュー中にディスクに書き込む JSONL ファイルから直接読み取られます。 + +## 起動 + +```bash +ocr viewer # binds localhost:5483 +ocr viewer --addr :3000 # bind to all interfaces on port 3000 +ocr viewer --addr 0.0.0.0:8080 # bind on all interfaces +``` + +デフォルトのアドレスは `localhost:5483` です。サーバーはフォアグラウンドで実行されます——`Ctrl+C` で停止します。セッションは各リクエスト時に +`~/.opencodereview/sessions/` から遅延スキャンされるため、別のターミナルで実行中のレビューも、その +JSONL ファイルが現れ次第表示されます。 + +> **DNS リバインディング対策。** ビューアは `Host` ヘッダーをループバックのホワイトリスト +> (`localhost`、`127.0.0.1`、`::1`)と照合します。具体的なバインドホスト +> (`--addr 192.168.1.10:5483` など)は自動的に追加されますが、**ワイルドカード**バインド +> (`:3000`、`0.0.0.0`、`::`)は追加されません——この場合、LAN IP やホスト名から UI にアクセスすると +> `forbidden host` が返されます。ワイルドカードバインドをアクセス可能にするには、 +> `OCR_VIEWER_ALLOWED_HOSTS` にカンマ区切りの許可ホスト名リストを設定します +> (例:`OCR_VIEWER_ALLOWED_HOSTS=box.local,192.168.1.10`)。 + +## 3 つのページ + +ビューアには 3 つの URL があります: + +| URL | 表示内容 | +|---|---| +| `/` | ディスク上にセッションを持つすべてのリポジトリの一覧。 | +| `/r/{repo}` | 単一リポジトリのセッション一覧。最新が先頭。 | +| `/r/{repo}/{sessionID}` | 単一セッションの完全な詳細。 | + +`{repo}` はパスエンコードされた文字列です(区切り文字 `/` と `\` は `-` に、コロンは +`_` に置換されます——ディスク上のディレクトリ命名と同じエンコード)。通常は手動で入力することはなく——クリックして遷移します。 + +### `/`——リポジトリ一覧 + +少なくとも 1 件のセッションを持つ各リポジトリについて、リポジトリパス、総セッション数、最新のアクティビティのタイムスタンプを表示します。 + +### `/r/{repo}`——単一リポジトリのセッション一覧 + +各セッションについて:ID(UUID)、ブランチ名(OCR が検出できた場合)、レビューモード、モデル、ファイル数、 +所要時間、開始タイムスタンプ。 + +### `/r/{repo}/{sessionID}`——セッション詳細 + +詳細ページが最も有用です。以下を表示します: + +1. **ヘッダー**——diff 範囲、モデル、ブランチ、合計 token、実行時間。 +2. **ファイルごとのグループ**——レビューされた各ファイルにつき 1 ブロック。各ファイル内には、5 つの「タスクタイプ」のスイムレーン: + +| タスクタイプ | 出現条件 | +|---|---| +| `plan_task` | plan フェーズが実行された(ファイルが ≥ `PLAN_MODE_LINE_THRESHOLD`)。 | +| `main_task` | すべてのファイル。メインのレビューループ。 | +| `review_filter_task` | そのファイルに対してレビュー後のコメントフィルタリング処理が実行された。 | +| `memory_compression_task` | active+compress 領域が予算の 60 % / 80 % を超えた。 | +| `re_location_task` | ある `code_comment` がアンカーできず、フォールバックの再位置特定が実行された。 | + +各スイムレーンは**タスクカード**の水平ストリップです——LLM の往復 1 回につき 1 枚。カードはタスクタイプごとに色分けされており、 +どのフェーズが実行を支配したかを一目で把握できます。 + +## タスクカードの中身 + +タスクカードをクリックすると展開されます。各カードには以下があります: + +- 1 行の**ヘッダー**——リクエスト番号、モデルバッジ、token バッジ(`P:` prompt / `C:` completion、 + 存在する場合は `CR:` / `CW:` キャッシュの読み書きも表示)、所要時間バッジ、そしてそのラウンドが失敗した場合はエラーバッジ。 +- **Response**——生の assistant レスポンス。推論 / `thinking` ブロックも含む。 +- **Tool calls**——各ツール呼び出しとその引数 + 返された結果(折りたたみ可能)。 + +モデルに送信された完全なメッセージリストとスコープ内のツール定義は、カード UI には**レンダリングされません**。必要な場合は、直接 +JSONL トランスクリプト(各 `llm_request` レコードの `messages` フィールド)を検査してください。 + +## ユースケース + +ビューアは 3 つのワークフローを想定して設計されています: + +### 「なぜモデルはこう言ったのか?」 + +ターミナル出力であるコメントを開き、ビューアでそのファイルを見つけ、その `main_task` スイムレーンを下にたどります。 +**ツール呼び出し**の中に、あなたが気にしている `code_comment` を含むカードこそが、それを生み出したラウンドです。カードの +Response にはモデルの推論が表示されます。モデルに送信された prompt + コンテキストを正確に知るには、JSONL トランスクリプトで +そのリクエスト番号の `llm_request` レコード(その `messages` フィールド)を開いてください。 + +### 「なぜこのファイルは沈黙しているのか?」 + +**コメントのない**ファイルは、モデルが*能動的に* `task_done` を呼び出した場合にのみ成功したレビューです。スイムレーンに +ツール呼び出しはあるが `code_comment` がない場合、それはモデルが能動的に下したクリーンなレビューです。スイムレーンがエラーカードで終わっている場合、それは +沈黙を装った失敗です——警告として扱うべきです。 + +### 「圧縮は何を保持 / 破棄したのか?」 + +`memory_compression_task` スイムレーンは各圧縮ラウンドを表示します。その中で、Response ペインには結果の要約があり、 +圧縮された compress 領域からレンダリングされた XML は、そのラウンドの `llm_request` の `messages`(JSONL トランスクリプト内)にあります。 +「モデルが以前のコンテキストを忘れた」というフィードバックの調査に有用です——圧縮が関連する詳細を破棄したかどうかを確認できます。 + +## ディスクのストレージレイアウト + +ビューアは以下を読み取ります: + +``` +~/.opencodereview/sessions/ +└── / + └── .jsonl +``` + +JSONL ファイルの各行は 1 つのイベントです: + +```json +{"type": "llm_request", "filePath": "src/foo.go", "taskType": "main_task", "request_no": 1, "messages": [{"role": "user", "content": "Review this diff…"}], "timestamp": "2026-06-02T10:15:23Z"} +{"type": "llm_response", "filePath": "src/foo.go", "taskType": "main_task", "model": "claude-sonnet-4-6", "content": "Found 2 issues…", "duration_ms": 8421, "usage": {"prompt_tokens": 12450, "completion_tokens": 320}} +{"type": "tool_call", "filePath": "src/foo.go", "tool_name": "file_read", "arguments": "{\"file_path\":\"src/foo.go\",\"start_line\":1,\"end_line\":50}", "result": "File: src/foo.go (Total lines: 220)\nIS_TRUNCATED: false\nLINE_RANGE: 1-50\n1|package foo…", "ok": true, "duration_ms": 14} +``` + +行は追記専用(append-only)です——不完全な JSONL は、セッションが実行中に中断されたことを意味し、ビューアは書き込み済みの +内容をレンダリングします。 + +ディスク容量を解放するには、セッションファイル全体を削除します。ビューアは次のリクエスト時にインデックスを再構築します。 + +## プライバシー + +JSONL トランスクリプトには、LLM に送信され LLM から受信した**すべて**が含まれ、diff 内のあらゆるコードも含まれます。これらは +完全にあなたのマシンの `~/.opencodereview/` 内に存在します。OCR はそれらをどこにもアップロードしません。 + +レビューに長期保存したくないコードが含まれる場合は、以下が可能です: + +- 定期的にセッションファイルを削除する、または +- CI で `--audience agent --format json` の出力を一時的なパイプにリダイレクトし、一時的な + `HOME` で実行して JSONL が永続化されないようにする。 + +OpenTelemetry exporter は別の話です——prompt の内容をエクスポートされる trace に含めない方法については +[テレメトリ](../telemetry/)を参照してください。 + +## ビューアが*適さない*場合 + +- プログラムによる後処理(CI、ダッシュボード)には `ocr review --format json --audience agent` を使用します。 + ビューアは人間向けのレンダリングであり、機械向けではありません。 +- 複数セッションにまたがる grep が必要な場合は、JSONL ファイルに対して直接 `jq` を使用します。UI にはまだ検索ボックスがありません。 + +## 関連項目 + +- [アーキテクチャ](../architecture/)——それら 5 つのタスクタイプが内部で実際に何をするか。 +- [ツール](../tools/)——`main_task` カードで目にするツール呼び出し。 From 6ded4f3624e8ad2552ca14981bf266b5d7a9fd06 Mon Sep 17 00:00:00 2001 From: kite Date: Fri, 3 Jul 2026 14:37:33 +0800 Subject: [PATCH 53/87] docs(pages): remove pipeline and project layout sections from overview (#284) --- pages/src/content/docs/en/overview.md | 51 --------------------------- pages/src/content/docs/ja/overview.md | 51 --------------------------- pages/src/content/docs/zh/overview.md | 51 --------------------------- 3 files changed, 153 deletions(-) diff --git a/pages/src/content/docs/en/overview.md b/pages/src/content/docs/en/overview.md index d47800f..ee1601c 100644 --- a/pages/src/content/docs/en/overview.md +++ b/pages/src/content/docs/en/overview.md @@ -80,57 +80,6 @@ The agent's strengths are concentrated where they matter most: result is a purpose-built set of [six tools](../tools/) that is more stable and predictable than a generic agent toolkit. -## How the pipeline fits together - -```mermaid -flowchart TD - Start["ocr review --from main --to feature"] - S1["1. Resolve LLM endpoint
config / env / shell rc"] - S2["2. Load diffs from git
workspace / commit / range"] - S3["3. Filter files
binary → user_exclude → user_include
→ ext allowlist → default path"] - S4["4. Drop diffs > 80% of MAX_TOKENS"] - S5["5. Dispatch per-file sub-agents (concurrent)

For each file:
  a. Plan phase (if changed lines ≥ 50)
  b. Main loop: LLM → tool calls → … → task_done
  c. code_comment results collected (async via worker pool)

Memory compression triggers when context
exceeds 60 % (async) or 80 % (sync) of MAX_TOKENS."] - S6["6. Resolve line numbers
from existing_code against diffs.
Re-locate via LLM if needed."] - S7["7. Emit text or JSON output
(and persist session to disk)"] - - Start --> S1 --> S2 --> S3 --> S4 --> S5 --> S6 --> S7 -``` - -## Project layout - -``` -open-code-review/ -├── cmd/opencodereview/ # CLI entry point: dispatch, flags, commands -├── internal/ -│ ├── agent/ # Per-file sub-agent loop + memory compression -│ ├── config/ -│ │ ├── allowlist/ # Default file-extension allowlist & exclusions -│ │ ├── rules/ # Layered rule resolver, system rule docs -│ │ ├── template/ # Plan / main / memory_compression prompts -│ │ ├── testconnection/ # Built-in `ocr llm test` task -│ │ └── toolsconfig/ # Tool definitions sent to the model -│ ├── diff/ # Git diff parsing, hunk math, relocation -│ ├── gitcmd/ # Git subprocess runner -│ ├── llm/ # Anthropic + OpenAI protocols, retries, BPE tokens -│ ├── model/ # Diff / Comment data structures -│ ├── pathutil/ # Path utilities -│ ├── release/ # Release-notes generation -│ ├── session/ # JSONL persistence of every review session -│ ├── stdout/ # Quiet-able stdout writer for `--audience agent` -│ ├── suggestdiff/ # Build "Apply suggestion" diffs -│ ├── telemetry/ # OpenTelemetry spans, metrics, exporters -│ ├── tool/ # The six built-in tools + comment collector -│ └── viewer/ # `ocr viewer` — local web UI for past sessions -├── pages/ # React-based marketing landing page (separate) -├── plugins/ # Claude Code plugin manifest + commands -├── extensions/ # Editor extensions (VS Code) -├── examples/ # CI recipes (GitHub Actions, GitLab CI) -├── skills/ # Generic agent Skill manifest -├── scripts/ # NPM install/update helpers, publish scripts -├── npm/ # Per-platform optional dependency packages -└── bin/ # NPM wrapper that shells out to the binary -``` - ## See Also - [QuickStart](../quickstart/) — install and run your first review. diff --git a/pages/src/content/docs/ja/overview.md b/pages/src/content/docs/ja/overview.md index 526149f..fcb602e 100644 --- a/pages/src/content/docs/ja/overview.md +++ b/pages/src/content/docs/ja/overview.md @@ -67,57 +67,6 @@ agent の強みは、最も重要な部分に集約されます。 (呼び出し頻度の分布、単一ツールの繰り返し率、各ツールが呼び出しチェーン全体に与える影響)。最終的に得られた 専用の [6 ツール](../tools/) セットは、汎用 agent のツールキットよりも安定していて予測可能です。 -## パイプラインの連携 - -```mermaid -flowchart TD - Start["ocr review --from main --to feature"] - S1["1. Resolve LLM endpoint
config / env / shell rc"] - S2["2. Load diffs from git
workspace / commit / range"] - S3["3. Filter files
binary → user_exclude → user_include
→ ext allowlist → default path"] - S4["4. Drop diffs > 80% of MAX_TOKENS"] - S5["5. Dispatch per-file sub-agents (concurrent)

For each file:
  a. Plan phase (if changed lines ≥ 50)
  b. Main loop: LLM → tool calls → … → task_done
  c. code_comment results collected (async via worker pool)

Memory compression triggers when context
exceeds 60 % (async) or 80 % (sync) of MAX_TOKENS."] - S6["6. Resolve line numbers
from existing_code against diffs.
Re-locate via LLM if needed."] - S7["7. Emit text or JSON output
(and persist session to disk)"] - - Start --> S1 --> S2 --> S3 --> S4 --> S5 --> S6 --> S7 -``` - -## プロジェクト構成 - -``` -open-code-review/ -├── cmd/opencodereview/ # CLI エントリ:ディスパッチ、引数、コマンド -├── internal/ -│ ├── agent/ # ファイルごとのサブ agent ループ + メモリ圧縮 -│ ├── config/ -│ │ ├── allowlist/ # デフォルトのファイル拡張子ホワイトリストと除外項目 -│ │ ├── rules/ # 階層型ルールパーサー、システムルールドキュメント -│ │ ├── template/ # plan / main / memory_compression prompt -│ │ ├── testconnection/ # 組み込みの `ocr llm test` タスク -│ │ └── toolsconfig/ # モデルに送信するツール定義 -│ ├── diff/ # Git diff 解析、hunk 計算、再配置 -│ ├── gitcmd/ # Git サブプロセスランナー -│ ├── llm/ # Anthropic + OpenAI プロトコル、リトライ、BPE token -│ ├── model/ # diff / コメント のデータ構造 -│ ├── pathutil/ # パスユーティリティ -│ ├── release/ # Release notes 生成 -│ ├── session/ # レビューセッションごとの JSONL 永続化 -│ ├── stdout/ # `--audience agent` 下でミュート可能な stdout writer -│ ├── suggestdiff/ # "Apply suggestion" diff の構築 -│ ├── telemetry/ # OpenTelemetry span、metrics、exporter -│ ├── tool/ # 6 つの組み込みツール + コメントコレクター -│ └── viewer/ # `ocr viewer`——過去のセッション用のローカル Web UI -├── pages/ # React ベースのマーケティング用ランディングページ(独立) -├── plugins/ # Claude Code プラグインマニフェスト + コマンド -├── extensions/ # エディタ拡張(VS Code) -├── examples/ # CI レシピ(GitHub Actions、GitLab CI) -├── skills/ # 汎用 agent Skill マニフェスト -├── scripts/ # NPM インストール/更新ヘルパー、リリーススクリプト -├── npm/ # 各プラットフォーム向け optional dependency パッケージ -└── bin/ # NPM wrapper、シェルからバイナリを呼び出す -``` - ## 関連項目 - [クイックスタート](../quickstart/)——インストールして初回のレビューを完了します。 diff --git a/pages/src/content/docs/zh/overview.md b/pages/src/content/docs/zh/overview.md index 3f036cb..f9f6df4 100644 --- a/pages/src/content/docs/zh/overview.md +++ b/pages/src/content/docs/zh/overview.md @@ -66,57 +66,6 @@ agent 的优势集中在最关键的地方: (调用频次分布、单工具重复率、每个工具对整体调用链的影响)。最终得到一套 专用 [六工具](../tools/) 集,比通用 agent 工具包更稳定、更可预测。 -## 流水线如何衔接 - -```mermaid -flowchart TD - Start["ocr review --from main --to feature"] - S1["1. Resolve LLM endpoint
config / env / shell rc"] - S2["2. Load diffs from git
workspace / commit / range"] - S3["3. Filter files
binary → user_exclude → user_include
→ ext allowlist → default path"] - S4["4. Drop diffs > 80% of MAX_TOKENS"] - S5["5. Dispatch per-file sub-agents (concurrent)

For each file:
  a. Plan phase (if changed lines ≥ 50)
  b. Main loop: LLM → tool calls → … → task_done
  c. code_comment results collected (async via worker pool)

Memory compression triggers when context
exceeds 60 % (async) or 80 % (sync) of MAX_TOKENS."] - S6["6. Resolve line numbers
from existing_code against diffs.
Re-locate via LLM if needed."] - S7["7. Emit text or JSON output
(and persist session to disk)"] - - Start --> S1 --> S2 --> S3 --> S4 --> S5 --> S6 --> S7 -``` - -## 项目结构 - -``` -open-code-review/ -├── cmd/opencodereview/ # CLI 入口:分发、参数、命令 -├── internal/ -│ ├── agent/ # 每文件子 agent 循环 + 记忆压缩 -│ ├── config/ -│ │ ├── allowlist/ # 默认文件扩展名白名单与排除项 -│ │ ├── rules/ # 分层规则解析器、系统规则文档 -│ │ ├── template/ # plan / main / memory_compression prompt -│ │ ├── testconnection/ # 内置 `ocr llm test` 任务 -│ │ └── toolsconfig/ # 发送给模型的工具定义 -│ ├── diff/ # Git diff 解析、hunk 数学、重新定位 -│ ├── gitcmd/ # Git 子进程运行器 -│ ├── llm/ # Anthropic + OpenAI 协议、重试、BPE token -│ ├── model/ # diff / 评论 数据结构 -│ ├── pathutil/ # 路径工具 -│ ├── release/ # Release notes 生成 -│ ├── session/ # 每次评审会话的 JSONL 持久化 -│ ├── stdout/ # `--audience agent` 下可静音的 stdout writer -│ ├── suggestdiff/ # 构建 "Apply suggestion" diff -│ ├── telemetry/ # OpenTelemetry span、metrics、exporter -│ ├── tool/ # 六个内置工具 + 评论收集器 -│ └── viewer/ # `ocr viewer`——历史会话的本地 Web UI -├── pages/ # 基于 React 的营销落地页(独立) -├── plugins/ # Claude Code 插件清单 + 命令 -├── extensions/ # 编辑器扩展(VS Code) -├── examples/ # CI 配方(GitHub Actions、GitLab CI) -├── skills/ # 通用 agent Skill 清单 -├── scripts/ # NPM 安装/更新助手、发布脚本 -├── npm/ # 各平台 optional dependency 包 -└── bin/ # NPM wrapper,shell 调用二进制 -``` - ## 另见 - [快速开始](../quickstart/)——安装并完成首次评审。 From 8c9778860da4f06c038207c3c059cbe0d04a7b40 Mon Sep 17 00:00:00 2001 From: munsunouk <52026496+munsunouk@users.noreply.github.com> Date: Fri, 3 Jul 2026 09:47:14 +0200 Subject: [PATCH 54/87] fix: support Astro files in review filters (#286) --- internal/config/allowlist/allowed_ext_test.go | 2 ++ internal/config/allowlist/supported_file_types.json | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/config/allowlist/allowed_ext_test.go b/internal/config/allowlist/allowed_ext_test.go index 4230c8d..79b9891 100644 --- a/internal/config/allowlist/allowed_ext_test.go +++ b/internal/config/allowlist/allowed_ext_test.go @@ -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}, diff --git a/internal/config/allowlist/supported_file_types.json b/internal/config/allowlist/supported_file_types.json index 27dc9fe..2c75bde 100644 --- a/internal/config/allowlist/supported_file_types.json +++ b/internal/config/allowlist/supported_file_types.json @@ -43,6 +43,7 @@ ".less", ".html", ".htm", + ".astro", ".vue", ".svelte", ".xml", @@ -66,4 +67,4 @@ ".json5", ".dart", ".tf" -] \ No newline at end of file +] From 0aa6ac2cbfa2bcce18703cad4e77664f11b62b1f Mon Sep 17 00:00:00 2001 From: skate29 Date: Fri, 3 Jul 2026 17:32:47 +0800 Subject: [PATCH 55/87] =?UTF-8?q?docs(README):=20add=20new=20=E2=80=9CPrer?= =?UTF-8?q?equisites=E2=80=9D=20section=20under=20the=20existing=20?= =?UTF-8?q?=E2=80=9CHow=20to=20Use=E2=80=9D=20heading=20and=20before=20the?= =?UTF-8?q?=20CLI=20subsection.=20(#261)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 ++++ README.zh-CN.md | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/README.md b/README.md index 1e82856..d23f290 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,10 @@ The agent's strengths are concentrated where they matter most — dynamic decisi ## How to Use +### Prerequisites + +- **Git >= 2.38** — Open Code Review relies on Git for diff generation, code search, and repository operations. + ### CLI #### Install diff --git a/README.zh-CN.md b/README.zh-CN.md index d84bd7f..485eedd 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -92,6 +92,10 @@ Open Code Review 的核心设计理念是将确定性工程与 Agent 结合, ## 如何使用 +### 前置条件 + +- **Git >= 2.38** — Open Code Review 依赖 Git 进行 diff 生成、代码搜索和仓库操作。 + ### CLI #### 安装 From d1ef549e9357c95479d3d35555fc9df198dc101f Mon Sep 17 00:00:00 2001 From: kite Date: Fri, 3 Jul 2026 17:37:00 +0800 Subject: [PATCH 56/87] docs(README): bump Git prerequisite to >= 2.41 and sync localized READMEs Bump the Git prerequisite version hint from >= 2.38 to >= 2.41, and add the Prerequisites section to the ja/ko/ru localized READMEs which were missing it. --- README.ja-JP.md | 4 ++++ README.ko-KR.md | 4 ++++ README.md | 2 +- README.ru-RU.md | 4 ++++ README.zh-CN.md | 2 +- 5 files changed, 14 insertions(+), 2 deletions(-) diff --git a/README.ja-JP.md b/README.ja-JP.md index b37648c..505d335 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -92,6 +92,10 @@ Open Code Reviewのコア哲学は、決定論的エンジニアリングとエ ## 使い方 +### 前提条件 + +- **Git >= 2.41** — Open Code Review は diff 生成、コード検索、リポジトリ操作に Git を利用します。 + ### CLI #### インストール diff --git a/README.ko-KR.md b/README.ko-KR.md index 54e507f..283576d 100644 --- a/README.ko-KR.md +++ b/README.ko-KR.md @@ -92,6 +92,10 @@ agent의 강점은 동적 판단과 동적 context 검색이 중요한 지점에 ## 사용 방법 +### 사전 요구 사항 + +- **Git >= 2.41** — Open Code Review는 diff 생성, 코드 검색, 저장소 작업에 Git을 사용합니다. + ### CLI #### 설치 diff --git a/README.md b/README.md index d23f290..00cae07 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ The agent's strengths are concentrated where they matter most — dynamic decisi ### Prerequisites -- **Git >= 2.38** — Open Code Review relies on Git for diff generation, code search, and repository operations. +- **Git >= 2.41** — Open Code Review relies on Git for diff generation, code search, and repository operations. ### CLI diff --git a/README.ru-RU.md b/README.ru-RU.md index d1fbad8..3df1068 100644 --- a/README.ru-RU.md +++ b/README.ru-RU.md @@ -92,6 +92,10 @@ Open Code Review — это CLI-инструмент для код-ревью н ## Как использовать +### Предварительные требования + +- **Git >= 2.41** — Open Code Review использует Git для генерации diff, поиска по коду и операций с репозиторием. + ### CLI #### Установка diff --git a/README.zh-CN.md b/README.zh-CN.md index 485eedd..40fa277 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -94,7 +94,7 @@ Open Code Review 的核心设计理念是将确定性工程与 Agent 结合, ### 前置条件 -- **Git >= 2.38** — Open Code Review 依赖 Git 进行 diff 生成、代码搜索和仓库操作。 +- **Git >= 2.41** — Open Code Review 依赖 Git 进行 diff 生成、代码搜索和仓库操作。 ### CLI From c9acbcf91b98ec668a5d8cc09d6bd0006f65961f Mon Sep 17 00:00:00 2001 From: kite Date: Fri, 3 Jul 2026 17:37:31 +0800 Subject: [PATCH 57/87] docs(pages): update CLI docs for install, auto-update, config and quickstart (#290) Refactor installation instructions, update auto-update mechanism description, streamline configuration and quickstart docs, and adjust FAQ across en/ja/zh translations. --- pages/src/content/docs/en/configuration.md | 297 +++++---------------- pages/src/content/docs/en/faq.md | 2 +- pages/src/content/docs/en/installation.md | 95 ++++--- pages/src/content/docs/en/quickstart.md | 193 ++----------- pages/src/content/docs/ja/configuration.md | 285 +++++--------------- pages/src/content/docs/ja/faq.md | 2 +- pages/src/content/docs/ja/installation.md | 90 +++---- pages/src/content/docs/ja/quickstart.md | 212 +++------------ pages/src/content/docs/zh/configuration.md | 279 ++++--------------- pages/src/content/docs/zh/faq.md | 2 +- pages/src/content/docs/zh/installation.md | 88 +++--- pages/src/content/docs/zh/quickstart.md | 212 +++------------ 12 files changed, 407 insertions(+), 1350 deletions(-) diff --git a/pages/src/content/docs/en/configuration.md b/pages/src/content/docs/en/configuration.md index addbfe4..b8443cb 100644 --- a/pages/src/content/docs/en/configuration.md +++ b/pages/src/content/docs/en/configuration.md @@ -4,185 +4,43 @@ sidebar: order: 5 --- -## Endpoint resolution +The config file lives at `~/.opencodereview/config.json`. You have three ways +to edit it: -When `ocr review` or `ocr llm test` runs, it tries four sources **in order** -and uses the first one that yields a complete `(URL, token, model)` triple: +- **Interactive TUI** — `ocr config provider` / `ocr config model`, with guided menus. +- **Command line** — `ocr config set `, ideal for scripts and CI. +- **Manual edit (not recommended)** — the JSON file directly (it gets reformatted on the next `ocr config set` write). -| Priority | Source | What it reads | -|---|---|---| -| 1 | `~/.opencodereview/config.json` | If `provider` is set, resolves via the `providers`/`custom_providers` maps (provider-first; see [Built-in providers](#built-in-providers)). Only falls back to the legacy `llm` section when no provider is set. | -| 2 | OCR environment variables | `OCR_LLM_URL`, `OCR_LLM_TOKEN`, `OCR_LLM_MODEL`, `OCR_USE_ANTHROPIC`, `OCR_LLM_AUTH_HEADER`. | -| 3 | Claude Code environment variables | `ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_MODEL`. | -| 4 | Shell rc files | `export ANTHROPIC_*=…` lines parsed out of `~/.zshrc`, `~/.bashrc`, `~/.bash_profile`, `~/.profile`. | +## Configuring a model -For Claude Code-style sources, if `ANTHROPIC_BASE_URL` lacks a versioned -path (`/v1/...`), OCR appends `/v1/messages` automatically. - -If no strategy yields a complete triple, OCR exits with: - -``` -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 -``` - -> Resolution stops at the first source that **errors**, not just the first -> that's empty. In particular, if `provider` is set in `config.json` but the -> entry is misconfigured (unknown provider name, missing `api_key` with no -> env-var fallback, missing `model`, a custom provider lacking `url`/`protocol`), -> OCR exits with that error and does **not** fall through to the OCR env, -> Claude Code, or rc-file sources. To switch to env-based config, unset the -> `provider` key first. - -> Source priority means the **environment overrides nothing** when the -> config file is fully populated. To force the environment to win, either -> delete the relevant `llm.*` keys from `~/.opencodereview/config.json` or -> use `ocr config set` to switch to the new values. - -## `ocr config set` — managing `~/.opencodereview/config.json` - -```bash -ocr config set -``` - -`config set` mutates the file via key/value pairs with schema-aware -parsing. The interactive TUI commands `ocr config provider` and -`ocr config model` also write to the same file (see -[Interactive setup](#interactive-setup--ocr-config-provider--ocr-config-model)). -Recognised keys: - -| Key | Type | Notes | -|---|---|---| -| `provider` | string | Set the active provider (built-in name or custom). Switching provider clears the model. | -| `model` | string | Set the model for the active provider (stored under the provider entry, or top-level `model` if no provider is set). | -| `providers..` | varies | Per-provider fields for built-in providers: `api_key`, `url`, `protocol`, `model`, `models`, `auth_header`, `extra_body`. | -| `custom_providers..` | varies | Same fields as above, for custom (non-built-in) providers. Custom providers must set at least `url` and `protocol`. | -| `llm.url` | string | Endpoint URL. For Anthropic, full Messages URL like `https://api.anthropic.com/v1/messages`. For OpenAI-compatible, the chat-completions URL. | -| `llm.auth_token` | string | API key. Sent as `Authorization: Bearer …` (OpenAI) or, for the legacy Anthropic path, `Authorization: Bearer …` by default (the preset `anthropic` provider defaults to `x-api-key` instead). Use `x-api-key` only by explicitly setting `llm.auth_header`. | -| `llm.auth_header` | string | Auth header name (`x-api-key`, `authorization`, or `bearer`). Anthropic-only; required for some Anthropic setups that need `x-api-key`. | -| `llm.model` | string | Model name. A `[m]` suffix is stripped automatically. | -| `llm.use_anthropic` | boolean | `true` (default) → Anthropic Messages protocol. `false` → OpenAI Chat Completions. | -| `llm.extra_body` | JSON object | Vendor-specific request fields merged into every chat request body. Example: `'{"thinking":{"type":"disabled"}}'`. | -| `language` | string | Forwarded into a directive appended to the system prompt; defaults to `English` when unset. See [Choosing a language](#choosing-a-language). | -| `telemetry.enabled` | boolean | Master switch for OpenTelemetry export. Off by default. | -| `telemetry.exporter` | string | `console` or `otlp`. | -| `telemetry.otlp_endpoint` | string | OTLP collector address (e.g., `localhost:4317`). | -| `telemetry.content_logging` | boolean | Include LLM prompts / responses in exported event data. | - -Examples: - -```bash -ocr config set llm.url https://api.anthropic.com/v1/messages -ocr config set llm.auth_token sk-ant-xxxxxxxxxx -ocr config set llm.model claude-opus-4-6 -ocr config set llm.use_anthropic true -ocr config set llm.extra_body '{"thinking":{"type":"disabled"}}' -ocr config set language English -ocr config set telemetry.enabled true -ocr config set telemetry.exporter otlp -ocr config set telemetry.otlp_endpoint localhost:4317 - -# Provider-based setup (recommended) -ocr config set provider anthropic -ocr config set model claude-opus-4-6 -ocr config set providers.anthropic.api_key "$ANTHROPIC_API_KEY" - -# Custom (non-built-in) provider -ocr config set provider my-gateway -ocr config set custom_providers.my-gateway.url https://gateway.internal.com/v1 -ocr config set custom_providers.my-gateway.protocol openai -ocr config set custom_providers.my-gateway.model llama-3-70b -ocr config set custom_providers.my-gateway.api_key "$MY_API_KEY" -``` - -Booleans accept anything Go's `strconv.ParseBool` accepts (`true`, `false`, -`1`, `0`, `t`, `f`, …). `llm.extra_body` must be valid JSON. - -## File schema reference - -After the commands above, `~/.opencodereview/config.json` looks like: - -```json -{ - "llm": { - "url": "https://api.anthropic.com/v1/messages", - "auth_token": "sk-ant-xxxxxxxxxx", - "auth_header": "x-api-key", - "model": "claude-opus-4-6", - "use_anthropic": true, - "extra_body": { - "thinking": { "type": "disabled" } - } - }, - "language": "English", - "telemetry": { - "enabled": true, - "exporter": "otlp", - "otlp_endpoint": "localhost:4317" - } -} -``` - -The provider-based form uses `provider`, `model`, `providers`, and -`custom_providers` instead of the legacy `llm` block: - -```json -{ - "provider": "anthropic", - "model": "claude-opus-4-6", - "providers": { - "anthropic": { - "api_key": "sk-ant-xxxxxxxxxx", - "model": "claude-opus-4-6" - } - }, - "custom_providers": { - "my-gateway": { - "url": "https://gateway.internal.com/v1", - "protocol": "openai", - "model": "llama-3-70b", - "models": ["llama-3-70b", "llama-3-8b"], - "api_key": "gw-xxxxxxxxxx", - "auth_header": "authorization" - } - }, - "language": "English" -} -``` - -When `provider` is set, the `providers`/`custom_providers` maps drive -resolution; the legacy `llm` section is ignored for that config. - -You can edit this file by hand if you prefer, but `ocr config set` will -remarshal with `" "` indent on the next write. - -## Interactive setup — `ocr config provider` / `ocr config model` - -For provider and model selection without typing keys, OCR ships two -interactive Bubble Tea TUIs that also mutate `~/.opencodereview/config.json`. +### Recommended: interactive setup ```bash ocr config provider +``` + +It lets you pick a built-in or custom provider, enter an API key, choose a model, saves everything to the config file, and then runs `ocr llm test` once to verify the endpoint. To switch models later: + +```bash ocr config model ``` -- `ocr config provider` — Interactive TUI for selecting a built-in or custom - provider, then entering URL / protocol / API key / model. Saves the choice - to config and runs `ocr llm test` automatically to verify the endpoint. - For built-in providers, the API key may be read from the provider's env var - (see [Built-in providers](#built-in-providers)) when not entered directly. - Selecting a manual configuration populates the legacy `llm.*` block instead. -- `ocr config model` — Interactive TUI for selecting a model from the current - provider's preset list, plus any user-added models stored under - `providers..models` / `custom_providers..models`. Requires a - provider to be set first (`ocr config provider`). +### Non-interactive setup (CI / no-TUI environments) -## Built-in providers +Write to the same config with `ocr config set`: -The following providers ship with OCR. Each has a preset `BaseURL`, -`Protocol`, and (where applicable) an API key environment variable used as a -fallback when `providers..api_key` is unset. +```bash +ocr config set provider anthropic +ocr config set model claude-opus-4-6 +ocr config set providers.anthropic.api_key sk-ant-xxxxxxxxxx +``` + +### Built-in providers + +The following providers ship with OCR, with the Base URL and protocol +preset — once selected, you only need to fill in the API key. If +`providers..api_key` is unset, OCR falls back to the corresponding +environment variable. | Name | Protocol | Base URL | API key env var | |---|---|---|---| @@ -200,84 +58,53 @@ fallback when `providers..api_key` is unset. | `minimax` | openai | `https://api.minimaxi.com/v1` | `MINIMAX_API_KEY` | | `baidu-qianfan` | openai | `https://qianfan.baidubce.com/v2` | `QIANFAN_API_KEY` | -Any other provider name is treated as custom and must be configured under -`custom_providers` with at least `url` and `protocol`. +### Custom providers -## Environment variable reference - -| Variable | Purpose | -|---|---| -| `OCR_LLM_URL` | Endpoint URL — same shape as `llm.url`. | -| `OCR_LLM_TOKEN` | API key — same as `llm.auth_token`. | -| `OCR_LLM_MODEL` | Model name. | -| `OCR_LLM_AUTH_HEADER` | Auth header name (`x-api-key`, `authorization`, or `bearer`). Anthropic-only; same as `llm.auth_header`. Defaults to `authorization` when unset. | -| `OCR_USE_ANTHROPIC` | Unset → Anthropic protocol (default). Set to `true` / `1` / `yes` (case-insensitive) → Anthropic. Set to anything else (`false`, `0`, `no`, typos, …) → OpenAI. | -| `ANTHROPIC_BASE_URL` | Claude Code-compatible base URL. | -| `ANTHROPIC_AUTH_TOKEN` | Claude Code-compatible API key. | -| `ANTHROPIC_MODEL` | Claude Code-compatible model. | -| `OCR_ENABLE_TELEMETRY` | `1` to enable telemetry from env. | -| `OTEL_SERVICE_NAME` | Override service name in spans/metrics. | -| `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP collector address — also forces the exporter to `otlp`. | -| `OTEL_EXPORTER_OTLP_PROTOCOL` | OTLP transport protocol (`grpc`, `http/protobuf`, or `http/json`). Defaults to `grpc`. | -| `OCR_CONTENT_LOGGING` | `1` to include prompts/responses in telemetry events. | - -Per-provider API keys (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, -`DASHSCOPE_API_KEY`, …) are used as a fallback when a built-in provider's -`api_key` field is unset. See the [Built-in providers](#built-in-providers) -table for each provider's env var name. - -## Why `extra_body` exists - -Some hosted providers add non-standard fields to the request body (for -example, Bedrock-style `thinking`, vendor-specific `temperature_strategy`, -streaming options). `llm.extra_body` is merged into every outgoing request, -so you can ship those fields without patching the source. +Any provider name not in the table above is treated as custom and must +supply at least `url` and `protocol` (`protocol` is either `anthropic` or +`openai`): ```bash -ocr config set llm.extra_body '{"thinking":{"type":"enabled","budget_tokens":2048}}' +ocr config set provider my-gateway +ocr config set custom_providers.my-gateway.url https://gateway.internal.com/v1 +ocr config set custom_providers.my-gateway.protocol openai +ocr config set custom_providers.my-gateway.model llama-3-70b +ocr config set custom_providers.my-gateway.api_key "$MY_API_KEY" ``` -## Choosing a language - -The `language` key controls one thing only: a directive appended to every -system-role message in the review and `ocr llm test` prompts. The exact -string injected is: - -``` -\n\nAlways respond in . -``` - -- *Unset* or empty — treated as `English`. -- `Chinese`, `English`, or any other string — passed through verbatim. - -There is no language switching on built-in rule docs. The files embedded -under `internal/config/rules/rule_docs/` are loaded by fixed filename and -are mostly written in Chinese (with `default.md` as an English exception); -they appear in the prompt as-is regardless of the `language` setting. When -`language` is `English`, the prompt therefore contains an English directive -on top of mostly-Chinese rule text — strong models honour the directive and -produce English comments, weaker models may emit mixed output. - -`language` has no environment-variable, CLI-flag, or per-project override — -the only place it can be set is the global `~/.opencodereview/config.json`, -via [`ocr config set`](#ocr-config-set--managing-opencodereviewconfigjson): +### Verify connectivity ```bash +ocr llm test +``` + +### Reuse existing environment variables + +If you already have Claude Code's `ANTHROPIC_*` or OCR's own `OCR_LLM_*` +environment variables configured, OCR picks them up automatically — no +config file needed. + +### Send vendor-specific fields + +Some providers require non-standard request fields (such as Bedrock-style +`thinking`). Use `extra_body` (merged into every request) to send them +without patching the source: + +```bash +ocr config set providers.anthropic.extra_body '{"thinking":{"type":"disabled"}}' +``` + +## Configuring the review language + +`language` determines which language review comments are written in; +it defaults to English when unset: + +```bash +ocr config set language 中文 ocr config set language English ``` -If you need fully English rule text, supply your own rules via `--rule`, -`/.opencodereview/rule.json`, or `~/.opencodereview/rule.json` (see -[Review Rules](../review-rules/#priority-chain)). - -## Per-project vs. global config - -The CLI itself is configured globally (`~/.opencodereview/config.json`) — -there is no project-local LLM config. **Review rules** *are* per-project, -however; see [Review Rules](../review-rules/#priority-chain). - ## See Also - [QuickStart](../quickstart/) — minimal setup and first review. - [CLI Reference](../cli-reference/) — every flag the review command accepts. -- [Telemetry](../telemetry/) — how to wire up OTLP / console exporters. diff --git a/pages/src/content/docs/en/faq.md b/pages/src/content/docs/en/faq.md index e00bce0..dbded44 100644 --- a/pages/src/content/docs/en/faq.md +++ b/pages/src/content/docs/en/faq.md @@ -19,7 +19,7 @@ no valid LLM endpoint configured; one of OCR_LLM_URL/OCR_LLM_TOKEN/OCR_LLM_MODEL ANTHROPIC_MODEL must be set ``` -OCR ran the four-source resolution chain ([Configuration](../configuration/#endpoint-resolution)) +OCR ran the full endpoint-resolution chain ([Configuration](../configuration/#reuse-existing-environment-variables)) and didn't find a complete `(URL, token, model)` triple. Either: - Run `ocr config set llm.url …` / `llm.auth_token …` / `llm.model …` diff --git a/pages/src/content/docs/en/installation.md b/pages/src/content/docs/en/installation.md index 908bb7c..36d57b3 100644 --- a/pages/src/content/docs/en/installation.md +++ b/pages/src/content/docs/en/installation.md @@ -4,46 +4,64 @@ sidebar: order: 4 --- -There are four supported ways to install the `ocr` CLI. They all produce -the same binary — pick whichever fits your environment. +There are four supported ways to install the `ocr` CLI. ## NPM (recommended) +#### Install + ```bash npm install -g @alibaba-group/open-code-review ``` -The NPM package ships a small wrapper script (`bin/ocr.js`) plus a -[postinstall hook](https://github.com/alibaba/open-code-review/blob/main/scripts/install.js) -that: - -1. Detects your platform (`darwin-amd64`, `darwin-arm64`, `linux-amd64`, - `linux-arm64`, `windows-amd64`, `windows-arm64`). -2. Downloads the matching binary from GitHub Releases. -3. Verifies it (when checksum data is present) and places it next to the - wrapper. - -If a platform-specific npm package (e.g. `@alibaba-group/ocr-darwin-arm64`) -is installed as an optional dependency, the binary is used directly and the -download is skipped. - -When you run `ocr`, the wrapper just `exec`s the downloaded binary, so the -overhead is effectively zero after first run. - -### Updating +Pin a specific version: ```bash -npm update -g @alibaba-group/open-code-review -# or pin a specific version: npm install -g @alibaba-group/open-code-review@ ``` -### Uninstalling +#### Updating + +When installed via NPM, `ocr` keeps itself up to date automatically by +default (the static binary opts out of this mechanism). On each `ocr` run, +the wrapper silently checks the registry for the latest version in the +background and upgrades automatically when an update is found, without +affecting the current review. There's an 18-minute cooldown between checks, +tunable with `OCR_UPDATE_INTERVAL` (minutes). + +To turn off auto-updates, set `OCR_NO_UPDATE` to any non-empty value: + +```bash +export OCR_NO_UPDATE=1 +``` + +#### Uninstalling ```bash npm uninstall -g @alibaba-group/open-code-review ``` +## Install script (curl | sh) + +A convenience installer that wraps the GitHub Release binary download +(with checksum verification) — handy for CI base images and headless +machines: + +```bash +curl -fsSL https://raw.githubusercontent.com/alibaba/open-code-review/main/install.sh | sh +``` + +It honours two environment variables: + +| Variable | Default | Purpose | +|---|---|---| +| `OCR_INSTALL_DIR` | `/usr/local/bin` | Where to place the `ocr` binary. | +| `OCR_VERSION` | latest release | Pin a specific release tag (e.g. `v1.2.3`). | + +The script supports `darwin` and `linux` on `amd64` / `arm64`; for +Windows, use the [GitHub Release binary](#github-release-binary) or +[NPM](#npm-recommended) path instead. + ## GitHub Release binary If you don't want Node.js, grab the static binary directly from the @@ -81,39 +99,18 @@ curl -LO https://github.com/alibaba/open-code-review/releases/latest/download/sh shasum -a 256 -c sha256sum.txt --ignore-missing ``` -## Install script (curl | sh) - -A convenience installer that wraps the GitHub Release binary download -(with checksum verification) — handy for CI base images and headless -machines: - -```bash -curl -fsSL https://raw.githubusercontent.com/alibaba/open-code-review/main/install.sh | sh -``` - -It honours two environment variables: - -| Variable | Default | Purpose | -|---|---|---| -| `OCR_INSTALL_DIR` | `/usr/local/bin` | Where to place the `ocr` binary. | -| `OCR_VERSION` | latest release | Pin a specific release tag (e.g. `v1.2.3`). | - -The script supports `darwin` and `linux` on `amd64` / `arm64`; for -Windows, use the [GitHub Release binary](#github-release-binary) or -[NPM](#npm-recommended) path instead. - ## Build from source You only need this path if you're hacking on OCR or running on a platform without a pre-built binary. -### Prerequisites +#### Prerequisites - [Go ≥ 1.25](https://go.dev/dl/) - [Git](https://git-scm.com/) - [Make](https://www.gnu.org/software/make/) -### Build +#### Build ```bash git clone https://github.com/alibaba/open-code-review.git @@ -122,7 +119,7 @@ make build # writes dist/opencodereview sudo cp dist/opencodereview /usr/local/bin/ocr ``` -### Build for another platform +#### Build for another platform ```bash make build-linux-amd64 @@ -139,7 +136,7 @@ make sha256sum # also produce sha256sum.txt file alongside the binaries — that's exactly what the release pipeline runs. -### Run tests +#### Run tests ```bash make test # LC_ALL=C go test -v -race -count=1 ./... @@ -170,7 +167,7 @@ echo $PATH | `~/.opencodereview/config.json` | LLM endpoint, language, telemetry config (managed by `ocr config set`). | | `~/.opencodereview/rule.json` | Optional global review rules. | | `~/.opencodereview/sessions//.jsonl` | Streaming JSONL transcript of every review session, used by `ocr viewer`. | -| `~/.opencodereview/{last-update-check,update.lock,update-available}` | NPM wrapper's background update-check state. The wrapper polls for a newer release (every ~18 min by default) and prints an upgrade hint. Disable with `OCR_NO_UPDATE=1`, or tune the interval with `OCR_UPDATE_INTERVAL` (seconds). Not written by the static binary. | +| `~/.opencodereview/{last-update-check,update.lock,update-available}` | NPM wrapper's background update-check state. The wrapper polls for a newer release (every ~18 min by default) and prints an upgrade hint. Disable with `OCR_NO_UPDATE=1`, or tune the interval with `OCR_UPDATE_INTERVAL` (minutes). Not written by the static binary. | | `/.opencodereview/rule.json` | Optional per-project review rules — safe to commit. | OCR never writes outside `~/.opencodereview/` (besides the transient binary diff --git a/pages/src/content/docs/en/quickstart.md b/pages/src/content/docs/en/quickstart.md index daac209..7ab60e3 100644 --- a/pages/src/content/docs/en/quickstart.md +++ b/pages/src/content/docs/en/quickstart.md @@ -4,156 +4,52 @@ sidebar: order: 3 --- -Install OCR, point it at any LLM that speaks the Anthropic Messages API or -the OpenAI Chat Completions API, and run your first code review. +Get your first code review running in a few minutes. ## Prerequisites -- A working **Git** install — OCR drives Git as a subprocess to read diffs. -- An **API key** for an Anthropic-compatible or OpenAI-compatible LLM. -- One of: - - **Node.js ≥ 18** (recommended; minimum supported: Node 14 — installs via NPM). - - Or just `curl` + `chmod` to drop the static binary into `$PATH`. - - Or **Go ≥ 1.25** if you prefer to build from source. +- **Git ≥ 2.41** +- **Node.js ≥ 18** +- **LLM API key** ## Step 1 — Install the CLI -### Option A: NPM (recommended) - ```bash npm install -g @alibaba-group/open-code-review +ocr version ``` -The NPM package installs a small wrapper that downloads the right binary for -your OS / architecture on install (via a postinstall hook). If the binary is -missing at run time, the wrapper errors out rather than downloading. After -install, you have a global `ocr` command: - -```bash -ocr --version -``` - -### Option B: GitHub Release binary - -Pick the binary for your platform from the -[releases page](https://github.com/alibaba/open-code-review/releases) and -drop it into your `$PATH`: - -```bash -# macOS (Apple Silicon) -curl -Lo ocr https://github.com/alibaba/open-code-review/releases/latest/download/opencodereview-darwin-arm64 -chmod +x ocr && sudo mv ocr /usr/local/bin/ocr - -# macOS (Intel) -curl -Lo ocr https://github.com/alibaba/open-code-review/releases/latest/download/opencodereview-darwin-amd64 -chmod +x ocr && sudo mv ocr /usr/local/bin/ocr - -# Linux x86_64 -curl -Lo ocr https://github.com/alibaba/open-code-review/releases/latest/download/opencodereview-linux-amd64 -chmod +x ocr && sudo mv ocr /usr/local/bin/ocr - -# Linux ARM64 -curl -Lo ocr https://github.com/alibaba/open-code-review/releases/latest/download/opencodereview-linux-arm64 -chmod +x ocr && sudo mv ocr /usr/local/bin/ocr - -# Windows (AMD64) -curl -Lo ocr.exe https://github.com/alibaba/open-code-review/releases/latest/download/opencodereview-windows-amd64.exe - -# Windows (ARM64) -curl -Lo ocr.exe https://github.com/alibaba/open-code-review/releases/latest/download/opencodereview-windows-arm64.exe -``` - -### Option C: Build from source - -```bash -git clone https://github.com/alibaba/open-code-review.git -cd open-code-review -make build -sudo cp dist/opencodereview /usr/local/bin/ocr -``` - -> See the [Installation](../installation/) page for details on each option, -> including how the NPM wrapper resolves the platform binary. +> See [Installation](../installation/) for more methods. ## Step 2 — Configure an LLM -OCR will refuse to run a review until it can resolve a complete LLM -endpoint (URL + token + model). It searches four sources in priority order: - -1. `~/.opencodereview/config.json` -2. OCR-specific environment variables (`OCR_LLM_*`) -3. Claude Code environment variables (`ANTHROPIC_*`) -4. `export ANTHROPIC_*` lines parsed out of your shell rc files - (`~/.zshrc`, `~/.bashrc`, `~/.bash_profile`, `~/.profile`) - -### Quickest path: `ocr config set` - ```bash -ocr config set llm.url https://api.anthropic.com/v1/messages -ocr config set llm.auth_token sk-ant-xxxxxxxxxx -ocr config set llm.model claude-opus-4-6 -ocr config set llm.use_anthropic true +ocr config provider ``` -These values are persisted to `~/.opencodereview/config.json`. - -### Alternative: environment variables - -Highest priority — useful in CI / containers where you don't want a config -file on disk: +It lets you pick a built-in or custom provider, enter an API key, choose a model, saves everything to the config file, and then runs `ocr llm test` once to verify the endpoint. To switch models later: ```bash -export OCR_LLM_URL=https://api.anthropic.com/v1/messages -export OCR_LLM_TOKEN=sk-ant-xxxxxxxxxx -export OCR_LLM_MODEL=claude-opus-4-6 -export OCR_USE_ANTHROPIC=true # default true; set false for OpenAI protocol +ocr config model ``` -### Already using Claude Code? +### Alternative: non-interactive command -OCR transparently picks up the same vars Claude Code uses, so no extra setup: +In CI or a no-TUI environment, write to the same config directly with `ocr config set`: ```bash -export ANTHROPIC_BASE_URL=https://api.anthropic.com -export ANTHROPIC_AUTH_TOKEN=sk-ant-xxxxxxxxxx -export ANTHROPIC_MODEL=claude-opus-4-6 +ocr config set provider anthropic +ocr config set model claude-opus-4-6 +ocr config set providers.anthropic.api_key sk-ant-xxxxxxxxxx ``` -If `ANTHROPIC_BASE_URL` lacks a versioned path, OCR appends `/v1/messages` -automatically. - -### Using an OpenAI-compatible endpoint? - -Set `llm.use_anthropic` to `false` (or `OCR_USE_ANTHROPIC=false`): - -```bash -ocr config set llm.url https://api.openai.com/v1/chat/completions -ocr config set llm.auth_token sk-xxxxxxxxxx -ocr config set llm.model gpt-4o -ocr config set llm.use_anthropic false -``` - -> See [Configuration](../configuration/) for the full key reference, -> including `llm.extra_body` for vendor-specific request fields and -> `language` for switching review-comment language. - ## Step 3 — Test connectivity ```bash ocr llm test ``` -Expected output (model name varies): - -``` -Source: OCR config file -URL: https://api.anthropic.com/v1/messages -Model: claude-opus-4-6 -Hello! … -``` - -If you instead get an error like `no valid LLM endpoint configured`, recheck -the config keys above. A 401 / 403 means the token is wrong or expired. +If you get an error like `no valid LLM endpoint configured`, recheck the Step 2 config. A 401 / 403 means the token is wrong or expired. ## Step 4 — Run your first review @@ -172,71 +68,28 @@ ocr review --from main --to feature-branch ocr review --commit abc123 ``` -You should see a stream of progress lines, finishing with one or more review -comments per file. +> See [CLI Reference](../cli-reference/) for the complete list of `ocr review` flags (concurrency tuning, output format, audience mode, background context, and more) plus every other sub-command. -> Workspace mode includes **untracked** files. If you only want to review -> what you've staged, `git add` selectively beforehand. - -> The three modes above are the basics. See [CLI Reference](../cli-reference/) -> for the complete list of `ocr review` flags — concurrency tuning, output -> format, audience mode, background context, and more — plus every other -> sub-command (`config`, `rules`, `llm test`, `viewer`). - -### Want to see what *would* be reviewed first? +### Want to see what would be reviewed first? ```bash -ocr review --preview # workspace -ocr review -c abc123 -p # commit +ocr review --preview # workspace +ocr review -c abc123 --preview # commit ``` -`--preview` runs every filter step but never calls the LLM, so it's free. -It prints the file list with each file's status (`added` / `modified` / -`deleted` / `renamed` / `binary`) and, for excluded files, the reason -(`binary`, `unsupported_ext`, `default_path`, `user_exclude`, `deleted`). +### JSON output for systems -### JSON output for tooling +`--audience agent` suppresses the human-friendly progress UI so the only thing on stdout is the JSON / final summary — exactly what an upstream agent or CI script wants. ```bash ocr review --format json --audience agent > review.json ``` -- `--format json` emits a machine-readable array of comments, each with - `path`, `content`, `start_line`, `end_line`, `existing_code`, - `suggestion_code` and optional `thinking`. -- `--audience agent` suppresses the human-friendly progress UI so the only - thing on stdout is the JSON / final summary — exactly what an upstream - agent or CI script wants. - -## Step 5 — Review the results - -Each comment includes: - -| Field | Meaning | -|---|---| -| `path` | File that the comment is about. | -| `content` | The review comment itself, in the configured `language`. | -| `start_line` / `end_line` | Line range in the **new** version of the file. Both `0` means OCR couldn't precisely position the comment — the issue is real but you'll need to find the exact spot yourself. | -| `existing_code` | The snippet from the diff the comment refers to. Used internally for line resolution; useful when `start_line` is `0`. | -| `suggestion_code` | Optional fix snippet. | -| `thinking` | Optional model reasoning. Only present for some models. | - -## Step 6 — Inspect a past session - -Every review is persisted to `~/.opencodereview/sessions/...` as a -JSONL transcript. Browse them in a local web UI: - -```bash -ocr viewer # http://localhost:5483 -ocr viewer --addr :3000 -``` - -> See [Session Viewer](../viewer/) for the full UI tour. - ## See Also +- [Installation](../installation/) — every install method and OCR's state directory. +- [Configuration](../configuration/) — every env var, config key, and built-in provider. - [CLI Reference](../cli-reference/) — every sub-command, flag, and output mode. - [Review Rules](../review-rules/) — customize what gets reviewed. - [Integrations](../integrations/) — embed OCR in Claude Code, an Agent skill, or CI. -- [Telemetry](../telemetry/) — ship traces and metrics over OTLP. - [FAQ](../faq/) — known errors and remedies. diff --git a/pages/src/content/docs/ja/configuration.md b/pages/src/content/docs/ja/configuration.md index a7db64b..f60f569 100644 --- a/pages/src/content/docs/ja/configuration.md +++ b/pages/src/content/docs/ja/configuration.md @@ -4,181 +4,43 @@ sidebar: order: 5 --- -## エンドポイントの解決 +設定ファイルは `~/.opencodereview/config.json` にあります。編集方法は 3 つあります。 -`ocr review` または `ocr llm test` が実行されると、4 つの来源を順に試し、 -完全な `(URL, token, model)` の三つ組を最初に返せた来源を使用します。 +- **インタラクティブ TUI** —— `ocr config provider` / `ocr config model`。ガイド付きメニューが表示されます。 +- **コマンドライン** —— `ocr config set `。スクリプトや CI に適しています。 +- **手動編集(非推奨)** —— この JSON ファイルを直接編集(次回の `ocr config set` 書き込み時に再フォーマットされます)。 -| 優先度 | 来源 | 読み取る内容 | -|---|---|---| -| 1 | `~/.opencodereview/config.json` | `provider` が設定されている場合は `providers`/`custom_providers` マッピングを通じて解決します(provider が優先。[組み込み provider](#built-in-providers) を参照)。provider が設定されていない場合にのみ、レガシーの `llm` セクションにフォールバックします。 | -| 2 | OCR 環境変数 | `OCR_LLM_URL`、`OCR_LLM_TOKEN`、`OCR_LLM_MODEL`、`OCR_USE_ANTHROPIC`、`OCR_LLM_AUTH_HEADER`。 | -| 3 | Claude Code 環境変数 | `ANTHROPIC_BASE_URL`、`ANTHROPIC_AUTH_TOKEN`、`ANTHROPIC_MODEL`。 | -| 4 | シェルの rc ファイル | `~/.zshrc`、`~/.bashrc`、`~/.bash_profile`、`~/.profile` から解析された `export ANTHROPIC_*=…` 行。 | +## モデルを設定する -Claude Code 風の来源については、`ANTHROPIC_BASE_URL` にバージョン付きのパス -(`/v1/...`)がない場合、OCR は自動的に `/v1/messages` を追加します。 - -いずれの戦略でも完全な三つ組が得られない場合、OCR は次のメッセージで終了します。 - -``` -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 -``` - -> 解決は、単に最初に空だった来源ではなく、最初に**エラーとなった**来源で停止します。特に注意してください。 -> `config.json` に `provider` が設定されているのにその項目の設定が誤っている場合(不明な provider 名、 -> 環境変数のフォールバックもなく `api_key` が欠落、`model` が欠落、カスタム provider の -> `url`/`protocol` が欠落)、OCR はそのエラーで終了し、OCR 環境変数、 -> Claude Code、rc ファイルの来源にフォールバックすることは**ありません**。環境変数ベースの設定に切り替えるには、まず -> `provider` key を解除してください。 - -> 来源の優先順位により、設定ファイルが完全に埋まっている場合、**環境変数はいかなる値も上書きしません**。 -> 環境変数を有効にするには、`~/.opencodereview/config.json` から該当する `llm.*` key を削除するか、 -> `ocr config set` で新しい値に切り替えてください。 - -## `ocr config set` ——`~/.opencodereview/config.json` を管理する - -```bash -ocr config set -``` - -`config set` は key/value ペアでファイルを変更し、schema を認識した解析を行います。インタラクティブな TUI -コマンド `ocr config provider` と `ocr config model` も同じファイルに書き込みます( -[インタラクティブ設定](#interactive-setup--ocr-config-provider--ocr-config-model) を参照)。認識される -key: - -| Key | 型 | 説明 | -|---|---|---| -| `provider` | string | 現在の provider を設定します(組み込み名またはカスタム)。provider を切り替えると model がクリアされます。 | -| `model` | string | 現在の provider に model を設定します(provider 項目の下に保存。provider がない場合はトップレベルの `model` に保存)。 | -| `providers..` | varies | 組み込み provider のフィールドごとの設定:`api_key`、`url`、`protocol`、`model`、`models`、`auth_header`、`extra_body`。 | -| `custom_providers..` | varies | 同じフィールド。カスタム(非組み込み)provider 用。カスタム provider には少なくとも `url` と `protocol` を設定する必要があります。 | -| `llm.url` | string | エンドポイント URL。Anthropic では完全な Messages URL(例:`https://api.anthropic.com/v1/messages`)を使用します。OpenAI 互換では chat-completions URL を使用します。 | -| `llm.auth_token` | string | API key。`Authorization: Bearer …` として送信されます(OpenAI)。レガシーの Anthropic パスもデフォルトで `Authorization: Bearer …`(プリセットの `anthropic` provider ではデフォルトが `x-api-key` に変わります)。`llm.auth_header` を明示的に設定した場合にのみ `x-api-key` を使用します。 | -| `llm.auth_header` | string | Auth header 名(`x-api-key`、`authorization`、または `bearer`)。Anthropic のみで使用します。`x-api-key` を必要とする一部の Anthropic 設定で必須です。 | -| `llm.model` | string | モデル名。`[<数字>m]` サフィックスは自動的に除去されます。 | -| `llm.use_anthropic` | boolean | `true`(デフォルト)→ Anthropic Messages プロトコル。`false` → OpenAI Chat Completions。 | -| `llm.extra_body` | JSON object | ベンダー固有のリクエストフィールド。各 chat リクエストボディにマージされます。例:`'{"thinking":{"type":"disabled"}}'`。 | -| `language` | string | system prompt に追加される指示として転送されます。未設定の場合はデフォルトで `English`。[言語の選択](#choosing-a-language) を参照。 | -| `telemetry.enabled` | boolean | OpenTelemetry エクスポートのマスタースイッチ。デフォルトは無効。 | -| `telemetry.exporter` | string | `console` または `otlp`。 | -| `telemetry.otlp_endpoint` | string | OTLP collector のアドレス(例:`localhost:4317`)。 | -| `telemetry.content_logging` | boolean | エクスポートされるイベントデータに LLM の prompt / レスポンスを含めます。 | - -例: - -```bash -ocr config set llm.url https://api.anthropic.com/v1/messages -ocr config set llm.auth_token sk-ant-xxxxxxxxxx -ocr config set llm.model claude-opus-4-6 -ocr config set llm.use_anthropic true -ocr config set llm.extra_body '{"thinking":{"type":"disabled"}}' -ocr config set language English -ocr config set telemetry.enabled true -ocr config set telemetry.exporter otlp -ocr config set telemetry.otlp_endpoint localhost:4317 - -# provider ベースの設定(推奨) -ocr config set provider anthropic -ocr config set model claude-opus-4-6 -ocr config set providers.anthropic.api_key "$ANTHROPIC_API_KEY" - -# カスタム(非組み込み)provider -ocr config set provider my-gateway -ocr config set custom_providers.my-gateway.url https://gateway.internal.com/v1 -ocr config set custom_providers.my-gateway.protocol openai -ocr config set custom_providers.my-gateway.model llama-3-70b -ocr config set custom_providers.my-gateway.api_key "$MY_API_KEY" -``` - -boolean は Go の `strconv.ParseBool` が受け付ける任意の形式(`true`、`false`、`1`、`0`、 -`t`、`f`……)を受け付けます。`llm.extra_body` は正しい JSON でなければなりません。 - -## ファイル schema リファレンス - -上記のコマンドを実行すると、`~/.opencodereview/config.json` は次のようになります。 - -```json -{ - "llm": { - "url": "https://api.anthropic.com/v1/messages", - "auth_token": "sk-ant-xxxxxxxxxx", - "auth_header": "x-api-key", - "model": "claude-opus-4-6", - "use_anthropic": true, - "extra_body": { - "thinking": { "type": "disabled" } - } - }, - "language": "English", - "telemetry": { - "enabled": true, - "exporter": "otlp", - "otlp_endpoint": "localhost:4317" - } -} -``` - -provider ベースの形式では、レガシーの `llm` ブロックではなく `provider`、`model`、`providers`、 -`custom_providers` を使用します。 - -```json -{ - "provider": "anthropic", - "model": "claude-opus-4-6", - "providers": { - "anthropic": { - "api_key": "sk-ant-xxxxxxxxxx", - "model": "claude-opus-4-6" - } - }, - "custom_providers": { - "my-gateway": { - "url": "https://gateway.internal.com/v1", - "protocol": "openai", - "model": "llama-3-70b", - "models": ["llama-3-70b", "llama-3-8b"], - "api_key": "gw-xxxxxxxxxx", - "auth_header": "authorization" - } - }, - "language": "English" -} -``` - -`provider` が設定されている場合、解決は `providers`/`custom_providers` マッピングによって駆動されます。この設定では -レガシーの `llm` セクションは無視されます。 - -このファイルを手動で編集することもできますが、次回の書き込み時に `ocr config set` が `" "` インデントで -再シリアライズします。 - -## インタラクティブ設定——`ocr config provider` / `ocr config model` - -provider と model を選択するために key を手動で入力する手間を省くため、OCR は 2 つのインタラクティブな Bubble Tea TUI を提供します。 -どちらも同様に `~/.opencodereview/config.json` を変更します。 +### 推奨:インタラクティブ設定 ```bash ocr config provider +``` + +組み込みまたはカスタムの provider を選択し、API key を入力し、model を選び、すべてを設定ファイルに保存したうえで、`ocr llm test` を 1 回実行してエンドポイントを検証します。あとで model を切り替えるには: + +```bash ocr config model ``` -- `ocr config provider`——組み込みまたはカスタムの provider を選択し、URL / protocol / - API key / model を入力するインタラクティブな TUI です。選択内容は config に保存され、自動的に `ocr llm test` を実行して - エンドポイントを検証します。組み込み provider の場合、直接入力しなければ API key はその provider の環境変数から - 読み取れます([組み込み provider](#built-in-providers) を参照)。手動設定を選んだ場合は、レガシーの - `llm.*` ブロックに代わりに書き込みます。 -- `ocr config model`——現在の provider のプリセットリスト、および - `providers..models` / `custom_providers..models` の下でユーザーが追加した - model からモデルを選択するインタラクティブな TUI です。先に provider を設定しておく必要があります(`ocr config provider`)。 +### 非インタラクティブ設定(CI / TUI なし環境) -## 組み込み provider +`ocr config set` で同じ設定に書き込みます。 -以下の provider が OCR に同梱されています。それぞれにプリセットの `BaseURL`、`Protocol`、および -(該当する場合)`providers..api_key` が未設定のときにフォールバックとなる API key 環境変数があります。 +```bash +ocr config set provider anthropic +ocr config set model claude-opus-4-6 +ocr config set providers.anthropic.api_key sk-ant-xxxxxxxxxx +``` -| 名称 | Protocol | Base URL | API key 環境変数 | +### 組み込み provider + +以下の provider が OCR に同梱されており、Base URL とプロトコルがプリセット +されています——選択後は API key を入力するだけです。`providers..api_key` +が未設定の場合は、対応する環境変数に自動的にフォールバックします。 + +| 名称 | プロトコル | Base URL | API key 環境変数 | |---|---|---|---| | `anthropic` | anthropic | `https://api.anthropic.com` | `ANTHROPIC_API_KEY` | | `openai` | openai | `https://api.openai.com/v1` | `OPENAI_API_KEY` | @@ -194,78 +56,53 @@ ocr config model | `minimax` | openai | `https://api.minimaxi.com/v1` | `MINIMAX_API_KEY` | | `baidu-qianfan` | openai | `https://qianfan.baidubce.com/v2` | `QIANFAN_API_KEY` | -その他の provider 名はすべてカスタムとみなされ、`custom_providers` の下で設定する必要があり、少なくとも -`url` と `protocol` が必要です。 +### カスタム provider -## 環境変数リファレンス - -| 変数 | 用途 | -|---|---| -| `OCR_LLM_URL` | エンドポイント URL——`llm.url` と同形。 | -| `OCR_LLM_TOKEN` | API key——`llm.auth_token` と同じ。 | -| `OCR_LLM_MODEL` | モデル名。 | -| `OCR_LLM_AUTH_HEADER` | Auth header 名(`x-api-key`、`authorization`、または `bearer`)。Anthropic のみ。`llm.auth_header` と同じ。未設定の場合はデフォルトで `authorization`。 | -| `OCR_USE_ANTHROPIC` | 未設定 → Anthropic プロトコル(デフォルト)。`true` / `1` / `yes`(大文字小文字を区別しない)に設定 → Anthropic。その他の値(`false`、`0`、`no`、スペルミス……)に設定 → OpenAI。 | -| `ANTHROPIC_BASE_URL` | Claude Code 互換の base URL。 | -| `ANTHROPIC_AUTH_TOKEN` | Claude Code 互換の API key。 | -| `ANTHROPIC_MODEL` | Claude Code 互換の model。 | -| `OCR_ENABLE_TELEMETRY` | `1` で環境変数からテレメトリを有効化します。 | -| `OTEL_SERVICE_NAME` | span/metric の service name を上書きします。 | -| `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP collector のアドレス——同時に exporter を `otlp` に強制します。 | -| `OTEL_EXPORTER_OTLP_PROTOCOL` | OTLP のトランスポートプロトコル(`grpc`、`http/protobuf`、または `http/json`)。デフォルトは `grpc`。 | -| `OCR_CONTENT_LOGGING` | `1` でテレメトリイベントに prompt/レスポンスを含めます。 | - -各 provider の API key(`ANTHROPIC_API_KEY`、`OPENAI_API_KEY`、 -`DASHSCOPE_API_KEY`……)は、組み込み provider の `api_key` フィールドが未設定のときにフォールバックとなります。 -各 provider の環境変数名は [組み込み provider](#built-in-providers) の表を参照してください。 - -## なぜ `extra_body` があるのか - -一部のホスト型 provider は、リクエストボディに非標準のフィールドを追加します(例:Bedrock 風の `thinking`、 -ベンダー固有の `temperature_strategy`、ストリーミングオプション)。`llm.extra_body` は発行される各リクエストに -マージされるため、ソースコードを変更せずにこれらのフィールドを送信できます。 +上記の表にない provider 名はすべてカスタムとみなされ、少なくとも `url` と +`protocol` を指定する必要があります(`protocol` は `anthropic` または +`openai`)。 ```bash -ocr config set llm.extra_body '{"thinking":{"type":"enabled","budget_tokens":2048}}' +ocr config set provider my-gateway +ocr config set custom_providers.my-gateway.url https://gateway.internal.com/v1 +ocr config set custom_providers.my-gateway.protocol openai +ocr config set custom_providers.my-gateway.model llama-3-70b +ocr config set custom_providers.my-gateway.api_key "$MY_API_KEY" ``` -## 言語の選択 - -`language` key は 1 つのことだけを制御します。それは、レビューと `ocr llm test` の prompt 内の各 -system-role メッセージに追加される 1 つの指示です。注入される正確な文字列は次のとおりです。 - -``` -\n\nAlways respond in . -``` - -- *未設定* または空——`English` として扱われます。 -- `Chinese`、`English`、またはその他任意の文字列——そのまま透過されます。 - -組み込みの rule docs は言語の切り替えをサポートしません。`internal/config/rules/rule_docs/` の下に埋め込まれたファイルは -固定のファイル名で読み込まれ、ほとんどが中国語で書かれています(`default.md` は英語の例外)。`language` -の設定にかかわらず、それらはそのまま prompt に現れます。したがって `language` を `English` に設定した場合、prompt -には英語の指示と大量の中国語の rule テキストが重なります——強力なモデルは指示に従って英語のコメントを生成し、 -弱いモデルは中国語と英語が混在した内容を出力する可能性があります。 - -`language` には環境変数、CLI 引数、プロジェクトレベルの上書きがありません——設定できる唯一の場所はグローバルな -`~/.opencodereview/config.json` で、 -[`ocr config set`](#ocr-config-set--managing-opencodereviewconfigjson) を通じて設定します。 +### 接続性を検証する ```bash +ocr llm test +``` + +### 既存の環境変数を再利用する + +Claude Code の `ANTHROPIC_*` や OCR 独自の `OCR_LLM_*` 環境変数をすでに +設定している場合、OCR はそれらを自動的に認識するため、設定ファイルを書く +必要はありません。 + +### ベンダー固有のフィールドを送信する + +一部の provider は非標準のリクエストフィールド(Bedrock 風の `thinking` など)を +必要とします。`extra_body`(各リクエストにマージされます)を使えば、ソースコードを +変更せずにそれらを送信できます。 + +```bash +ocr config set providers.anthropic.extra_body '{"thinking":{"type":"disabled"}}' +``` + +## レビュー言語を設定する + +`language` はレビューコメントをどの言語で出力するかを決めます。未設定の場合は +デフォルトで英語になります。 + +```bash +ocr config set language 中文 ocr config set language English ``` -純粋な英語の rule テキストが必要な場合は、`--rule`、`/.opencodereview/rule.json`、 -または `~/.opencodereview/rule.json` を通じて独自のルールを提供してください( -[レビュールール](../review-rules/#priority-chain) を参照)。 - -## プロジェクトレベル vs グローバル設定 - -CLI 自体はグローバルに設定されます(`~/.opencodereview/config.json`)——プロジェクトレベルの LLM 設定はありません。 -ただし**レビュールール**はプロジェクトレベルです。[レビュールール](../review-rules/#priority-chain) を参照してください。 - ## 関連項目 - [クイックスタート](../quickstart/)——最小限のセットアップと初回のレビュー。 - [CLI リファレンス](../cli-reference/)——review コマンドが受け入れる各引数。 -- [テレメトリ](../telemetry/)——OTLP / console exporter への接続方法。 diff --git a/pages/src/content/docs/ja/faq.md b/pages/src/content/docs/ja/faq.md index 9def2c0..b2d541a 100644 --- a/pages/src/content/docs/ja/faq.md +++ b/pages/src/content/docs/ja/faq.md @@ -18,7 +18,7 @@ no valid LLM endpoint configured; one of OCR_LLM_URL/OCR_LLM_TOKEN/OCR_LLM_MODEL ANTHROPIC_MODEL must be set ``` -OCR は 4 つのソースからなる解決チェーン([設定](../configuration/#endpoint-resolution))を +OCR はエンドポイント解決チェーン全体([設定](../configuration/#既存の環境変数を再利用する))を たどりましたが、完全な `(URL, token, model)` の三つ組を見つけられませんでした。次のいずれかを 行ってください。 diff --git a/pages/src/content/docs/ja/installation.md b/pages/src/content/docs/ja/installation.md index 9230e7e..7abc977 100644 --- a/pages/src/content/docs/ja/installation.md +++ b/pages/src/content/docs/ja/installation.md @@ -4,44 +4,62 @@ sidebar: order: 4 --- -`ocr` CLI をインストールするには、サポートされた 4 つの方法があります。いずれも生成されるのは同じバイナリなので、 -環境に合わせて選んでください。 +`ocr` CLI をインストールするには、サポートされた 4 つの方法があります。 ## NPM(推奨) +#### インストール + ```bash npm install -g @alibaba-group/open-code-review ``` -NPM パッケージには、小さな wrapper スクリプト(`bin/ocr.js`)と -[postinstall hook](https://github.com/alibaba/open-code-review/blob/main/scripts/install.js) -が付属しており、以下を行います。 - -1. お使いのプラットフォームを検出します(`darwin-amd64`、`darwin-arm64`、`linux-amd64`、 - `linux-arm64`、`windows-amd64`、`windows-arm64`)。 -2. GitHub Releases から一致するバイナリをダウンロードします。 -3. (チェックサムデータが存在する場合は)検証し、wrapper の隣に配置します。 - -プラットフォーム固有の npm パッケージ(例:`@alibaba-group/ocr-darwin-arm64`)が -optional dependency としてインストールされている場合は、そのバイナリを直接使用し、ダウンロードをスキップします。 - -`ocr` を実行すると、wrapper はダウンロード済みのバイナリを単に `exec` するだけなので、初回実行後の実際のオーバーヘッド -はゼロです。 - -### 更新 +特定のバージョンに固定: ```bash -npm update -g @alibaba-group/open-code-review -# または特定のバージョンに固定: npm install -g @alibaba-group/open-code-review@ ``` -### アンインストール +#### 更新 + +NPM 経由でインストールした場合、`ocr` はデフォルトで自動的に最新の状態を保ちます +(静的バイナリはこのメカニズムの対象外です)。`ocr` を実行するたびに、wrapper は +バックグラウンドで registry の最新バージョンを静かにチェックし、更新が見つかると +今回のレビューに影響を与えることなく自動的にアップグレードします。チェックの間には +18 分のクールダウンがあり、`OCR_UPDATE_INTERVAL`(分)で調整できます。 + +自動更新をオフにするには、`OCR_NO_UPDATE` に空でない任意の値を設定します。 + +```bash +export OCR_NO_UPDATE=1 +``` + +#### アンインストール ```bash npm uninstall -g @alibaba-group/open-code-review ``` +## インストールスクリプト(curl | sh) + +GitHub Release バイナリのダウンロード(検証付き)をラップした便利なインストーラーです——CI のベース +イメージやヘッドレス環境に適しています。 + +```bash +curl -fsSL https://raw.githubusercontent.com/alibaba/open-code-review/main/install.sh | sh +``` + +2 つの環境変数を認識します。 + +| 変数 | デフォルト値 | 用途 | +|---|---|---| +| `OCR_INSTALL_DIR` | `/usr/local/bin` | `ocr` バイナリを配置する場所。 | +| `OCR_VERSION` | 最新 release | 特定の release tag に固定します(例:`v1.2.3`)。 | + +このスクリプトは `darwin` と `linux` の `amd64` / `arm64` をサポートします。Windows では +[GitHub Release バイナリ](#github-release-binary) または [NPM](#npm-recommended) +の方法を使用してください。 + ## GitHub Release バイナリ Node.js をインストールしたくない場合は、 @@ -79,37 +97,17 @@ curl -LO https://github.com/alibaba/open-code-review/releases/latest/download/sh shasum -a 256 -c sha256sum.txt --ignore-missing ``` -## インストールスクリプト(curl | sh) - -GitHub Release バイナリのダウンロード(検証付き)をラップした便利なインストーラーです——CI のベース -イメージやヘッドレス環境に適しています。 - -```bash -curl -fsSL https://raw.githubusercontent.com/alibaba/open-code-review/main/install.sh | sh -``` - -2 つの環境変数を認識します。 - -| 変数 | デフォルト値 | 用途 | -|---|---|---| -| `OCR_INSTALL_DIR` | `/usr/local/bin` | `ocr` バイナリを配置する場所。 | -| `OCR_VERSION` | 最新 release | 特定の release tag に固定します(例:`v1.2.3`)。 | - -このスクリプトは `darwin` と `linux` の `amd64` / `arm64` をサポートします。Windows では -[GitHub Release バイナリ](#github-release-binary) または [NPM](#npm-recommended) -の方法を使用してください。 - ## ソースからビルドする OCR 自体を変更する場合、またはプリコンパイル済みバイナリのないプラットフォームで実行する場合にのみ、この方法が必要です。 -### 前提条件 +#### 前提条件 - [Go ≥ 1.25](https://go.dev/dl/) - [Git](https://git-scm.com/) - [Make](https://www.gnu.org/software/make/) -### ビルド +#### ビルド ```bash git clone https://github.com/alibaba/open-code-review.git @@ -118,7 +116,7 @@ make build # dist/opencodereview を生成 sudo cp dist/opencodereview /usr/local/bin/ocr ``` -### 他のプラットフォーム向けにビルドする +#### 他のプラットフォーム向けにビルドする ```bash make build-linux-amd64 @@ -134,7 +132,7 @@ make sha256sum # sha256sum.txt も生成 `make dist` は `clean → build-all → sha256sum` を実行し、バイナリの隣に `VERSION` ファイルを書き込みます——これはまさに release パイプラインが実行するステップです。 -### テストの実行 +#### テストの実行 ```bash make test # LC_ALL=C go test -v -race -count=1 ./... @@ -164,7 +162,7 @@ echo $PATH | `~/.opencodereview/config.json` | LLM エンドポイント、言語、テレメトリ設定(`ocr config set` で管理)。 | | `~/.opencodereview/rule.json` | オプションのグローバルレビュールール。 | | `~/.opencodereview/sessions//.jsonl` | レビューセッションごとのストリーミング JSONL トランスクリプト。`ocr viewer` で使用します。 | -| `~/.opencodereview/{last-update-check,update.lock,update-available}` | NPM wrapper のバックグラウンド更新チェックの状態。wrapper はより新しい release があるかをポーリングし(デフォルトで約 18 分ごと)、アップグレードの案内を表示します。`OCR_NO_UPDATE=1` で無効化するか、`OCR_UPDATE_INTERVAL`(秒)で間隔を調整します。静的バイナリはこれらのファイルを書き込みません。 | +| `~/.opencodereview/{last-update-check,update.lock,update-available}` | NPM wrapper のバックグラウンド更新チェックの状態。wrapper はより新しい release があるかをポーリングし(デフォルトで約 18 分ごと)、アップグレードの案内を表示します。`OCR_NO_UPDATE=1` で無効化するか、`OCR_UPDATE_INTERVAL`(分)で間隔を調整します。静的バイナリはこれらのファイルを書き込みません。 | | `/.opencodereview/rule.json` | オプションのプロジェクトレベルのレビュールール——安全にコミットできます。 | OCR は `~/.opencodereview/` の外に書き込むことは決してありません(NPM が一時的にバイナリをダウンロードする場合を除く)。 diff --git a/pages/src/content/docs/ja/quickstart.md b/pages/src/content/docs/ja/quickstart.md index 759fbbb..a29e594 100644 --- a/pages/src/content/docs/ja/quickstart.md +++ b/pages/src/content/docs/ja/quickstart.md @@ -4,232 +4,92 @@ sidebar: order: 3 --- -OCR をインストールし、Anthropic Messages API または OpenAI Chat Completions API -に対応した任意の LLM に接続して、初回のコードレビューを実行しましょう。 +数分で初回のコードレビューを実行できます。 ## 前提条件 -- 動作する **Git** のインストール——OCR は Git をサブプロセスとして駆動し diff を読み取ります。 -- Anthropic または OpenAI 互換の LLM の **API key**。 -- 以下のいずれか: - - **Node.js ≥ 18**(推奨。最低サポートは Node 14——NPM 経由でインストール)。 - - または `curl` + `chmod` だけで静的バイナリを `$PATH` に配置。 - - またはソースからビルドしたい場合は **Go ≥ 1.25**。 +- **Git ≥ 2.41** +- **Node.js ≥ 18** +- **LLM API key** -## ステップ 1——CLI をインストールする - -### 方法 A:NPM(推奨) +## ステップ 1 —— CLI をインストールする ```bash npm install -g @alibaba-group/open-code-review +ocr version ``` -NPM パッケージは小さな wrapper をインストールし、インストール時に(postinstall hook を通じて)お使いの -OS / アーキテクチャ向けの正しいバイナリをダウンロードします。実行時にバイナリが存在しない場合、wrapper はエラーを出し、 -ダウンロードは行いません。インストール後、グローバルな `ocr` コマンドが利用できます。 +> その他の方法は [インストール](../installation/) を参照してください。 + +## ステップ 2 —— LLM を設定する ```bash -ocr --version +ocr config provider ``` -### 方法 B:GitHub Release バイナリ - -[releases ページ](https://github.com/alibaba/open-code-review/releases) -から対応プラットフォームのバイナリを選び、`$PATH` に配置します。 +組み込みまたはカスタムの provider を選択し、API key を入力し、model を選び、すべてを設定ファイルに保存したうえで、`ocr llm test` を 1 回実行してエンドポイントを検証します。あとで model を切り替えるには: ```bash -# macOS (Apple Silicon) -curl -Lo ocr https://github.com/alibaba/open-code-review/releases/latest/download/opencodereview-darwin-arm64 -chmod +x ocr && sudo mv ocr /usr/local/bin/ocr - -# macOS (Intel) -curl -Lo ocr https://github.com/alibaba/open-code-review/releases/latest/download/opencodereview-darwin-amd64 -chmod +x ocr && sudo mv ocr /usr/local/bin/ocr - -# Linux x86_64 -curl -Lo ocr https://github.com/alibaba/open-code-review/releases/latest/download/opencodereview-linux-amd64 -chmod +x ocr && sudo mv ocr /usr/local/bin/ocr - -# Linux ARM64 -curl -Lo ocr https://github.com/alibaba/open-code-review/releases/latest/download/opencodereview-linux-arm64 -chmod +x ocr && sudo mv ocr /usr/local/bin/ocr - -# Windows (AMD64) -curl -Lo ocr.exe https://github.com/alibaba/open-code-review/releases/latest/download/opencodereview-windows-amd64.exe - -# Windows (ARM64) -curl -Lo ocr.exe https://github.com/alibaba/open-code-review/releases/latest/download/opencodereview-windows-arm64.exe +ocr config model ``` -### 方法 C:ソースからビルドする +### 代替方法:非インタラクティブコマンド + +CI や TUI のない環境では、`ocr config set` で同じ設定に直接書き込みます。 ```bash -git clone https://github.com/alibaba/open-code-review.git -cd open-code-review -make build -sudo cp dist/opencodereview /usr/local/bin/ocr +ocr config set provider anthropic +ocr config set model claude-opus-4-6 +ocr config set providers.anthropic.api_key sk-ant-xxxxxxxxxx ``` -> 各インストール方法の詳細は [インストール](../installation/) を参照してください。NPM wrapper が -> プラットフォームバイナリをどのように解決するかも含まれています。 - -## ステップ 2——LLM を設定する - -完全な LLM エンドポイント(URL + token + model)を解決できるまで、OCR はレビューの実行を拒否します。 -以下の優先順位で 4 つの来源を探索します。 - -1. `~/.opencodereview/config.json` -2. OCR 専用の環境変数(`OCR_LLM_*`) -3. Claude Code の環境変数(`ANTHROPIC_*`) -4. シェルの rc ファイル(`~/.zshrc`、`~/.bashrc`、`~/.bash_profile`、 - `~/.profile`)から解析された `export ANTHROPIC_*` 行 - -### 最速の経路:`ocr config set` - -```bash -ocr config set llm.url https://api.anthropic.com/v1/messages -ocr config set llm.auth_token sk-ant-xxxxxxxxxx -ocr config set llm.model claude-opus-4-6 -ocr config set llm.use_anthropic true -``` - -これらの値は `~/.opencodereview/config.json` に永続化されます。 - -### 代替方法:環境変数 - -優先度が最も高く、ディスクに設定ファイルを残したくない CI / コンテナに適しています。 - -```bash -export OCR_LLM_URL=https://api.anthropic.com/v1/messages -export OCR_LLM_TOKEN=sk-ant-xxxxxxxxxx -export OCR_LLM_MODEL=claude-opus-4-6 -export OCR_USE_ANTHROPIC=true # デフォルトは true。false にすると OpenAI プロトコルを使用 -``` - -### すでに Claude Code を使っている場合 - -OCR は Claude Code が使用するのと同じ変数群を自動的に読み取るため、追加の設定は不要です。 - -```bash -export ANTHROPIC_BASE_URL=https://api.anthropic.com -export ANTHROPIC_AUTH_TOKEN=sk-ant-xxxxxxxxxx -export ANTHROPIC_MODEL=claude-opus-4-6 -``` - -`ANTHROPIC_BASE_URL` にバージョン付きのパスがない場合、OCR は自動的に -`/v1/messages` を追加します。 - -### OpenAI 互換エンドポイントを使う場合 - -`llm.use_anthropic` を `false` に設定します(または `OCR_USE_ANTHROPIC=false`)。 - -```bash -ocr config set llm.url https://api.openai.com/v1/chat/completions -ocr config set llm.auth_token sk-xxxxxxxxxx -ocr config set llm.model gpt-4o -ocr config set llm.use_anthropic false -``` - -> 完全な key のリファレンスは [設定](../configuration/) を参照してください。ベンダー固有のリクエストフィールド -> 用の `llm.extra_body` や、レビューコメントの言語を切り替える `language` も含まれています。 - -## ステップ 3——接続性をテストする +## ステップ 3 —— 接続性をテストする ```bash ocr llm test ``` -期待される出力(モデル名は異なります): +`no valid LLM endpoint configured` のようなエラーが出た場合は、ステップ 2 の設定を再確認してください。401 / 403 は token が誤っているか期限切れであることを示します。 -``` -Source: OCR config file -URL: https://api.anthropic.com/v1/messages -Model: claude-opus-4-6 -Hello! … -``` - -代わりに `no valid LLM endpoint configured` のようなエラーが出た場合は、上記の -設定 key を再確認してください。401 / 403 は token が誤っているか期限切れであることを示します。 - -## ステップ 4——初回のレビューを実行する +## ステップ 4 —— 初回のレビューを実行する 任意の Git リポジトリに移動して実行します。 ```bash cd path/to/your-repo -# ワークスペースモード——staged + unstaged + untracked の変更をレビュー(デフォルト) +# ワークスペースモード —— staged + unstaged + untracked の変更をレビュー(デフォルト) ocr review -# ブランチ区間——`main..feature-branch` をレビュー +# ブランチ区間 —— `main..feature-branch` をレビュー ocr review --from main --to feature-branch -# 単一 commit——その commit が導入した diff をレビュー +# 単一 commit —— その commit が導入した diff をレビュー ocr review --commit abc123 ``` -進捗情報が継続的に出力され、最後に各ファイルに 1 つ以上のレビューコメントが表示されます。 +> `ocr review` の完全な引数(並行数のチューニング、出力形式、audience モード、背景コンテキストなど)と、その他すべてのサブコマンドは [CLI リファレンス](../cli-reference/) を参照してください。 -> ワークスペースモードには **untracked** ファイルが含まれます。すでにステージされた内容だけをレビューしたい場合は、まず -> `git add` で選択的にステージしてください。 - -> 上記の 3 つは基本的な使い方です。`ocr review` の完全な引数(並行数のチューニング、出力形式、 -> audience モード、背景コンテキストなど)と、その他すべてのサブコマンド(`config`、`rules`、 -> `llm test`、`viewer`)は [CLI リファレンス](../cli-reference/) を参照してください。 - -### 先に *何が* レビューされるか見てみたい場合 +### 先に何がレビューされるか見てみたい場合 ```bash -ocr review --preview # ワークスペース -ocr review -c abc123 -p # commit +ocr review --preview # ワークスペース +ocr review -c abc123 --preview # commit ``` -`--preview` は各フィルタステップを実行しますが LLM は一切呼び出さないため、token を消費しません。ファイルリストと -各ファイルのステータス(`added` / `modified` / `deleted` / `renamed` / `binary`)を出力し、 -除外されたファイルについてはその理由(`binary`、`unsupported_ext`、`default_path`、 -`user_exclude`、`deleted`)も示します。 +### システム向けの JSON 出力 -### ツール向けの JSON 出力 +`--audience agent` は人間向けの進捗 UI を抑制し、stdout を JSON / 最終サマリーだけにします —— 上流の agent や CI スクリプトが必要とするものです。 ```bash ocr review --format json --audience agent > review.json ``` -- `--format json` は機械可読なコメント配列を出力します。各コメントには `path`、`content`、 - `start_line`、`end_line`、`existing_code`、`suggestion_code`、およびオプションの - `thinking` が含まれます。 -- `--audience agent` は人間向けの進捗 UI を抑制し、stdout を JSON / 最終 - サマリーだけにします——上流の agent や CI スクリプトが必要とするものです。 - -## ステップ 5——結果を確認する - -各コメントには以下が含まれます。 - -| フィールド | 意味 | -|---|---| -| `path` | そのコメントが対象とするファイル。 | -| `content` | レビューコメント本体。設定された `language` を使用します。 | -| `start_line` / `end_line` | ファイルの **新しい** バージョンにおける行範囲。両方が `0` の場合は OCR がコメントを正確に配置できなかったことを意味します——問題は本物ですが、正確な位置は自分で特定する必要があります。 | -| `existing_code` | コメントが指す diff の断片。内部的に行解決に使われます。`start_line` が `0` のときに役立ちます。 | -| `suggestion_code` | オプションの修正断片。 | -| `thinking` | オプションのモデルの推論。一部のモデルにのみ存在します。 | - -## ステップ 6——過去のセッションを閲覧する - -各レビューは JSONL トランスクリプトとして -`~/.opencodereview/sessions/...` に永続化されます。ローカルの Web UI でそれらを閲覧できます。 - -```bash -ocr viewer # http://localhost:5483 -ocr viewer --addr :3000 -``` - -> UI の完全な紹介は [セッションビューア](../viewer/) を参照してください。 - ## 関連項目 -- [CLI リファレンス](../cli-reference/)——各サブコマンド、引数、出力モード。 -- [レビュールール](../review-rules/)——レビュー内容をカスタマイズします。 -- [インテグレーション](../integrations/)——OCR を Claude Code、Agent skill、CI に組み込みます。 -- [テレメトリ](../telemetry/)——OTLP 経由で trace と metrics を送信します。 -- [FAQ](../faq/)——既知のエラーと対策。 +- [インストール](../installation/) —— すべてのインストール方法と OCR の状態ディレクトリ。 +- [設定](../configuration/) —— 各環境変数、config key、組み込み provider。 +- [CLI リファレンス](../cli-reference/) —— 各サブコマンド、引数、出力モード。 +- [レビュールール](../review-rules/) —— レビュー内容をカスタマイズします。 +- [インテグレーション](../integrations/) —— OCR を Claude Code、Agent skill、CI に組み込みます。 +- [FAQ](../faq/) —— 既知のエラーと対策。 diff --git a/pages/src/content/docs/zh/configuration.md b/pages/src/content/docs/zh/configuration.md index 8f38b43..51ad3b1 100644 --- a/pages/src/content/docs/zh/configuration.md +++ b/pages/src/content/docs/zh/configuration.md @@ -4,182 +4,42 @@ sidebar: order: 5 --- -## 端点解析 +配置文件在 `~/.opencodereview/config.json`,你有三种方式编辑它: -当 `ocr review` 或 `ocr llm test` 运行时,它会按顺序尝试四个来源, -并使用第一个能给出完整 `(URL, token, model)` 三元组的来源: +- **交互式 TUI** —— `ocr config provider` / `ocr config model`,带引导菜单。 +- **命令行** —— `ocr config set `,适合脚本与 CI。 +- **手动编辑(不推荐)** —— 该 JSON 文件(下次 `ocr config set` 写入时会重新格式化)。 -| 优先级 | 来源 | 读取内容 | -|---|---|---| -| 1 | `~/.opencodereview/config.json` | 若设置了 `provider`,则通过 `providers`/`custom_providers` 映射解析(provider 优先;见[内置 provider](#built-in-providers))。仅当未设置 provider 时才回退到遗留的 `llm` 段。 | -| 2 | OCR 环境变量 | `OCR_LLM_URL`、`OCR_LLM_TOKEN`、`OCR_LLM_MODEL`、`OCR_USE_ANTHROPIC`、`OCR_LLM_AUTH_HEADER`。 | -| 3 | Claude Code 环境变量 | `ANTHROPIC_BASE_URL`、`ANTHROPIC_AUTH_TOKEN`、`ANTHROPIC_MODEL`。 | -| 4 | Shell rc 文件 | 从 `~/.zshrc`、`~/.bashrc`、`~/.bash_profile`、`~/.profile` 中解析出的 `export ANTHROPIC_*=…` 行。 | +## 配置模型 -对于 Claude Code 风格的来源,若 `ANTHROPIC_BASE_URL` 缺少带版本的路径 -(`/v1/...`),OCR 会自动追加 `/v1/messages`。 - -如果没有一种策略能给出完整三元组,OCR 会以如下信息退出: - -``` -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 -``` - -> 解析在第一个**报错**的来源处停止,而不仅仅是第一个为空的来源。尤其要注意: -> 如果 `config.json` 中设置了 `provider` 但该条目配置有误(未知的 provider 名、 -> 缺少 `api_key` 且无环境变量回退、缺少 `model`、自定义 provider 缺少 -> `url`/`protocol`),OCR 会以该错误退出,并**不会**继续回退到 OCR 环境变量、 -> Claude Code 或 rc 文件来源。要切换到基于环境变量的配置,请先取消 -> 设置 `provider` key。 - -> 来源优先级意味着当配置文件已完整填充时,**环境变量不会覆盖任何值**。要让 -> 环境变量生效,要么从 `~/.opencodereview/config.json` 删除相关 `llm.*` key, -> 要么用 `ocr config set` 切换到新值。 - -## `ocr config set` ——管理 `~/.opencodereview/config.json` - -```bash -ocr config set -``` - -`config set` 通过 key/value 对修改文件,并做具备 schema 感知的解析。交互式 TUI -命令 `ocr config provider` 和 `ocr config model` 也写入同一个文件(见 -[交互式设置](#interactive-setup--ocr-config-provider--ocr-config-model))。识别的 -key: - -| Key | 类型 | 说明 | -|---|---|---| -| `provider` | string | 设置当前 provider(内置名或自定义)。切换 provider 会清空 model。 | -| `model` | string | 为当前 provider 设置 model(存在 provider 条目下;若无 provider 则存到顶层 `model`)。 | -| `providers..` | varies | 内置 provider 的按字段设置:`api_key`、`url`、`protocol`、`model`、`models`、`auth_header`、`extra_body`。 | -| `custom_providers..` | varies | 同上字段,用于自定义(非内置)provider。自定义 provider 至少要设置 `url` 和 `protocol`。 | -| `llm.url` | string | 端点 URL。Anthropic 用完整的 Messages URL,如 `https://api.anthropic.com/v1/messages`。OpenAI 兼容则用 chat-completions URL。 | -| `llm.auth_token` | string | API key。以 `Authorization: Bearer …` 发送(OpenAI);遗留 Anthropic 路径默认也是 `Authorization: Bearer …`(预设 `anthropic` provider 改为默认 `x-api-key`)。仅在显式设置 `llm.auth_header` 时才用 `x-api-key`。 | -| `llm.auth_header` | string | Auth header 名(`x-api-key`、`authorization` 或 `bearer`)。仅 Anthropic 用;某些需要 `x-api-key` 的 Anthropic 设置必需。 | -| `llm.model` | string | 模型名。`[<数字>m]` 后缀会被自动去除。 | -| `llm.use_anthropic` | boolean | `true`(默认)→ Anthropic Messages 协议。`false` → OpenAI Chat Completions。 | -| `llm.extra_body` | JSON object | 厂商专属的请求字段,合并进每次 chat 请求体。示例:`'{"thinking":{"type":"disabled"}}'`。 | -| `language` | string | 转发为追加到 system prompt 的指令;未设置时默认 `English`。见[选择语言](#choosing-a-language)。 | -| `telemetry.enabled` | boolean | OpenTelemetry 导出的总开关。默认关闭。 | -| `telemetry.exporter` | string | `console` 或 `otlp`。 | -| `telemetry.otlp_endpoint` | string | OTLP collector 地址(如 `localhost:4317`)。 | -| `telemetry.content_logging` | boolean | 在导出的事件数据中包含 LLM prompt / 响应。 | - -示例: - -```bash -ocr config set llm.url https://api.anthropic.com/v1/messages -ocr config set llm.auth_token sk-ant-xxxxxxxxxx -ocr config set llm.model claude-opus-4-6 -ocr config set llm.use_anthropic true -ocr config set llm.extra_body '{"thinking":{"type":"disabled"}}' -ocr config set language English -ocr config set telemetry.enabled true -ocr config set telemetry.exporter otlp -ocr config set telemetry.otlp_endpoint localhost:4317 - -# 基于 provider 的设置(推荐) -ocr config set provider anthropic -ocr config set model claude-opus-4-6 -ocr config set providers.anthropic.api_key "$ANTHROPIC_API_KEY" - -# 自定义(非内置)provider -ocr config set provider my-gateway -ocr config set custom_providers.my-gateway.url https://gateway.internal.com/v1 -ocr config set custom_providers.my-gateway.protocol openai -ocr config set custom_providers.my-gateway.model llama-3-70b -ocr config set custom_providers.my-gateway.api_key "$MY_API_KEY" -``` - -布尔值接受 Go `strconv.ParseBool` 接受的任何形式(`true`、`false`、`1`、`0`、 -`t`、`f`……)。`llm.extra_body` 必须是合法 JSON。 - -## 文件 schema 参考 - -执行上述命令后,`~/.opencodereview/config.json` 形如: - -```json -{ - "llm": { - "url": "https://api.anthropic.com/v1/messages", - "auth_token": "sk-ant-xxxxxxxxxx", - "auth_header": "x-api-key", - "model": "claude-opus-4-6", - "use_anthropic": true, - "extra_body": { - "thinking": { "type": "disabled" } - } - }, - "language": "English", - "telemetry": { - "enabled": true, - "exporter": "otlp", - "otlp_endpoint": "localhost:4317" - } -} -``` - -基于 provider 的形式使用 `provider`、`model`、`providers` 和 -`custom_providers`,而非遗留的 `llm` 块: - -```json -{ - "provider": "anthropic", - "model": "claude-opus-4-6", - "providers": { - "anthropic": { - "api_key": "sk-ant-xxxxxxxxxx", - "model": "claude-opus-4-6" - } - }, - "custom_providers": { - "my-gateway": { - "url": "https://gateway.internal.com/v1", - "protocol": "openai", - "model": "llama-3-70b", - "models": ["llama-3-70b", "llama-3-8b"], - "api_key": "gw-xxxxxxxxxx", - "auth_header": "authorization" - } - }, - "language": "English" -} -``` - -当设置了 `provider` 时,由 `providers`/`custom_providers` 映射驱动解析;该配置下 -遗留的 `llm` 段被忽略。 - -你也可以手动编辑此文件,但下次写入时 `ocr config set` 会以 `" "` 缩进 -重新序列化。 - -## 交互式设置——`ocr config provider` / `ocr config model` - -为免去手动键入 key 来选择 provider 和 model,OCR 提供两个交互式 Bubble Tea TUI, -二者同样会修改 `~/.opencodereview/config.json`。 +### 推荐:交互式设置 ```bash ocr config provider +``` + +它会让你选择一个内置或自定义 provider、填入 API key、挑选 model,保存到配置文件后自动运行一次 `ocr llm test` 验证端点。之后想换模型: + +```bash ocr config model ``` -- `ocr config provider`——选择内置或自定义 provider,并输入 URL / protocol / - API key / model 的交互式 TUI。选择会保存到 config,并自动运行 `ocr llm test` - 验证端点。对于内置 provider,若未直接输入,API key 可从该 provider 的环境变量 - 读取(见[内置 provider](#built-in-providers))。若选择手动配置,则改为填充遗留的 - `llm.*` 块。 -- `ocr config model`——从当前 provider 的预设列表,以及 - `providers..models` / `custom_providers..models` 下用户添加的 - model 中选择模型的交互式 TUI。需要先设置 provider(`ocr config provider`)。 +### 非交互设置(CI / 无 TUI 环境) -## 内置 provider +用 `ocr config set` 写入同一份配置: -以下 provider 随 OCR 发布。每个都有预设的 `BaseURL`、`Protocol`,以及 -(如适用)一个 API key 环境变量,在 `providers..api_key` 未设置时作为 -回退。 +```bash +ocr config set provider anthropic +ocr config set model claude-opus-4-6 +ocr config set providers.anthropic.api_key sk-ant-xxxxxxxxxx +``` -| 名称 | Protocol | Base URL | API key 环境变量 | +### 内置 provider + +下列 provider 随 OCR 发布,已预置 Base URL 与协议,选中后只需填 API key。 +若 `providers..api_key` 未设置,会自动回退到对应的环境变量。 + +| 名称 | 协议 | Base URL | API key 环境变量 | |---|---|---|---| | `anthropic` | anthropic | `https://api.anthropic.com` | `ANTHROPIC_API_KEY` | | `openai` | openai | `https://api.openai.com/v1` | `OPENAI_API_KEY` | @@ -195,78 +55,47 @@ ocr config model | `minimax` | openai | `https://api.minimaxi.com/v1` | `MINIMAX_API_KEY` | | `baidu-qianfan` | openai | `https://qianfan.baidubce.com/v2` | `QIANFAN_API_KEY` | -任何其他 provider 名都被视为自定义,必须在 `custom_providers` 下配置,且至少 -要有 `url` 和 `protocol`。 +### 自定义 provider -## 环境变量参考 - -| 变量 | 用途 | -|---|---| -| `OCR_LLM_URL` | 端点 URL——与 `llm.url` 同形。 | -| `OCR_LLM_TOKEN` | API key——与 `llm.auth_token` 相同。 | -| `OCR_LLM_MODEL` | 模型名。 | -| `OCR_LLM_AUTH_HEADER` | Auth header 名(`x-api-key`、`authorization` 或 `bearer`)。仅 Anthropic;与 `llm.auth_header` 相同。未设置时默认 `authorization`。 | -| `OCR_USE_ANTHROPIC` | 未设置 → Anthropic 协议(默认)。设为 `true` / `1` / `yes`(不区分大小写)→ Anthropic。设为其他值(`false`、`0`、`no`、拼写错误……)→ OpenAI。 | -| `ANTHROPIC_BASE_URL` | Claude Code 兼容的 base URL。 | -| `ANTHROPIC_AUTH_TOKEN` | Claude Code 兼容的 API key。 | -| `ANTHROPIC_MODEL` | Claude Code 兼容的 model。 | -| `OCR_ENABLE_TELEMETRY` | `1` 表示从环境变量启用遥测。 | -| `OTEL_SERVICE_NAME` | 覆盖 span/metric 中的 service name。 | -| `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP collector 地址——同时强制 exporter 为 `otlp`。 | -| `OTEL_EXPORTER_OTLP_PROTOCOL` | OTLP 传输协议(`grpc`、`http/protobuf` 或 `http/json`)。默认 `grpc`。 | -| `OCR_CONTENT_LOGGING` | `1` 表示在遥测事件中包含 prompt/响应。 | - -各 provider 的 API key(`ANTHROPIC_API_KEY`、`OPENAI_API_KEY`、 -`DASHSCOPE_API_KEY`……)在内置 provider 的 `api_key` 字段未设置时作为回退。 -各 provider 的环境变量名见[内置 provider](#built-in-providers)表。 - -## 为什么有 `extra_body` - -一些托管 provider 会在请求体中加入非标准字段(例如 Bedrock 风格的 `thinking`、 -厂商专属的 `temperature_strategy`、流式选项)。`llm.extra_body` 会被合并进每个 -发出的请求,因此你无需改源码即可发送这些字段。 +任何不在上表中的 provider 名都视为自定义,至少要提供 `url` 和 `protocol` +(`protocol` 取 `anthropic` 或 `openai`): ```bash -ocr config set llm.extra_body '{"thinking":{"type":"enabled","budget_tokens":2048}}' +ocr config set provider my-gateway +ocr config set custom_providers.my-gateway.url https://gateway.internal.com/v1 +ocr config set custom_providers.my-gateway.protocol openai +ocr config set custom_providers.my-gateway.model llama-3-70b +ocr config set custom_providers.my-gateway.api_key "$MY_API_KEY" ``` -## 选择语言 - -`language` key 只控制一件事:追加到评审和 `ocr llm test` prompt 中每条 -system-role 消息的一条指令。注入的精确字符串是: - -``` -\n\nAlways respond in . -``` - -- *未设置*或为空——按 `English` 对待。 -- `Chinese`、`English` 或任何其他字符串——原样透传。 - -内置 rule docs 不支持语言切换。`internal/config/rules/rule_docs/` 下嵌入的文件按 -固定文件名加载,多数以中文撰写(`default.md` 是英文例外);无论 `language` -如何设置,它们都原样出现在 prompt 中。因此当 `language` 设为 `English` 时,prompt -里会是一条英文指令叠加大段中文 rule 文本——强模型会遵从指令产出英文评论, -弱模型可能输出中英混杂的内容。 - -`language` 没有环境变量、CLI 参数或项目级覆盖——唯一能设置它的地方是全局 -`~/.opencodereview/config.json`,通过 -[`ocr config set`](#ocr-config-set--managing-opencodereviewconfigjson): +### 验证连通性 ```bash +ocr llm test +``` + +### 复用已有的环境变量 + +如果你已经配好了 Claude Code 的 `ANTHROPIC_*`,或 OCR 自己的 `OCR_LLM_*`环境变量,OCR 会自动识别,无需再写配置文件。 + +### 发送厂商专属字段 + +某些 provider 需要非标准的请求字段(如 Bedrock 风格的 `thinking`)。用`extra_body`(合并进每次请求)即可发送,无需改源码: + +```bash +ocr config set providers.anthropic.extra_body '{"thinking":{"type":"disabled"}}' +``` + +## 配置评审语言 + +`language` 决定评审评论用哪种语言输出,未设置时默认英文: + +```bash +ocr config set language 中文 ocr config set language English ``` -如果你需要纯英文 rule 文本,请通过 `--rule`、`/.opencodereview/rule.json` -或 `~/.opencodereview/rule.json` 提供自己的规则(见 -[评审规则](../review-rules/#priority-chain))。 - -## 项目级 vs 全局配置 - -CLI 本身是全局配置的(`~/.opencodereview/config.json`)——没有项目级 LLM 配置。 -但**评审规则**是项目级的;见[评审规则](../review-rules/#priority-chain)。 - ## 另见 - [快速开始](../quickstart/)——最小化设置与首次评审。 - [CLI 参考](../cli-reference/)——review 命令接受的每个参数。 -- [遥测](../telemetry/)——如何接入 OTLP / console exporter。 diff --git a/pages/src/content/docs/zh/faq.md b/pages/src/content/docs/zh/faq.md index 262ee05..838c8b7 100644 --- a/pages/src/content/docs/zh/faq.md +++ b/pages/src/content/docs/zh/faq.md @@ -17,7 +17,7 @@ no valid LLM endpoint configured; one of OCR_LLM_URL/OCR_LLM_TOKEN/OCR_LLM_MODEL ANTHROPIC_MODEL must be set ``` -OCR 走完了四来源解析链([配置](../configuration/#endpoint-resolution))但没 +OCR 走完了整条端点解析链([配置](../configuration/#复用已有的环境变量))但没 找到完整的 `(URL, token, model)` 三元组。要么: - 运行 `ocr config set llm.url …` / `llm.auth_token …` / `llm.model …` 填充 diff --git a/pages/src/content/docs/zh/installation.md b/pages/src/content/docs/zh/installation.md index 8229860..d6dca95 100644 --- a/pages/src/content/docs/zh/installation.md +++ b/pages/src/content/docs/zh/installation.md @@ -4,44 +4,60 @@ sidebar: order: 4 --- -安装 `ocr` CLI 有四种受支持的方式。它们产出的都是同一个二进制——按你的 -环境选择即可。 +安装 `ocr` CLI 有四种受支持的方式。 ## NPM(推荐) +#### 安装 + ```bash npm install -g @alibaba-group/open-code-review ``` -NPM 包附带一个小的 wrapper 脚本(`bin/ocr.js`)和一个 -[postinstall hook](https://github.com/alibaba/open-code-review/blob/main/scripts/install.js), -它会: - -1. 探测你的平台(`darwin-amd64`、`darwin-arm64`、`linux-amd64`、 - `linux-arm64`、`windows-amd64`、`windows-arm64`)。 -2. 从 GitHub Releases 下载匹配的二进制。 -3. (当存在校验和数据时)验证它,并放到 wrapper 旁边。 - -如果某个平台专属 npm 包(如 `@alibaba-group/ocr-darwin-arm64`)作为 -optional dependency 被安装,则直接使用该二进制,跳过下载。 - -运行 `ocr` 时,wrapper 只是 `exec` 下载好的二进制,因此首次运行后实际开销 -为零。 - -### 更新 +固定到某个版本: ```bash -npm update -g @alibaba-group/open-code-review -# 或固定到某个版本: npm install -g @alibaba-group/open-code-review@ ``` -### 卸载 +#### 更新 + +通过 NPM 安装时,`ocr` 默认会自动保持最新(静态二进制不参与此机制)。每次运行 +`ocr` 时,wrapper 会在后台静默检查 registry 上的最新版本,发现更新即自动升级, +不影响本次评审。两次检查之间有 18 分钟冷却,可用 `OCR_UPDATE_INTERVAL`(分钟)调整。 + +关闭自动更新,将 `OCR_NO_UPDATE` 设为任意非空值即可: + +```bash +export OCR_NO_UPDATE=1 +``` + +#### 卸载 ```bash npm uninstall -g @alibaba-group/open-code-review ``` +## 安装脚本(curl | sh) + +一个便捷安装器,封装了 GitHub Release 二进制下载(带校验)——适合 CI 基础 +镜像和无界面环境: + +```bash +curl -fsSL https://raw.githubusercontent.com/alibaba/open-code-review/main/install.sh | sh +``` + +它识别两个环境变量: + +| 变量 | 默认值 | 用途 | +|---|---|---| +| `OCR_INSTALL_DIR` | `/usr/local/bin` | 放置 `ocr` 二进制的位置。 | +| `OCR_VERSION` | 最新 release | 固定到某个 release tag(如 `v1.2.3`)。 | + +该脚本支持 `darwin` 与 `linux` 的 `amd64` / `arm64`;Windows 请改用 +[GitHub Release 二进制](#github-release-binary)或 [NPM](#npm-recommended) +方式。 + ## GitHub Release 二进制 如果你不想装 Node.js,可直接从 @@ -79,37 +95,17 @@ curl -LO https://github.com/alibaba/open-code-review/releases/latest/download/sh shasum -a 256 -c sha256sum.txt --ignore-missing ``` -## 安装脚本(curl | sh) - -一个便捷安装器,封装了 GitHub Release 二进制下载(带校验)——适合 CI 基础 -镜像和无界面环境: - -```bash -curl -fsSL https://raw.githubusercontent.com/alibaba/open-code-review/main/install.sh | sh -``` - -它识别两个环境变量: - -| 变量 | 默认值 | 用途 | -|---|---|---| -| `OCR_INSTALL_DIR` | `/usr/local/bin` | 放置 `ocr` 二进制的位置。 | -| `OCR_VERSION` | 最新 release | 固定到某个 release tag(如 `v1.2.3`)。 | - -该脚本支持 `darwin` 与 `linux` 的 `amd64` / `arm64`;Windows 请改用 -[GitHub Release 二进制](#github-release-binary)或 [NPM](#npm-recommended) -方式。 - ## 从源码构建 仅当你要修改 OCR 本身,或在某个没有预编译二进制的平台上运行时才需要此方式。 -### 前置条件 +#### 前置条件 - [Go ≥ 1.25](https://go.dev/dl/) - [Git](https://git-scm.com/) - [Make](https://www.gnu.org/software/make/) -### 构建 +#### 构建 ```bash git clone https://github.com/alibaba/open-code-review.git @@ -118,7 +114,7 @@ make build # 产出 dist/opencodereview sudo cp dist/opencodereview /usr/local/bin/ocr ``` -### 为其他平台构建 +#### 为其他平台构建 ```bash make build-linux-amd64 @@ -134,7 +130,7 @@ make sha256sum # 同时产出 sha256sum.txt `make dist` 会运行 `clean → build-all → sha256sum`,并在二进制旁写入一个 `VERSION` 文件——这正是 release 流水线执行的步骤。 -### 运行测试 +#### 运行测试 ```bash make test # LC_ALL=C go test -v -race -count=1 ./... @@ -164,7 +160,7 @@ echo $PATH | `~/.opencodereview/config.json` | LLM 端点、语言、遥测配置(由 `ocr config set` 管理)。 | | `~/.opencodereview/rule.json` | 可选的全局评审规则。 | | `~/.opencodereview/sessions//.jsonl` | 每次评审会话的流式 JSONL 转录,供 `ocr viewer` 使用。 | -| `~/.opencodereview/{last-update-check,update.lock,update-available}` | NPM wrapper 的后台更新检查状态。wrapper 会轮询是否有更新的 release(默认约每 18 分钟一次)并打印升级提示。用 `OCR_NO_UPDATE=1` 禁用,或用 `OCR_UPDATE_INTERVAL`(秒)调整间隔。静态二进制不写入这些文件。 | +| `~/.opencodereview/{last-update-check,update.lock,update-available}` | NPM wrapper 的后台更新检查状态。wrapper 会轮询是否有更新的 release(默认约每 18 分钟一次)并打印升级提示。用 `OCR_NO_UPDATE=1` 禁用,或用 `OCR_UPDATE_INTERVAL`(分钟)调整间隔。静态二进制不写入这些文件。 | | `/.opencodereview/rule.json` | 可选的项目级评审规则——可安全提交。 | OCR 永远不会写入 `~/.opencodereview/` 之外(除 NPM 临时下载二进制外)。 diff --git a/pages/src/content/docs/zh/quickstart.md b/pages/src/content/docs/zh/quickstart.md index 1fa7aa0..10f8753 100644 --- a/pages/src/content/docs/zh/quickstart.md +++ b/pages/src/content/docs/zh/quickstart.md @@ -4,232 +4,92 @@ sidebar: order: 3 --- -安装 OCR,连接到任意支持 Anthropic Messages API 或 OpenAI Chat Completions API -的 LLM,然后运行你的第一次代码评审。 +几分钟内跑通第一次代码评审。 ## 前置条件 -- 一个可用的 **Git** 安装——OCR 以子进程方式驱动 Git 读取 diff。 -- 一个兼容 Anthropic 或 OpenAI 的 LLM 的 **API key**。 -- 以下之一: - - **Node.js ≥ 18**(推荐;最低支持 Node 14——通过 NPM 安装)。 - - 或仅用 `curl` + `chmod` 把静态二进制放进 `$PATH`。 - - 或 **Go ≥ 1.25**,如果你偏好从源码构建。 +- **Git ≥ 2.41** +- **Node.js ≥ 18** +- **LLM API key** -## 第 1 步——安装 CLI - -### 方式 A:NPM(推荐) +## 第 1 步 —— 安装 CLI ```bash npm install -g @alibaba-group/open-code-review +ocr version ``` -NPM 包安装一个小的 wrapper,它在安装时(通过 postinstall hook)为你的 -操作系统 / 架构下载正确的二进制。如果运行时二进制缺失,wrapper 会报错而 -不会去下载。安装后,你得到一个全局 `ocr` 命令: +> 更多方式见 [安装](../installation/)。 + +## 第 2 步 —— 配置 LLM ```bash -ocr --version +ocr config provider ``` -### 方式 B:GitHub Release 二进制 - -从 [releases 页面](https://github.com/alibaba/open-code-review/releases) -选择对应平台的二进制,放进你的 `$PATH`: +它会让你选择一个内置或自定义 provider、填入 API key、挑选 model,保存到配置文件后自动运行一次 `ocr llm test` 验证端点。之后想换模型: ```bash -# macOS (Apple Silicon) -curl -Lo ocr https://github.com/alibaba/open-code-review/releases/latest/download/opencodereview-darwin-arm64 -chmod +x ocr && sudo mv ocr /usr/local/bin/ocr - -# macOS (Intel) -curl -Lo ocr https://github.com/alibaba/open-code-review/releases/latest/download/opencodereview-darwin-amd64 -chmod +x ocr && sudo mv ocr /usr/local/bin/ocr - -# Linux x86_64 -curl -Lo ocr https://github.com/alibaba/open-code-review/releases/latest/download/opencodereview-linux-amd64 -chmod +x ocr && sudo mv ocr /usr/local/bin/ocr - -# Linux ARM64 -curl -Lo ocr https://github.com/alibaba/open-code-review/releases/latest/download/opencodereview-linux-arm64 -chmod +x ocr && sudo mv ocr /usr/local/bin/ocr - -# Windows (AMD64) -curl -Lo ocr.exe https://github.com/alibaba/open-code-review/releases/latest/download/opencodereview-windows-amd64.exe - -# Windows (ARM64) -curl -Lo ocr.exe https://github.com/alibaba/open-code-review/releases/latest/download/opencodereview-windows-arm64.exe +ocr config model ``` -### 方式 C:从源码构建 +### 备选:非交互命令 + +在 CI 或无 TUI 的环境里,用 `ocr config set` 直接写入同一份配置: ```bash -git clone https://github.com/alibaba/open-code-review.git -cd open-code-review -make build -sudo cp dist/opencodereview /usr/local/bin/ocr +ocr config set provider anthropic +ocr config set model claude-opus-4-6 +ocr config set providers.anthropic.api_key sk-ant-xxxxxxxxxx ``` -> 各安装方式的详情见 [安装](../installation/),包括 NPM wrapper 如何解析 -> 平台二进制。 - -## 第 2 步——配置 LLM - -在能解析出一个完整的 LLM 端点(URL + token + model)之前,OCR 会拒绝运行 -评审。它按以下优先级顺序搜索四个来源: - -1. `~/.opencodereview/config.json` -2. OCR 专属环境变量(`OCR_LLM_*`) -3. Claude Code 环境变量(`ANTHROPIC_*`) -4. 从你的 shell rc 文件(`~/.zshrc`、`~/.bashrc`、`~/.bash_profile`、 - `~/.profile`)中解析出的 `export ANTHROPIC_*` 行 - -### 最快路径:`ocr config set` - -```bash -ocr config set llm.url https://api.anthropic.com/v1/messages -ocr config set llm.auth_token sk-ant-xxxxxxxxxx -ocr config set llm.model claude-opus-4-6 -ocr config set llm.use_anthropic true -``` - -这些值会持久化到 `~/.opencodereview/config.json`。 - -### 替代方式:环境变量 - -优先级最高——适合不想在磁盘上留配置文件的 CI / 容器: - -```bash -export OCR_LLM_URL=https://api.anthropic.com/v1/messages -export OCR_LLM_TOKEN=sk-ant-xxxxxxxxxx -export OCR_LLM_MODEL=claude-opus-4-6 -export OCR_USE_ANTHROPIC=true # 默认 true;设为 false 走 OpenAI 协议 -``` - -### 已经在用 Claude Code? - -OCR 会自动读取 Claude Code 使用的同一批变量,无需额外配置: - -```bash -export ANTHROPIC_BASE_URL=https://api.anthropic.com -export ANTHROPIC_AUTH_TOKEN=sk-ant-xxxxxxxxxx -export ANTHROPIC_MODEL=claude-opus-4-6 -``` - -如果 `ANTHROPIC_BASE_URL` 缺少带版本的路径,OCR 会自动追加 -`/v1/messages`。 - -### 使用 OpenAI 兼容端点? - -把 `llm.use_anthropic` 设为 `false`(或 `OCR_USE_ANTHROPIC=false`): - -```bash -ocr config set llm.url https://api.openai.com/v1/chat/completions -ocr config set llm.auth_token sk-xxxxxxxxxx -ocr config set llm.model gpt-4o -ocr config set llm.use_anthropic false -``` - -> 完整的 key 参考见 [配置](../configuration/),包括用于厂商专属请求字段 -> 的 `llm.extra_body`,以及用于切换评审评论语言的 `language`。 - -## 第 3 步——测试连通性 +## 第 3 步 —— 测试连通性 ```bash ocr llm test ``` -预期输出(模型名会有所不同): +如果报出 `no valid LLM endpoint configured` 这类错误,请重新检查第 2 步的配置。 401 / 403 表示 token 错误或已过期。 -``` -Source: OCR config file -URL: https://api.anthropic.com/v1/messages -Model: claude-opus-4-6 -Hello! … -``` - -如果反而报出 `no valid LLM endpoint configured` 这类错误,请重新检查上面的 -配置 key。401 / 403 表示 token 错误或已过期。 - -## 第 4 步——运行第一次评审 +## 第 4 步 —— 运行第一次评审 进入任意 Git 仓库并运行: ```bash cd path/to/your-repo -# 工作区模式——评审 staged + unstaged + untracked 变更(默认) +# 工作区模式 —— 评审 staged + unstaged + untracked 变更(默认) ocr review -# 分支区间——评审 `main..feature-branch` +# 分支区间 —— 评审 `main..feature-branch` ocr review --from main --to feature-branch -# 单个 commit——评审该 commit 引入的 diff +# 单个 commit —— 评审该 commit 引入的 diff ocr review --commit abc123 ``` -你会看到持续输出的进度信息,最后每个文件出现一条或多条评审评论。 +> `ocr review` 的完整参数(并发调优、输出格式、audience模式、背景上下文等)及其他所有子命令见 [CLI 参考](../cli-reference/)。 -> 工作区模式包含 **untracked** 文件。如果你只想评审已暂存的内容,请先用 -> `git add` 选择性暂存。 - -> 以上三种是基础用法。`ocr review` 的完整参数(并发调优、输出格式、 -> audience 模式、背景上下文等)及其他所有子命令(`config`、`rules`、 -> `llm test`、`viewer`)见 [CLI 参考](../cli-reference/)。 - -### 想先看看 *会* 评审什么? +### 想先看看会评审什么? ```bash -ocr review --preview # 工作区 -ocr review -c abc123 -p # commit +ocr review --preview # 工作区 +ocr review -c abc123 --preview # commit ``` -`--preview` 运行每个过滤步骤但绝不调用 LLM,因此不消耗任何 token。它打印文件列表 -及每个文件的状态(`added` / `modified` / `deleted` / `renamed` / `binary`), -对于被排除的文件还会给出原因(`binary`、`unsupported_ext`、`default_path`、 -`user_exclude`、`deleted`)。 +### 面向系统的 JSON 输出 -### 给工具用的 JSON 输出 +`--audience agent` 屏蔽人性化的进度 UI,让 stdout 只剩 JSON / 最终摘要 —— 正是上游 agent 或 CI 脚本所需。 ```bash ocr review --format json --audience agent > review.json ``` -- `--format json` 输出一个机器可读的评论数组,每条含 `path`、`content`、 - `start_line`、`end_line`、`existing_code`、`suggestion_code` 和可选的 - `thinking`。 -- `--audience agent` 屏蔽人性化的进度 UI,让 stdout 只剩 JSON / 最终 - 摘要——正是上游 agent 或 CI 脚本所需。 - -## 第 5 步——查看结果 - -每条评论包含: - -| 字段 | 含义 | -|---|---| -| `path` | 该评论所针对的文件。 | -| `content` | 评审评论本身,使用配置的 `language`。 | -| `start_line` / `end_line` | 文件 **新** 版本中的行范围。两者都为 `0` 表示 OCR 无法精确定位评论——问题是真实的,但需自行定位到准确位置。 | -| `existing_code` | 评论所指的 diff 片段。内部用于行解析;在 `start_line` 为 `0` 时有用。 | -| `suggestion_code` | 可选的修复片段。 | -| `thinking` | 可选的模型推理。仅部分模型存在。 | - -## 第 6 步——查看历史会话 - -每次评审都会以 JSONL 转录形式持久化到 -`~/.opencodereview/sessions/...`。在本地 Web UI 中浏览它们: - -```bash -ocr viewer # http://localhost:5483 -ocr viewer --addr :3000 -``` - -> 完整 UI 介绍见 [会话查看器](../viewer/)。 - ## 另见 -- [CLI 参考](../cli-reference/)——每个子命令、参数与输出模式。 -- [评审规则](../review-rules/)——自定义评审内容。 -- [集成](../integrations/)——把 OCR 嵌入 Claude Code、Agent skill 或 CI。 -- [遥测](../telemetry/)——经 OTLP 上报 trace 与 metrics。 -- [FAQ](../faq/)——已知错误与对策。 +- [安装](../installation/) —— 全部安装方式与 OCR 的状态目录。 +- [配置](../configuration/) —— 每个环境变量、config key 与内置 provider。 +- [CLI 参考](../cli-reference/) —— 每个子命令、参数与输出模式。 +- [评审规则](../review-rules/) —— 自定义评审内容。 +- [集成](../integrations/) —— 把 OCR 嵌入 Claude Code、Agent skill 或 CI。 +- [FAQ](../faq/) —— 已知错误与对策。 From 5e8099f9e3425516cc6ed6790ef7c93a050c832c Mon Sep 17 00:00:00 2001 From: kite Date: Fri, 3 Jul 2026 18:00:00 +0800 Subject: [PATCH 58/87] docs(pages): remove overview page across all languages and clean up references - Delete en/zh/ja overview.md - Remove overview imports, DocSlug entry and doc maps in index.ts - Drop overview sidebar item and switch default slug to quickstart in DocsPage - Remove overview-related i18n keys from en/zh/ja - Sync viewer.md heading emphasis removal to en/ja --- pages/src/content/docs/en/overview.md | 88 --------------------------- pages/src/content/docs/en/viewer.md | 2 +- pages/src/content/docs/index.ts | 7 --- pages/src/content/docs/ja/overview.md | 75 ----------------------- pages/src/content/docs/ja/viewer.md | 2 +- pages/src/content/docs/zh/overview.md | 74 ---------------------- pages/src/content/docs/zh/viewer.md | 2 +- pages/src/i18n/en.ts | 11 ---- pages/src/i18n/ja.ts | 11 ---- pages/src/i18n/zh.ts | 11 ---- pages/src/pages/DocsPage.tsx | 3 +- 11 files changed, 4 insertions(+), 282 deletions(-) delete mode 100644 pages/src/content/docs/en/overview.md delete mode 100644 pages/src/content/docs/ja/overview.md delete mode 100644 pages/src/content/docs/zh/overview.md diff --git a/pages/src/content/docs/en/overview.md b/pages/src/content/docs/en/overview.md deleted file mode 100644 index ee1601c..0000000 --- a/pages/src/content/docs/en/overview.md +++ /dev/null @@ -1,88 +0,0 @@ ---- -title: Overview -sidebar: - order: 2 ---- - -## What is Open Code Review? - -Open Code Review (**OCR** for short, distinct from Optical Character -Recognition) is an AI-powered code review CLI distributed as the -[`@alibaba-group/open-code-review`](https://www.npmjs.com/package/@alibaba-group/open-code-review) -NPM package and as standalone Go binaries. The CLI binary is named `ocr`. - -In a single command (`ocr review`) it: - -1. Resolves a Git diff — workspace, branch range, or single commit. -2. Filters the changed files using both system defaults and any user rules. -3. Spawns one **per-file sub-agent** for each changed file, in parallel. -4. Each sub-agent runs an LLM tool-use loop, optionally preceded by a - **plan phase** for larger diffs. -5. The model calls `code_comment` to record findings, optionally `file_read`, - `code_search`, `file_find`, `file_read_diff` to gather context, and - `task_done` when finished. -6. OCR resolves each comment to exact line numbers, runs an optional - re-positioning pass for any comments that didn't match cleanly, and - prints (or JSON-emits) the final list. - -## The problem with general-purpose agents - -If you've used a general-purpose coding agent (Claude Code with a Skill, -Cursor, Cline, etc.) for code review, you've likely run into: - -- **Incomplete coverage** — on larger changesets the agent quietly cuts - corners, reviewing only some files. -- **Position drift** — comments don't line up with the code they refer to; - line numbers and file paths drift off target. -- **Unstable quality** — natural-language Skills are hard to debug, and - output quality fluctuates with minor prompt edits. - -The root cause: a purely language-driven architecture lacks **hard -constraints** on the review process. - -## Core design: deterministic engineering × agent - -OCR's core philosophy is to combine **deterministic engineering** with an -**agent** — each handling what it does best. - -### Deterministic engineering — hard constraints - -For steps that *must not go wrong*, engineering logic — not the model — -guarantees correctness: - -- **Precise file selection** — a [five-gate filter](../review-rules/#how-files-are-filtered) - decides exactly which files are reviewed, with explicit `include`/`exclude` - controls. -- **Smart file bundling** — related files (e.g., `message_en.properties` and - `message_zh.properties`) can be grouped into a single review unit. Each - bundle runs as a sub-agent with isolated context — divide and conquer that - stays stable on very large changesets and naturally supports concurrent - review. -- **Fine-grained rule matching** — review rules are matched per file path - with first-match-wins, keeping the model's attention sharply focused and - eliminating noise. Template-based matching is more stable than purely - language-driven rule guidance. -- **External positioning and reflection modules** — independent comment - positioning ([`internal/diff/relocation.go`](https://github.com/alibaba/open-code-review/blob/main/internal/diff/relocation.go)) - and re-location passes systematically improve both location and content - accuracy. - -### Agent — dynamic decision-making - -The agent's strengths are concentrated where they matter most: - -- **Scenario-tuned prompts** — prompt templates deeply optimized for code - review, improving effectiveness while reducing token consumption (see - [`internal/config/template/task_template.json`](https://github.com/alibaba/open-code-review/blob/main/internal/config/template/task_template.json)). -- **Scenario-tuned toolset** — distilled from analysis of tool-call traces in - large-scale production data (call-frequency distributions, per-tool - repetition rates, the impact of each tool on the overall call chain). The - result is a purpose-built set of [six tools](../tools/) that is more stable - and predictable than a generic agent toolkit. - -## See Also - -- [QuickStart](../quickstart/) — install and run your first review. -- [Architecture](../architecture/) — the agent loop, plan phase, and memory compression. -- [CLI Reference](../cli-reference/) — every flag and sub-command. -- [Integrations](../integrations/) — call OCR from Claude Code or any agent. diff --git a/pages/src/content/docs/en/viewer.md b/pages/src/content/docs/en/viewer.md index f9ae6eb..25c2fed 100644 --- a/pages/src/content/docs/en/viewer.md +++ b/pages/src/content/docs/en/viewer.md @@ -166,7 +166,7 @@ The OpenTelemetry exporter is a separate concern — see [Telemetry](../telemetry/) for how to keep prompt content out of exported traces. -## When the viewer is *not* the right tool +## When the viewer is not the right tool - For programmatic post-processing (CI, dashboards), use `ocr review --format json --audience agent`. The viewer renders for diff --git a/pages/src/content/docs/index.ts b/pages/src/content/docs/index.ts index 2ea81bd..c1730af 100644 --- a/pages/src/content/docs/index.ts +++ b/pages/src/content/docs/index.ts @@ -1,7 +1,6 @@ /* Docs content index — imports all markdown files and provides a lookup by slug + language */ // English docs -import enOverview from './en/overview.md'; import enQuickstart from './en/quickstart.md'; import enInstallation from './en/installation.md'; import enConfiguration from './en/configuration.md'; @@ -20,7 +19,6 @@ import enContributing from './en/contributing.md'; import enFaq from './en/faq.md'; // Chinese docs -import zhOverview from './zh/overview.md'; import zhQuickstart from './zh/quickstart.md'; import zhInstallation from './zh/installation.md'; import zhConfiguration from './zh/configuration.md'; @@ -39,7 +37,6 @@ import zhContributing from './zh/contributing.md'; import zhFaq from './zh/faq.md'; // Japanese docs -import jaOverview from './ja/overview.md'; import jaQuickstart from './ja/quickstart.md'; import jaInstallation from './ja/installation.md'; import jaConfiguration from './ja/configuration.md'; @@ -58,7 +55,6 @@ import jaContributing from './ja/contributing.md'; import jaFaq from './ja/faq.md'; export type DocSlug = - | 'overview' | 'quickstart' | 'installation' | 'configuration' @@ -77,7 +73,6 @@ export type DocSlug = | 'faq'; const enDocs: Record = { - 'overview': enOverview, 'quickstart': enQuickstart, 'installation': enInstallation, 'configuration': enConfiguration, @@ -97,7 +92,6 @@ const enDocs: Record = { }; const zhDocs: Record = { - 'overview': zhOverview, 'quickstart': zhQuickstart, 'installation': zhInstallation, 'configuration': zhConfiguration, @@ -117,7 +111,6 @@ const zhDocs: Record = { }; const jaDocs: Record = { - 'overview': jaOverview, 'quickstart': jaQuickstart, 'installation': jaInstallation, 'configuration': jaConfiguration, diff --git a/pages/src/content/docs/ja/overview.md b/pages/src/content/docs/ja/overview.md deleted file mode 100644 index fcb602e..0000000 --- a/pages/src/content/docs/ja/overview.md +++ /dev/null @@ -1,75 +0,0 @@ ---- -title: 概要 -sidebar: - order: 2 ---- - -## Open Code Review とは? - -Open Code Review(略称 **OCR**。光学式文字認識 Optical Character -Recognition とは異なります)は、AI 駆動のコードレビュー CLI であり、 -[`@alibaba-group/open-code-review`](https://www.npmjs.com/package/@alibaba-group/open-code-review) -NPM パッケージおよびスタンドアロンの Go バイナリとして配布されています。CLI バイナリ名は `ocr` です。 - -たった 1 つのコマンド(`ocr review`)で、以下を実行します。 - -1. Git diff を解析します——ワークスペース、ブランチ区間、または単一の commit。 -2. システムのデフォルトルールとユーザールールを組み合わせて変更ファイルをフィルタリングします。 -3. 変更ファイルごとに **per-file サブ agent** を並行で起動します。 -4. 各サブ agent は LLM のツール呼び出しループを実行します。大きな diff に対しては、オプションでまず - **plan フェーズ** を実行できます。 -5. モデルは `code_comment` を呼び出して発見事項を記録し、オプションで `file_read`、 - `code_search`、`file_find`、`file_read_diff` を呼び出してコンテキストを収集し、完了後に - `task_done` を呼び出します。 -6. OCR は各コメントを正確な行番号に解決し、正確にマッチできなかったコメントに対しては - オプションの再配置フローを実行し、最終的なリストを出力(または JSON で出力)します。 - -## 汎用 agent の問題 - -汎用のコーディング agent(Claude Code の Skill、Cursor、Cline など)でコード -レビューを行ったことがあるなら、次のような経験をしたことがあるでしょう。 - -- **カバレッジ不足**——大きな変更セットでは、agent がひそかに手を抜き、一部のファイルしかレビューしません。 -- **位置ずれ**——コメントが指し示すコードと一致しません。行番号やファイルパスが対象からずれます。 -- **品質が不安定**——自然言語ベースの Skill はデバッグが難しく、prompt のわずかな変更で - 出力品質が変動します。 - -根本的な原因は、純粋な言語駆動のアーキテクチャにはレビュープロセスに対する **ハードな制約** が欠けていることです。 - -## コア設計:決定論的エンジニアリング × agent - -OCR のコアとなる考え方は、**決定論的エンジニアリング** と **agent** を組み合わせ、それぞれが最も得意とすることを担わせることです。 - -### 決定論的エンジニアリング——ハードな制約 - -*絶対に間違えてはならない* ステップについては、モデルではなくエンジニアリングロジックによって正しさを保証します。 - -- **正確なファイル選択**——[5 段階のフィルタゲート](../review-rules/#how-files-are-filtered) - がどのファイルをレビューするかを決定し、明示的な `include`/`exclude` 制御を提供します。 -- **スマートなファイルのパッケージング**——関連するファイル(例:`message_en.properties` と - `message_zh.properties`)を 1 つのレビュー単位にまとめられます。各パッケージは独立したコンテキストとして - サブ agent に渡されて実行されます——分割統治により、超大規模な変更セットでも安定し、並行レビューを自然にサポートします。 -- **きめ細かなルールマッチング**——レビュールールはファイルパスでマッチし、最初にマッチしたものが有効になります。これによりモデルの注意を - 高度に集中させ、ノイズを排除します。テンプレートベースのマッチングは、純粋な言語駆動のルール誘導よりも安定しています。 -- **外部の配置・リフレクションモジュール**——独立したコメント配置 - ([`internal/diff/relocation.go`](https://github.com/alibaba/open-code-review/blob/main/internal/diff/relocation.go)) - と再配置フローにより、位置と内容の正確性を体系的に向上させます。 - -### Agent——動的な意思決定 - -agent の強みは、最も重要な部分に集約されます。 - -- **シーン特化でチューニングされた prompt**——コードレビューのシーンに向けて深くチューニングされた prompt - テンプレートにより、token 消費を抑えつつ効果を高めます( - [`internal/config/template/task_template.json`](https://github.com/alibaba/open-code-review/blob/main/internal/config/template/task_template.json) - を参照)。 -- **シーン特化でチューニングされたツールセット**——大規模な本番データのツール呼び出し trace 分析から抽出されました - (呼び出し頻度の分布、単一ツールの繰り返し率、各ツールが呼び出しチェーン全体に与える影響)。最終的に得られた - 専用の [6 ツール](../tools/) セットは、汎用 agent のツールキットよりも安定していて予測可能です。 - -## 関連項目 - -- [クイックスタート](../quickstart/)——インストールして初回のレビューを完了します。 -- [アーキテクチャ](../architecture/)——agent ループ、plan フェーズ、メモリ圧縮。 -- [CLI リファレンス](../cli-reference/)——各引数とサブコマンド。 -- [インテグレーション](../integrations/)——Claude Code や任意の agent から OCR を呼び出します。 diff --git a/pages/src/content/docs/ja/viewer.md b/pages/src/content/docs/ja/viewer.md index 4eea1f0..f598a44 100644 --- a/pages/src/content/docs/ja/viewer.md +++ b/pages/src/content/docs/ja/viewer.md @@ -139,7 +139,7 @@ JSONL トランスクリプトには、LLM に送信され LLM から受信し OpenTelemetry exporter は別の話です——prompt の内容をエクスポートされる trace に含めない方法については [テレメトリ](../telemetry/)を参照してください。 -## ビューアが*適さない*場合 +## ビューアが適さない場合 - プログラムによる後処理(CI、ダッシュボード)には `ocr review --format json --audience agent` を使用します。 ビューアは人間向けのレンダリングであり、機械向けではありません。 diff --git a/pages/src/content/docs/zh/overview.md b/pages/src/content/docs/zh/overview.md deleted file mode 100644 index f9f6df4..0000000 --- a/pages/src/content/docs/zh/overview.md +++ /dev/null @@ -1,74 +0,0 @@ ---- -title: 概览 -sidebar: - order: 2 ---- - -## 什么是 Open Code Review? - -Open Code Review(简称 **OCR**,区别于光学字符识别 Optical Character -Recognition)是一个 AI 驱动的代码评审 CLI,以 -[`@alibaba-group/open-code-review`](https://www.npmjs.com/package/@alibaba-group/open-code-review) -NPM 包和独立的 Go 二进制形式发布。CLI 二进制名为 `ocr`。 - -只需一条命令(`ocr review`),它会: - -1. 解析 Git diff——工作区、分支区间或单个 commit。 -2. 结合系统默认规则与用户规则对变更文件进行过滤。 -3. 为每个变更文件并行启动一个 **per-file 子 agent**。 -4. 每个子 agent 运行一个 LLM 工具调用循环;对于较大的 diff,可选地先执行 - **plan 阶段**。 -5. 模型调用 `code_comment` 记录发现,可选地调用 `file_read`、 - `code_search`、`file_find`、`file_read_diff` 收集上下文,完成后调用 - `task_done`。 -6. OCR 将每条评论解析到精确的行号,对未能精确匹配的评论运行可选的重新定位 - 流程,并打印(或以 JSON 输出)最终列表。 - -## 通用 agent 的问题 - -如果你用过通用编码 agent(Claude Code 的 Skill、Cursor、Cline 等)做代码 -评审,很可能遇到过: - -- **覆盖不全**——在较大的变更集上,agent 会悄悄偷工减料,只评审部分文件。 -- **位置漂移**——评论与它所指的代码对不上;行号和文件路径偏离目标。 -- **质量不稳定**——自然语言 Skill 难以调试,输出质量随 prompt 的微小改动 - 而波动。 - -根本原因:纯语言驱动的架构缺乏对评审流程的 **硬约束**。 - -## 核心设计:确定性工程 × agent - -OCR 的核心理念是把 **确定性工程** 与 **agent** 结合——各自做自己最擅长的事。 - -### 确定性工程——硬约束 - -对于那些 *绝不能出错* 的步骤,由工程逻辑(而非模型)保证正确性: - -- **精确的文件选择**——一个[五重门过滤](../review-rules/#how-files-are-filtered) - 决定到底评审哪些文件,并提供显式的 `include`/`exclude` 控制。 -- **智能文件打包**——相关文件(如 `message_en.properties` 与 - `message_zh.properties`)可以合并为一个评审单元。每个包作为独立上下文交给 - 子 agent 运行——分而治之,在超大变更集上依然稳定,并天然支持并发评审。 -- **细粒度规则匹配**——评审规则按文件路径匹配,首条匹配生效,让模型的注意力 - 高度聚焦并消除噪声。基于模板的匹配比纯语言驱动的规则引导更稳定。 -- **外部定位与反思模块**——独立的评论定位 - ([`internal/diff/relocation.go`](https://github.com/alibaba/open-code-review/blob/main/internal/diff/relocation.go)) - 与重新定位流程,系统地提升位置与内容的准确性。 - -### Agent——动态决策 - -agent 的优势集中在最关键的地方: - -- **场景化调优的 prompt**——针对代码评审场景深度调优的 prompt 模板,在降低 token - 消耗的同时提升效果(见 - [`internal/config/template/task_template.json`](https://github.com/alibaba/open-code-review/blob/main/internal/config/template/task_template.json))。 -- **场景化调优的工具集**——从大规模生产数据的工具调用 trace 分析中提炼而来 - (调用频次分布、单工具重复率、每个工具对整体调用链的影响)。最终得到一套 - 专用 [六工具](../tools/) 集,比通用 agent 工具包更稳定、更可预测。 - -## 另见 - -- [快速开始](../quickstart/)——安装并完成首次评审。 -- [架构](../architecture/)——agent 循环、plan 阶段与记忆压缩。 -- [CLI 参考](../cli-reference/)——每个参数与子命令。 -- [集成](../integrations/)——从 Claude Code 或任意 agent 调用 OCR。 diff --git a/pages/src/content/docs/zh/viewer.md b/pages/src/content/docs/zh/viewer.md index 1d76452..235502b 100644 --- a/pages/src/content/docs/zh/viewer.md +++ b/pages/src/content/docs/zh/viewer.md @@ -139,7 +139,7 @@ JSONL 转录包含发给 LLM 和从 LLM 收到的**一切**,包括 diff 中的 OpenTelemetry exporter 是另一回事——如何让 prompt 内容不进入导出 trace 见 [遥测](../telemetry/)。 -## 查看器*不*适用时 +## 查看器不适用时 - 程序化后处理(CI、仪表盘)用 `ocr review --format json --audience agent`。 查看器为人渲染,不为机器。 diff --git a/pages/src/i18n/en.ts b/pages/src/i18n/en.ts index 044cd5b..53b387d 100644 --- a/pages/src/i18n/en.ts +++ b/pages/src/i18n/en.ts @@ -100,7 +100,6 @@ export const en: TranslationKeys = { // Docs Page (keep existing) 'docs.toc': 'Table of Contents', - 'docs.overview': 'Overview', 'docs.install': 'Install', 'docs.config': 'ocr config', 'docs.review': 'ocr review', @@ -108,15 +107,6 @@ export const en: TranslationKeys = { 'docs.viewer': 'ocr viewer', 'docs.mcp': 'MCP Server', 'docs.env': 'Claude Code Integration', - 'docs.overviewTitle': 'Overview', - 'docs.overviewDesc': '(short ocr) is an AI-powered code review CLI tool.', - 'docs.overviewFeatures': 'Core Features:', - 'docs.overviewFeat1': 'Supports workspace, branch diff, single-commit, and full scan review modes', - 'docs.overviewFeat2': 'Built-in Anthropic, OpenAI, DashScope, DeepSeek, Z.AI and custom provider support', - 'docs.overviewFeat3': 'Concurrent review with customizable timeouts', - 'docs.overviewFeat4': 'Output format supports text and json', - 'docs.overviewFeat5': 'Zero config for Claude Code users', - 'docs.overviewFeat6': 'WebUI viewer for visualizing review results', 'docs.installTitle': 'Install', 'docs.installLabel': 'Install', 'docs.installVerifyLabel': 'Verify Installation', @@ -269,7 +259,6 @@ export const en: TranslationKeys = { // Docs Sidebar 'docs.sidebar.gettingStarted': 'Getting Started', 'docs.sidebar.userGuide': 'User Guide', - 'docs.sidebar.overview': 'Overview', 'docs.sidebar.quickstart': 'QuickStart', 'docs.sidebar.installation': 'Installation', 'docs.sidebar.configuration': 'Configuration', diff --git a/pages/src/i18n/ja.ts b/pages/src/i18n/ja.ts index 90e98a3..0164c26 100644 --- a/pages/src/i18n/ja.ts +++ b/pages/src/i18n/ja.ts @@ -102,7 +102,6 @@ export const ja: TranslationKeys = { // Docs Page 'docs.toc': '目次', - 'docs.overview': '概要', 'docs.install': 'インストール', 'docs.config': 'ocr config', 'docs.review': 'ocr review', @@ -110,15 +109,6 @@ export const ja: TranslationKeys = { 'docs.viewer': 'ocr viewer', 'docs.mcp': 'MCP Server', 'docs.env': 'Claude Code 統合', - 'docs.overviewTitle': '概要', - 'docs.overviewDesc': '(略称 ocr)は AI 駆動のコードレビュー CLI ツールです。', - 'docs.overviewFeatures': 'コア機能:', - 'docs.overviewFeat1': 'ワークスペース変更、ブランチ差分、単一コミット、フルスキャンレビューモードをサポート', - 'docs.overviewFeat2': 'Anthropic、OpenAI、DashScope、DeepSeek、Z.AI などの内蔵プロバイダーとカスタムプロバイダーをサポート', - 'docs.overviewFeat3': 'カスタマイズ可能なタイムアウト付き並行レビュー', - 'docs.overviewFeat4': '出力形式は text と json をサポート', - 'docs.overviewFeat5': 'Claude Code ユーザーはゼロ設定', - 'docs.overviewFeat6': 'WebUI ビューアーでレビュー結果を可視化', 'docs.installTitle': 'インストール', 'docs.installLabel': 'インストール', 'docs.installVerifyLabel': 'インストール確認', @@ -271,7 +261,6 @@ export const ja: TranslationKeys = { // Docs Sidebar 'docs.sidebar.gettingStarted': '入門ガイド', 'docs.sidebar.userGuide': '利用ガイド', - 'docs.sidebar.overview': '概要', 'docs.sidebar.quickstart': 'クイックスタート', 'docs.sidebar.installation': 'インストール', 'docs.sidebar.configuration': '設定', diff --git a/pages/src/i18n/zh.ts b/pages/src/i18n/zh.ts index aeb24f1..8b1aa1d 100644 --- a/pages/src/i18n/zh.ts +++ b/pages/src/i18n/zh.ts @@ -102,7 +102,6 @@ export const zh: TranslationKeys = { // Docs Page 'docs.toc': '目录', - 'docs.overview': '概览', 'docs.install': '安装', 'docs.config': 'ocr config', 'docs.review': 'ocr review', @@ -110,15 +109,6 @@ export const zh: TranslationKeys = { 'docs.viewer': 'ocr viewer', 'docs.mcp': 'MCP Server', 'docs.env': 'Claude Code 集成', - 'docs.overviewTitle': '概览', - 'docs.overviewDesc': '(简称 ocr)是一款 AI 驱动的代码审查 CLI 工具。', - 'docs.overviewFeatures': '核心功能:', - 'docs.overviewFeat1': '支持工作区变更、分支差异、单次提交和全量扫描审查模式', - 'docs.overviewFeat2': '内置 Anthropic、OpenAI、DashScope、DeepSeek、Z.AI 等主流供应商及自定义供应商支持', - 'docs.overviewFeat3': '并发审查与可自定义超时', - 'docs.overviewFeat4': '输出格式支持 text 和 json', - 'docs.overviewFeat5': 'Claude Code 用户零配置', - 'docs.overviewFeat6': 'WebUI 查看器可视化审查结果', 'docs.installTitle': '安装', 'docs.installLabel': '安装', 'docs.installVerifyLabel': '验证安装', @@ -271,7 +261,6 @@ export const zh: TranslationKeys = { // Docs Sidebar 'docs.sidebar.gettingStarted': '入门指南', 'docs.sidebar.userGuide': '使用指南', - 'docs.sidebar.overview': '概览', 'docs.sidebar.quickstart': '快速开始', 'docs.sidebar.installation': '安装', 'docs.sidebar.configuration': '配置', diff --git a/pages/src/pages/DocsPage.tsx b/pages/src/pages/DocsPage.tsx index 005c8d5..a4e395b 100644 --- a/pages/src/pages/DocsPage.tsx +++ b/pages/src/pages/DocsPage.tsx @@ -27,7 +27,6 @@ const sidebarTree: SidebarGroup[] = [ { groupLabelKey: 'docs.sidebar.gettingStarted', items: [ - { id: 'sb-overview', labelKey: 'docs.sidebar.overview', slug: 'overview' }, { id: 'sb-quickstart', labelKey: 'docs.sidebar.quickstart', slug: 'quickstart' }, { id: 'sb-installation', labelKey: 'docs.sidebar.installation', slug: 'installation' }, { id: 'sb-configuration', labelKey: 'docs.sidebar.configuration', slug: 'configuration' }, @@ -111,7 +110,7 @@ function buildFlatDocList(): { slug: DocSlug; labelKey: string }[] { const flatDocList = buildFlatDocList(); const DocsPage: React.FC = () => { - const [activeSlug, setActiveSlug] = useState('overview'); + const [activeSlug, setActiveSlug] = useState('quickstart'); const [expandedItems, setExpandedItems] = useState>({ 'sb-integrations': false }); const [activeHeadingId, setActiveHeadingId] = useState(''); const [hoveredHeadingId, setHoveredHeadingId] = useState(''); From 4596e2ce2e4833dbe793ea06723d21123bc66872 Mon Sep 17 00:00:00 2001 From: kite Date: Fri, 3 Jul 2026 19:03:27 +0800 Subject: [PATCH 59/87] docs(pages): add MCP servers guide to the user guide (#292) Add an MCP tutorial page (en/zh/ja) covering how OCR acts as an MCP client that pulls tools from external MCP servers into a review: configuration via mcp_servers, the config fields, CLI usage, tool filtering, name conflicts, the setup command, and troubleshooting. Wire the new page into the docs system (index.ts, DocsPage sidebar, i18n en/zh/ja) and cross-link from the integrations page to clarify the client vs server distinction. --- pages/src/content/docs/en/integrations.md | 4 + pages/src/content/docs/en/mcp.md | 156 ++++++++++++++++++++++ pages/src/content/docs/index.ts | 7 + pages/src/content/docs/ja/integrations.md | 3 + pages/src/content/docs/ja/mcp.md | 150 +++++++++++++++++++++ pages/src/content/docs/zh/integrations.md | 3 + pages/src/content/docs/zh/mcp.md | 140 +++++++++++++++++++ pages/src/i18n/en.ts | 1 + pages/src/i18n/ja.ts | 1 + pages/src/i18n/zh.ts | 1 + pages/src/pages/DocsPage.tsx | 1 + 11 files changed, 467 insertions(+) create mode 100644 pages/src/content/docs/en/mcp.md create mode 100644 pages/src/content/docs/ja/mcp.md create mode 100644 pages/src/content/docs/zh/mcp.md diff --git a/pages/src/content/docs/en/integrations.md b/pages/src/content/docs/en/integrations.md index 603dc62..61d7659 100644 --- a/pages/src/content/docs/en/integrations.md +++ b/pages/src/content/docs/en/integrations.md @@ -44,6 +44,10 @@ agent platform requires MCP specifically, wrap the CLI with a thin shim — a 30-line Node script that exposes a single `review` tool is enough. +The reverse direction *is* supported: OCR can act as an MCP **client** and +pull tools from external MCP servers into a review. See +[MCP Servers](../mcp/). + ## Tips that apply to every pattern - **Always pass `--audience agent`** when the caller is non-human. diff --git a/pages/src/content/docs/en/mcp.md b/pages/src/content/docs/en/mcp.md new file mode 100644 index 0000000..c18fa60 --- /dev/null +++ b/pages/src/content/docs/en/mcp.md @@ -0,0 +1,156 @@ +--- +title: MCP Servers +sidebar: + order: 10 +--- + +OCR can act as a **Model Context Protocol (MCP) client**. You point it at +one or more external MCP servers, and the tools those servers expose +become available to the review agent — right alongside the +[built-in tools](../tools/) like `file_read` and `code_search`. + +This is the *client* side of MCP. OCR does **not** run as an MCP server +that other agents call — see the note in +[Integrations](../integrations/#what-about-mcp) for that direction. This +page is about the opposite: giving OCR's own reviewer extra capabilities. + +## When to use it + +Reach for an MCP server when the reviewer would benefit from context that +lives outside the diff: + +- **Issue / ticket lookup** — let the agent fetch the linked Jira / GitHub + issue to check whether the change matches the stated requirement. +- **Docs / knowledge base** — pull internal API docs or coding standards + so comments cite the real house rules. +- **Custom analysis** — expose a linter, a schema validator, or a + dependency checker as a tool the reviewer can invoke on demand. + +If all you need is a plain read of the repo, the built-in tools already +cover it — MCP is for reaching *beyond* the checkout. + +## How it works + +- OCR connects to each configured server over the **stdio transport**: it + launches the server as a subprocess and speaks MCP over its + stdin/stdout. +- The subprocess runs with its **working directory set to the repository + root**, and inherits OCR's environment plus any `env` you configure. +- On startup OCR lists the server's tools and registers them into the same + tool registry the built-in tools use. Registered MCP tools are available + in **both the plan phase and the main task**. +- Servers stay alive for the duration of the review and are shut down when + it finishes. + +If a server fails to start (or its `setup` command fails), OCR prints a +warning and **continues the review without it** — a broken MCP server +never blocks a review. + +## Configuration + +MCP servers live under the `mcp_servers` key in your user config file +(`~/.opencodereview/config.json`). Each entry is keyed by a name you +choose and accepts these fields: + +| Field | Type | Required | Description | +|---|---|---|---| +| `command` | string | ✓ | Executable that starts the MCP server (e.g. `npx`, `uvx`, an absolute path). | +| `args` | string array | | Arguments passed to `command`. | +| `env` | string array | | Extra environment variables in `KEY=VALUE` form. | +| `tools` | string array | | Allowlist of tool names to register. Empty = register every tool the server offers. | +| `setup` | string | | Shell command run once before the server starts (e.g. install deps). Runs in the repo root with a 5-minute timeout. | + +### With the CLI + +The `ocr config set` command writes these fields non-interactively. Array +fields (`args`, `env`, `tools`) take a JSON array string: + +```bash +# Minimal: just a command +ocr config set mcp_servers.docs.command npx + +# Arguments +ocr config set mcp_servers.docs.args '["-y", "@acme/docs-mcp-server"]' + +# Environment variables (KEY=VALUE entries) +ocr config set mcp_servers.docs.env '["DOCS_TOKEN=secret", "DOCS_REGION=eu"]' + +# Restrict which tools are exposed to the reviewer +ocr config set mcp_servers.docs.tools '["search_docs", "get_page"]' + +# A setup command to run before the server starts +ocr config set mcp_servers.docs.setup "npm install -g @acme/docs-mcp-server" +``` + +Remove a server with `unset`: + +```bash +ocr config unset mcp_servers.docs +``` + +### By hand + +The same configuration as JSON: + +```json +{ + "mcp_servers": { + "docs": { + "command": "npx", + "args": ["-y", "@acme/docs-mcp-server"], + "env": ["DOCS_TOKEN=secret", "DOCS_REGION=eu"], + "tools": ["search_docs", "get_page"], + "setup": "npm install -g @acme/docs-mcp-server" + } + } +} +``` + +## Filtering tools + +By default every tool a server advertises is registered. Set `tools` to an +allowlist when a server exposes more than the reviewer needs — fewer, +sharper tools keep the agent focused and cut token cost. Names in the list +that the server doesn't actually offer are skipped with a warning, so a +typo surfaces on stderr rather than silently doing nothing. + +## Name conflicts + +MCP tool names share one namespace with the built-in tools. If a server +advertises a tool whose name collides with a **built-in/reserved** tool +(`file_read`, `code_search`, `task_done`, …) or with a tool already +registered by another MCP server, OCR **skips** it and logs a warning. +First registration wins; give servers distinct tool names to avoid losing +tools this way. + +## The `setup` command + +`setup` runs once, before the server subprocess starts, from the +repository root. Use it to install or build the server on demand: + +```json +"setup": "npm install -g @acme/docs-mcp-server" +``` + +It has a **5-minute timeout**. If it exits non-zero, OCR logs the command, +working directory, and output, then skips that server and proceeds with +the review. + +## Troubleshooting + +All MCP diagnostics go to **stderr**, prefixed with `[ocr]`, so they never +pollute `--format json` output on stdout: + +- `Running setup for MCP server "x": …` — the setup command is executing. +- `failed to start MCP server "x": …` — the subprocess didn't connect + within the 30-second init timeout, or `command` isn't on `PATH`. +- `tool "y" conflicts with built-in tool, skipping` — rename the server's + tool or drop it from `tools`. +- `allowed tool "y" not found in server's tool list` — the name in `tools` + doesn't match anything the server offers; check spelling. + +## See also + +- [Tools](../tools/) — the six built-in tools MCP tools sit beside. +- [Configuration](../configuration/) — the full config file and every key. +- [CLI Reference](../cli-reference/) — `ocr config` and the review flags. diff --git a/pages/src/content/docs/index.ts b/pages/src/content/docs/index.ts index c1730af..c422604 100644 --- a/pages/src/content/docs/index.ts +++ b/pages/src/content/docs/index.ts @@ -8,6 +8,7 @@ import enCliReference from './en/cli-reference.md'; import enReviewRules from './en/review-rules.md'; import enArchitecture from './en/architecture.md'; import enTools from './en/tools.md'; +import enMcp from './en/mcp.md'; import enViewer from './en/viewer.md'; import enTelemetry from './en/telemetry.md'; import enIntegrations from './en/integrations.md'; @@ -26,6 +27,7 @@ import zhCliReference from './zh/cli-reference.md'; import zhReviewRules from './zh/review-rules.md'; import zhArchitecture from './zh/architecture.md'; import zhTools from './zh/tools.md'; +import zhMcp from './zh/mcp.md'; import zhViewer from './zh/viewer.md'; import zhTelemetry from './zh/telemetry.md'; import zhIntegrations from './zh/integrations.md'; @@ -44,6 +46,7 @@ import jaCliReference from './ja/cli-reference.md'; import jaReviewRules from './ja/review-rules.md'; import jaArchitecture from './ja/architecture.md'; import jaTools from './ja/tools.md'; +import jaMcp from './ja/mcp.md'; import jaViewer from './ja/viewer.md'; import jaTelemetry from './ja/telemetry.md'; import jaIntegrations from './ja/integrations.md'; @@ -62,6 +65,7 @@ export type DocSlug = | 'review-rules' | 'architecture' | 'tools' + | 'mcp' | 'viewer' | 'telemetry' | 'integrations' @@ -80,6 +84,7 @@ const enDocs: Record = { 'review-rules': enReviewRules, 'architecture': enArchitecture, 'tools': enTools, + 'mcp': enMcp, 'viewer': enViewer, 'telemetry': enTelemetry, 'integrations': enIntegrations, @@ -99,6 +104,7 @@ const zhDocs: Record = { 'review-rules': zhReviewRules, 'architecture': zhArchitecture, 'tools': zhTools, + 'mcp': zhMcp, 'viewer': zhViewer, 'telemetry': zhTelemetry, 'integrations': zhIntegrations, @@ -118,6 +124,7 @@ const jaDocs: Record = { 'review-rules': jaReviewRules, 'architecture': jaArchitecture, 'tools': jaTools, + 'mcp': jaMcp, 'viewer': jaViewer, 'telemetry': jaTelemetry, 'integrations': jaIntegrations, diff --git a/pages/src/content/docs/ja/integrations.md b/pages/src/content/docs/ja/integrations.md index cbd6540..894e1e9 100644 --- a/pages/src/content/docs/ja/integrations.md +++ b/pages/src/content/docs/ja/integrations.md @@ -40,6 +40,9 @@ OCR は現在、Model Context Protocol server を公開していません。想 必要とする場合は、CLI を薄い shim でラップしてください。単一の `review` ツールを公開する 30 行程度の Node スクリプトで十分です。 +逆方向は**サポートされています**:OCR は MCP **クライアント**として動作し、外部の +MCP server のツールをレビューに取り込めます。[MCP サーバー](../mcp/)を参照してください。 + ## すべてのモードに共通するヒント - **呼び出し側が人間でない場合は、常に `--audience agent` を渡してください。** そうしないと、 diff --git a/pages/src/content/docs/ja/mcp.md b/pages/src/content/docs/ja/mcp.md new file mode 100644 index 0000000..42b7a10 --- /dev/null +++ b/pages/src/content/docs/ja/mcp.md @@ -0,0 +1,150 @@ +--- +title: MCP サーバー +sidebar: + order: 10 +--- + +OCR は **Model Context Protocol(MCP)クライアント**として動作できます。1 つ以上の +外部 MCP server を指定すると、それらの server が公開するツールがレビュー +エージェントから利用できるようになり、`file_read` や `code_search` などの +[組み込みツール](../tools/)と並んで使えます。 + +これは MCP の**クライアント**側です。OCR は他のエージェントが呼び出す MCP server +としては動作**しません**——その方向については[統合](../integrations/#mcp)の +説明を参照してください。本ページはその逆、OCR 自身のレビュアーに能力を追加する話です。 + +## いつ使うか + +レビュアーが diff の外にあるコンテキストを必要とするときに MCP server を導入します: + +- **Issue / チケット参照**——リンクされた Jira / GitHub issue を取得させ、変更が + 述べられた要件に合致するか確認する。 +- **ドキュメント / ナレッジベース**——社内 API ドキュメントやコーディング規約を + 取り込み、コメントが実際のチームルールを引用できるようにする。 +- **カスタム解析**——linter、スキーマ検証器、依存関係チェッカーを、レビュアーが + 必要に応じて呼び出せるツールとして公開する。 + +リポジトリを読むだけでよいなら組み込みツールで十分です——MCP は checkout の**外**に +到達するためのものです。 + +## 仕組み + +- OCR は設定された各 server に **stdio トランスポート**で接続します:server を + サブプロセスとして起動し、その stdin/stdout 経由で MCP を話します。 +- サブプロセスは**作業ディレクトリをリポジトリのルート**に設定して実行され、OCR の + 環境変数に加えて設定した `env` を継承します。 +- 起動時に OCR は server のツールを列挙し、組み込みツールが使うのと同じツール + レジストリに登録します。登録された MCP ツールは **plan フェーズと main task の + 両方**で利用できます。 +- server はレビューの間ずっと稼働し続け、終了時にシャットダウンされます。 + +server の起動に失敗した場合(または `setup` コマンドが失敗した場合)、OCR は警告を +表示し、**それを使わずにレビューを続行**します——壊れた MCP server がレビューを +ブロックすることはありません。 + +## 設定 + +MCP server はユーザー設定ファイル(`~/.opencodereview/config.json`)の +`mcp_servers` キーの下に置きます。各エントリは任意の名前を key とし、次の +フィールドを受け付けます: + +| フィールド | 型 | 必須 | 説明 | +|---|---|---|---| +| `command` | string | ✓ | MCP server を起動する実行ファイル(`npx`、`uvx`、絶対パスなど)。 | +| `args` | string 配列 | | `command` に渡す引数。 | +| `env` | string 配列 | | 追加の環境変数、`KEY=VALUE` 形式。 | +| `tools` | string 配列 | | 登録するツール名の許可リスト。空 = server が提供する全ツールを登録。 | +| `setup` | string | | server 起動前に一度実行される shell コマンド(依存関係のインストールなど)。リポジトリのルートで実行、タイムアウト 5 分。 | + +### CLI を使う + +`ocr config set` コマンドはこれらのフィールドを非対話的に書き込みます。配列 +フィールド(`args`、`env`、`tools`)は JSON 配列文字列を受け取ります: + +```bash +# 最小構成:コマンドだけ +ocr config set mcp_servers.docs.command npx + +# 引数 +ocr config set mcp_servers.docs.args '["-y", "@acme/docs-mcp-server"]' + +# 環境変数(KEY=VALUE エントリ) +ocr config set mcp_servers.docs.env '["DOCS_TOKEN=secret", "DOCS_REGION=eu"]' + +# レビュアーに公開するツールを制限 +ocr config set mcp_servers.docs.tools '["search_docs", "get_page"]' + +# server 起動前に実行する setup コマンド +ocr config set mcp_servers.docs.setup "npm install -g @acme/docs-mcp-server" +``` + +`unset` で server を削除します: + +```bash +ocr config unset mcp_servers.docs +``` + +### 手動で編集 + +同じ設定を JSON で書くと: + +```json +{ + "mcp_servers": { + "docs": { + "command": "npx", + "args": ["-y", "@acme/docs-mcp-server"], + "env": ["DOCS_TOKEN=secret", "DOCS_REGION=eu"], + "tools": ["search_docs", "get_page"], + "setup": "npm install -g @acme/docs-mcp-server" + } + } +} +``` + +## ツールのフィルタリング + +デフォルトでは server が広告するすべてのツールが登録されます。server が +レビュアーに必要以上のツールを公開する場合は `tools` に許可リストを設定します—— +ツールが少なく的確なほどエージェントは集中でき、トークンコストも下がります。 +リストに含まれていて server が実際には提供しない名前は警告付きでスキップされる +ため、タイプミスは黙って無視されるのではなく stderr に表示されます。 + +## 名前の衝突 + +MCP ツール名は組み込みツールと 1 つの名前空間を共有します。server が広告する +ツール名が**組み込み / 予約**ツール(`file_read`、`code_search`、`task_done` など)や、 +別の MCP server が既に登録したツールと衝突する場合、OCR はそれを**スキップ**して +警告を記録します。先に登録されたものが優先されます。こうしてツールを失わない +よう、各 server には重複しないツール名を付けてください。 + +## `setup` コマンド + +`setup` は server サブプロセスの起動前に、リポジトリのルートから一度実行されます。 +server をオンデマンドでインストールまたはビルドするのに使います: + +```json +"setup": "npm install -g @acme/docs-mcp-server" +``` + +**5 分のタイムアウト**があります。非ゼロで終了した場合、OCR はコマンド、作業 +ディレクトリ、出力を記録し、その server をスキップしてレビューを続行します。 + +## トラブルシューティング + +すべての MCP 診断情報は **stderr** に、`[ocr]` プレフィックス付きで出力されるため、 +stdout の `--format json` 出力を汚染することはありません: + +- `Running setup for MCP server "x": …`——setup コマンドを実行中。 +- `failed to start MCP server "x": …`——サブプロセスが 30 秒の初期化タイムアウト内に + 接続できなかったか、`command` が `PATH` にない。 +- `tool "y" conflicts with built-in tool, skipping`——server のツールを改名するか、 + `tools` から外す。 +- `allowed tool "y" not found in server's tool list`——`tools` の名前が server の提供 + する何にも一致しない。スペルを確認。 + +## 関連項目 + +- [ツール](../tools/)——MCP ツールが並ぶ 6 つの組み込みツール。 +- [設定](../configuration/)——設定ファイル全体とすべてのキー。 +- [CLI リファレンス](../cli-reference/)——`ocr config` と review のフラグ。 diff --git a/pages/src/content/docs/zh/integrations.md b/pages/src/content/docs/zh/integrations.md index 2a5bd08..87f8bd2 100644 --- a/pages/src/content/docs/zh/integrations.md +++ b/pages/src/content/docs/zh/integrations.md @@ -38,6 +38,9 @@ OCR 目前不暴露 Model Context Protocol server。预期的集成方式是“a 要求 MCP,用一个薄 shim 包裹 CLI——一个 30 行的 Node 脚本暴露单个 `review` 工具就够了。 +反过来的方向**是**支持的:OCR 可以作为 MCP **客户端**,把外部 MCP server 的工具 +拉进一次审查。见 [MCP 服务器](../mcp/)。 + ## 适用于所有模式的提示 - **始终传 `--audience agent`**,当调用方不是人时。否则进度行会污染待解析的输出。 diff --git a/pages/src/content/docs/zh/mcp.md b/pages/src/content/docs/zh/mcp.md new file mode 100644 index 0000000..962344e --- /dev/null +++ b/pages/src/content/docs/zh/mcp.md @@ -0,0 +1,140 @@ +--- +title: MCP 服务器 +sidebar: + order: 10 +--- + +OCR 可以作为 **Model Context Protocol(MCP)客户端**。你把它指向一个或多个外部 +MCP server,这些 server 暴露的工具就会提供给审查 agent——与 `file_read`、 +`code_search` 等[内置工具](../tools/)并列。 + +这是 MCP 的**客户端**侧。OCR **不会**作为供其他 agent 调用的 MCP server 运行—— +那个方向见[集成](../integrations/#mcp-怎么办)中的说明。本页讲的是相反的事:为 +OCR 自己的审查器扩展能力。 + +## 何时使用 + +当审查器需要 diff 之外的上下文时,就该引入 MCP server: + +- **Issue / 工单查询**——让 agent 拉取关联的 Jira / GitHub issue,核对变更是否 + 符合声明的需求。 +- **文档 / 知识库**——拉取内部 API 文档或编码规范,让评论引用真正的团队约定。 +- **自定义分析**——把 linter、schema 校验器或依赖检查器暴露为工具,供审查器按需 + 调用。 + +如果你只需要读仓库本身,内置工具就够了——MCP 是为了触达 checkout **之外**的东西。 + +## 工作原理 + +- OCR 通过 **stdio 传输**连接每个配置的 server:把 server 作为子进程启动,通过其 + stdin/stdout 说 MCP 协议。 +- 子进程的**工作目录设为仓库根目录**,并继承 OCR 的环境变量,外加你配置的 `env`。 +- 启动时 OCR 列出该 server 的工具,并注册进内置工具所用的同一个工具注册表。已注册的 + MCP 工具在 **plan 阶段和 main task 阶段都可用**。 +- server 在整个审查期间保持存活,审查结束时关闭。 + +如果某个 server 启动失败(或其 `setup` 命令失败),OCR 会打印警告并**在不使用它的 +情况下继续审查**——损坏的 MCP server 绝不会阻塞审查。 + +## 配置 + +MCP server 配置在用户配置文件(`~/.opencodereview/config.json`)的 `mcp_servers` +键下。每一项以你自选的名字为 key,接受以下字段: + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `command` | string | ✓ | 启动 MCP server 的可执行文件(如 `npx`、`uvx`、绝对路径)。 | +| `args` | string 数组 | | 传给 `command` 的参数。 | +| `env` | string 数组 | | 额外环境变量,`KEY=VALUE` 形式。 | +| `tools` | string 数组 | | 要注册的工具名白名单。为空 = 注册该 server 提供的全部工具。 | +| `setup` | string | | server 启动前运行一次的 shell 命令(如安装依赖)。在仓库根目录运行,超时 5 分钟。 | + +### 使用 CLI + +`ocr config set` 命令以非交互方式写入这些字段。数组字段(`args`、`env`、`tools`) +接受 JSON 数组字符串: + +```bash +# 最小配置:只给命令 +ocr config set mcp_servers.docs.command npx + +# 参数 +ocr config set mcp_servers.docs.args '["-y", "@acme/docs-mcp-server"]' + +# 环境变量(KEY=VALUE 条目) +ocr config set mcp_servers.docs.env '["DOCS_TOKEN=secret", "DOCS_REGION=eu"]' + +# 限制暴露给审查器的工具 +ocr config set mcp_servers.docs.tools '["search_docs", "get_page"]' + +# server 启动前运行的 setup 命令 +ocr config set mcp_servers.docs.setup "npm install -g @acme/docs-mcp-server" +``` + +用 `unset` 移除某个 server: + +```bash +ocr config unset mcp_servers.docs +``` + +### 手动编辑 + +等价的 JSON 配置: + +```json +{ + "mcp_servers": { + "docs": { + "command": "npx", + "args": ["-y", "@acme/docs-mcp-server"], + "env": ["DOCS_TOKEN=secret", "DOCS_REGION=eu"], + "tools": ["search_docs", "get_page"], + "setup": "npm install -g @acme/docs-mcp-server" + } + } +} +``` + +## 过滤工具 + +默认注册 server 声明的每个工具。当 server 暴露的工具超出审查器所需时,用 `tools` +设一个白名单——更少、更精准的工具能让 agent 更专注,也降低 token 成本。白名单里 +server 实际没有提供的名字会被跳过并给出警告,因此拼写错误会显示在 stderr 上,而不是 +悄无声息地什么都不做。 + +## 名称冲突 + +MCP 工具名与内置工具共享同一个命名空间。如果某个 server 声明的工具名与**内置/保留** +工具(`file_read`、`code_search`、`task_done` 等)冲突,或与另一个 MCP server 已 +注册的工具冲突,OCR 会**跳过**它并记录警告。先注册者胜出;为各 server 使用互不相同 +的工具名,以免因此丢失工具。 + +## `setup` 命令 + +`setup` 在 server 子进程启动前、从仓库根目录运行一次。用它来按需安装或构建 server: + +```json +"setup": "npm install -g @acme/docs-mcp-server" +``` + +它有 **5 分钟超时**。若非零退出,OCR 会记录命令、工作目录和输出,然后跳过该 server +并继续审查。 + +## 排错 + +所有 MCP 诊断信息都输出到 **stderr**,以 `[ocr]` 前缀标记,因此绝不会污染 stdout 上 +的 `--format json` 输出: + +- `Running setup for MCP server "x": …`——正在执行 setup 命令。 +- `failed to start MCP server "x": …`——子进程未在 30 秒初始化超时内连接成功,或 + `command` 不在 `PATH` 中。 +- `tool "y" conflicts with built-in tool, skipping`——重命名该 server 的工具,或将其 + 从 `tools` 中去掉。 +- `allowed tool "y" not found in server's tool list`——`tools` 中的名字与 server 提供 + 的任何工具都不匹配;检查拼写。 + +## 另见 + +- [工具](../tools/)——MCP 工具与之并列的六个内置工具。 +- [配置](../configuration/)——完整的配置文件与每个键。 +- [CLI 参考](../cli-reference/)——`ocr config` 与 review 参数。 diff --git a/pages/src/i18n/en.ts b/pages/src/i18n/en.ts index 53b387d..9560254 100644 --- a/pages/src/i18n/en.ts +++ b/pages/src/i18n/en.ts @@ -266,6 +266,7 @@ export const en: TranslationKeys = { 'docs.sidebar.reviewRules': 'Review Rules', 'docs.sidebar.architecture': 'Architecture', 'docs.sidebar.tools': 'Tools', + 'docs.sidebar.mcp': 'MCP Servers', 'docs.sidebar.viewer': 'Session Viewer', 'docs.sidebar.telemetry': 'Telemetry', 'docs.sidebar.integrations': 'Integrations', diff --git a/pages/src/i18n/ja.ts b/pages/src/i18n/ja.ts index 0164c26..2ead9d0 100644 --- a/pages/src/i18n/ja.ts +++ b/pages/src/i18n/ja.ts @@ -268,6 +268,7 @@ export const ja: TranslationKeys = { 'docs.sidebar.reviewRules': 'レビュールール', 'docs.sidebar.architecture': 'アーキテクチャ', 'docs.sidebar.tools': 'ツール', + 'docs.sidebar.mcp': 'MCP サーバー', 'docs.sidebar.viewer': 'セッションビューア', 'docs.sidebar.telemetry': 'テレメトリ', 'docs.sidebar.integrations': '統合', diff --git a/pages/src/i18n/zh.ts b/pages/src/i18n/zh.ts index 8b1aa1d..9b47435 100644 --- a/pages/src/i18n/zh.ts +++ b/pages/src/i18n/zh.ts @@ -268,6 +268,7 @@ export const zh: TranslationKeys = { 'docs.sidebar.reviewRules': '评审规则', 'docs.sidebar.architecture': '架构', 'docs.sidebar.tools': '工具', + 'docs.sidebar.mcp': 'MCP 服务器', 'docs.sidebar.viewer': '会话查看器', 'docs.sidebar.telemetry': '遥测', 'docs.sidebar.integrations': '集成', diff --git a/pages/src/pages/DocsPage.tsx b/pages/src/pages/DocsPage.tsx index a4e395b..7b37ca3 100644 --- a/pages/src/pages/DocsPage.tsx +++ b/pages/src/pages/DocsPage.tsx @@ -39,6 +39,7 @@ const sidebarTree: SidebarGroup[] = [ { id: 'sb-rules', labelKey: 'docs.sidebar.reviewRules', slug: 'review-rules' }, { id: 'sb-arch', labelKey: 'docs.sidebar.architecture', slug: 'architecture' }, { id: 'sb-tools', labelKey: 'docs.sidebar.tools', slug: 'tools' }, + { id: 'sb-mcp', labelKey: 'docs.sidebar.mcp', slug: 'mcp' }, { id: 'sb-viewer', labelKey: 'docs.sidebar.viewer', slug: 'viewer' }, { id: 'sb-telemetry', labelKey: 'docs.sidebar.telemetry', slug: 'telemetry' }, { From 44e486763733dfa7c6f93640eb5a70ccf3a96a1d Mon Sep 17 00:00:00 2001 From: kite Date: Fri, 3 Jul 2026 20:04:35 +0800 Subject: [PATCH 60/87] docs(pages): sync restructured MCP guide to en and ja (#293) Sync the reorganized zh/mcp.md into the English and Japanese versions: remove the MCP client-side paragraph and the How it works section, split Configuration into Adding/Removing subsections, drop the manual JSON edit example, move env to the end of the code block and table, and align dash spacing. Also fix a double-space typo in the zh version. --- pages/src/content/docs/en/mcp.md | 70 ++++++------------------- pages/src/content/docs/ja/mcp.md | 89 ++++++++++---------------------- pages/src/content/docs/zh/mcp.md | 87 ++++++++++--------------------- 3 files changed, 68 insertions(+), 178 deletions(-) diff --git a/pages/src/content/docs/en/mcp.md b/pages/src/content/docs/en/mcp.md index c18fa60..8392620 100644 --- a/pages/src/content/docs/en/mcp.md +++ b/pages/src/content/docs/en/mcp.md @@ -9,11 +9,6 @@ one or more external MCP servers, and the tools those servers expose become available to the review agent — right alongside the [built-in tools](../tools/) like `file_read` and `code_search`. -This is the *client* side of MCP. OCR does **not** run as an MCP server -that other agents call — see the note in -[Integrations](../integrations/#what-about-mcp) for that direction. This -page is about the opposite: giving OCR's own reviewer extra capabilities. - ## When to use it Reach for an MCP server when the reviewer would benefit from context that @@ -27,40 +22,11 @@ lives outside the diff: dependency checker as a tool the reviewer can invoke on demand. If all you need is a plain read of the repo, the built-in tools already -cover it — MCP is for reaching *beyond* the checkout. - -## How it works - -- OCR connects to each configured server over the **stdio transport**: it - launches the server as a subprocess and speaks MCP over its - stdin/stdout. -- The subprocess runs with its **working directory set to the repository - root**, and inherits OCR's environment plus any `env` you configure. -- On startup OCR lists the server's tools and registers them into the same - tool registry the built-in tools use. Registered MCP tools are available - in **both the plan phase and the main task**. -- Servers stay alive for the duration of the review and are shut down when - it finishes. - -If a server fails to start (or its `setup` command fails), OCR prints a -warning and **continues the review without it** — a broken MCP server -never blocks a review. +cover it — MCP is for reaching beyond the checkout. ## Configuration -MCP servers live under the `mcp_servers` key in your user config file -(`~/.opencodereview/config.json`). Each entry is keyed by a name you -choose and accepts these fields: - -| Field | Type | Required | Description | -|---|---|---|---| -| `command` | string | ✓ | Executable that starts the MCP server (e.g. `npx`, `uvx`, an absolute path). | -| `args` | string array | | Arguments passed to `command`. | -| `env` | string array | | Extra environment variables in `KEY=VALUE` form. | -| `tools` | string array | | Allowlist of tool names to register. Empty = register every tool the server offers. | -| `setup` | string | | Shell command run once before the server starts (e.g. install deps). Runs in the repo root with a 5-minute timeout. | - -### With the CLI +#### Adding an MCP server The `ocr config set` command writes these fields non-interactively. Array fields (`args`, `env`, `tools`) take a JSON array string: @@ -72,39 +38,33 @@ ocr config set mcp_servers.docs.command npx # Arguments ocr config set mcp_servers.docs.args '["-y", "@acme/docs-mcp-server"]' -# Environment variables (KEY=VALUE entries) -ocr config set mcp_servers.docs.env '["DOCS_TOKEN=secret", "DOCS_REGION=eu"]' - # Restrict which tools are exposed to the reviewer ocr config set mcp_servers.docs.tools '["search_docs", "get_page"]' # A setup command to run before the server starts ocr config set mcp_servers.docs.setup "npm install -g @acme/docs-mcp-server" + +# Environment variables (KEY=VALUE entries) +ocr config set mcp_servers.docs.env '["DOCS_TOKEN=secret", "DOCS_REGION=eu"]' ``` +#### Removing an MCP server + Remove a server with `unset`: ```bash ocr config unset mcp_servers.docs ``` -### By hand +MCP servers live under the `mcp_servers` key in your user config file (`~/.opencodereview/config.json`). -The same configuration as JSON: - -```json -{ - "mcp_servers": { - "docs": { - "command": "npx", - "args": ["-y", "@acme/docs-mcp-server"], - "env": ["DOCS_TOKEN=secret", "DOCS_REGION=eu"], - "tools": ["search_docs", "get_page"], - "setup": "npm install -g @acme/docs-mcp-server" - } - } -} -``` +| Field | Type | Required | Description | +|---|---|---|---| +| `command` | string | ✓ | Executable that starts the MCP server (e.g. `npx`, `uvx`, an absolute path). | +| `args` | string array | | Arguments passed to `command`. | +| `tools` | string array | | Allowlist of tool names to register. Empty = register every tool the server offers. | +| `setup` | string | | Shell command run once before the server starts (e.g. install deps). Runs in the repo root with a 5-minute timeout. | +| `env` | string array | | Extra environment variables in `KEY=VALUE` form. | ## Filtering tools diff --git a/pages/src/content/docs/ja/mcp.md b/pages/src/content/docs/ja/mcp.md index 42b7a10..f072318 100644 --- a/pages/src/content/docs/ja/mcp.md +++ b/pages/src/content/docs/ja/mcp.md @@ -9,54 +9,23 @@ OCR は **Model Context Protocol(MCP)クライアント**として動作で エージェントから利用できるようになり、`file_read` や `code_search` などの [組み込みツール](../tools/)と並んで使えます。 -これは MCP の**クライアント**側です。OCR は他のエージェントが呼び出す MCP server -としては動作**しません**——その方向については[統合](../integrations/#mcp)の -説明を参照してください。本ページはその逆、OCR 自身のレビュアーに能力を追加する話です。 - ## いつ使うか レビュアーが diff の外にあるコンテキストを必要とするときに MCP server を導入します: -- **Issue / チケット参照**——リンクされた Jira / GitHub issue を取得させ、変更が +- **Issue / チケット参照** —— リンクされた Jira / GitHub issue を取得させ、変更が 述べられた要件に合致するか確認する。 -- **ドキュメント / ナレッジベース**——社内 API ドキュメントやコーディング規約を +- **ドキュメント / ナレッジベース** —— 社内 API ドキュメントやコーディング規約を 取り込み、コメントが実際のチームルールを引用できるようにする。 -- **カスタム解析**——linter、スキーマ検証器、依存関係チェッカーを、レビュアーが +- **カスタム解析** —— linter、スキーマ検証器、依存関係チェッカーを、レビュアーが 必要に応じて呼び出せるツールとして公開する。 -リポジトリを読むだけでよいなら組み込みツールで十分です——MCP は checkout の**外**に +リポジトリを読むだけでよいなら組み込みツールで十分です —— MCP は checkout の外に 到達するためのものです。 -## 仕組み - -- OCR は設定された各 server に **stdio トランスポート**で接続します:server を - サブプロセスとして起動し、その stdin/stdout 経由で MCP を話します。 -- サブプロセスは**作業ディレクトリをリポジトリのルート**に設定して実行され、OCR の - 環境変数に加えて設定した `env` を継承します。 -- 起動時に OCR は server のツールを列挙し、組み込みツールが使うのと同じツール - レジストリに登録します。登録された MCP ツールは **plan フェーズと main task の - 両方**で利用できます。 -- server はレビューの間ずっと稼働し続け、終了時にシャットダウンされます。 - -server の起動に失敗した場合(または `setup` コマンドが失敗した場合)、OCR は警告を -表示し、**それを使わずにレビューを続行**します——壊れた MCP server がレビューを -ブロックすることはありません。 - ## 設定 -MCP server はユーザー設定ファイル(`~/.opencodereview/config.json`)の -`mcp_servers` キーの下に置きます。各エントリは任意の名前を key とし、次の -フィールドを受け付けます: - -| フィールド | 型 | 必須 | 説明 | -|---|---|---|---| -| `command` | string | ✓ | MCP server を起動する実行ファイル(`npx`、`uvx`、絶対パスなど)。 | -| `args` | string 配列 | | `command` に渡す引数。 | -| `env` | string 配列 | | 追加の環境変数、`KEY=VALUE` 形式。 | -| `tools` | string 配列 | | 登録するツール名の許可リスト。空 = server が提供する全ツールを登録。 | -| `setup` | string | | server 起動前に一度実行される shell コマンド(依存関係のインストールなど)。リポジトリのルートで実行、タイムアウト 5 分。 | - -### CLI を使う +#### MCP server を追加する `ocr config set` コマンドはこれらのフィールドを非対話的に書き込みます。配列 フィールド(`args`、`env`、`tools`)は JSON 配列文字列を受け取ります: @@ -68,44 +37,38 @@ ocr config set mcp_servers.docs.command npx # 引数 ocr config set mcp_servers.docs.args '["-y", "@acme/docs-mcp-server"]' -# 環境変数(KEY=VALUE エントリ) -ocr config set mcp_servers.docs.env '["DOCS_TOKEN=secret", "DOCS_REGION=eu"]' - # レビュアーに公開するツールを制限 ocr config set mcp_servers.docs.tools '["search_docs", "get_page"]' # server 起動前に実行する setup コマンド ocr config set mcp_servers.docs.setup "npm install -g @acme/docs-mcp-server" + +# 環境変数(KEY=VALUE エントリ) +ocr config set mcp_servers.docs.env '["DOCS_TOKEN=secret", "DOCS_REGION=eu"]' ``` +#### MCP server を削除する + `unset` で server を削除します: ```bash ocr config unset mcp_servers.docs ``` -### 手動で編集 +MCP server はユーザー設定ファイル(`~/.opencodereview/config.json`)の `mcp_servers` キーの下に置きます。 -同じ設定を JSON で書くと: - -```json -{ - "mcp_servers": { - "docs": { - "command": "npx", - "args": ["-y", "@acme/docs-mcp-server"], - "env": ["DOCS_TOKEN=secret", "DOCS_REGION=eu"], - "tools": ["search_docs", "get_page"], - "setup": "npm install -g @acme/docs-mcp-server" - } - } -} -``` +| フィールド | 型 | 必須 | 説明 | +|---|---|---|---| +| `command` | string | ✓ | MCP server を起動する実行ファイル(`npx`、`uvx`、絶対パスなど)。 | +| `args` | string 配列 | | `command` に渡す引数。 | +| `tools` | string 配列 | | 登録するツール名の許可リスト。空 = server が提供する全ツールを登録。 | +| `setup` | string | | server 起動前に一度実行される shell コマンド(依存関係のインストールなど)。リポジトリのルートで実行、タイムアウト 5 分。 | +| `env` | string 配列 | | 追加の環境変数、`KEY=VALUE` 形式。 | ## ツールのフィルタリング デフォルトでは server が広告するすべてのツールが登録されます。server が -レビュアーに必要以上のツールを公開する場合は `tools` に許可リストを設定します—— +レビュアーに必要以上のツールを公開する場合は `tools` に許可リストを設定します —— ツールが少なく的確なほどエージェントは集中でき、トークンコストも下がります。 リストに含まれていて server が実際には提供しない名前は警告付きでスキップされる ため、タイプミスは黙って無視されるのではなく stderr に表示されます。 @@ -135,16 +98,16 @@ server をオンデマンドでインストールまたはビルドするのに すべての MCP 診断情報は **stderr** に、`[ocr]` プレフィックス付きで出力されるため、 stdout の `--format json` 出力を汚染することはありません: -- `Running setup for MCP server "x": …`——setup コマンドを実行中。 -- `failed to start MCP server "x": …`——サブプロセスが 30 秒の初期化タイムアウト内に +- `Running setup for MCP server "x": …` —— setup コマンドを実行中。 +- `failed to start MCP server "x": …` —— サブプロセスが 30 秒の初期化タイムアウト内に 接続できなかったか、`command` が `PATH` にない。 -- `tool "y" conflicts with built-in tool, skipping`——server のツールを改名するか、 +- `tool "y" conflicts with built-in tool, skipping` —— server のツールを改名するか、 `tools` から外す。 -- `allowed tool "y" not found in server's tool list`——`tools` の名前が server の提供 +- `allowed tool "y" not found in server's tool list` —— `tools` の名前が server の提供 する何にも一致しない。スペルを確認。 ## 関連項目 -- [ツール](../tools/)——MCP ツールが並ぶ 6 つの組み込みツール。 -- [設定](../configuration/)——設定ファイル全体とすべてのキー。 -- [CLI リファレンス](../cli-reference/)——`ocr config` と review のフラグ。 +- [ツール](../tools/) —— MCP ツールが並ぶ 6 つの組み込みツール。 +- [設定](../configuration/) —— 設定ファイル全体とすべてのキー。 +- [CLI リファレンス](../cli-reference/) —— `ocr config` と review のフラグ。 diff --git a/pages/src/content/docs/zh/mcp.md b/pages/src/content/docs/zh/mcp.md index 962344e..4ca1422 100644 --- a/pages/src/content/docs/zh/mcp.md +++ b/pages/src/content/docs/zh/mcp.md @@ -5,51 +5,24 @@ sidebar: --- OCR 可以作为 **Model Context Protocol(MCP)客户端**。你把它指向一个或多个外部 -MCP server,这些 server 暴露的工具就会提供给审查 agent——与 `file_read`、 +MCP server,这些 server 暴露的工具就会提供给审查 agent —— 与 `file_read`、 `code_search` 等[内置工具](../tools/)并列。 -这是 MCP 的**客户端**侧。OCR **不会**作为供其他 agent 调用的 MCP server 运行—— -那个方向见[集成](../integrations/#mcp-怎么办)中的说明。本页讲的是相反的事:为 -OCR 自己的审查器扩展能力。 - ## 何时使用 当审查器需要 diff 之外的上下文时,就该引入 MCP server: -- **Issue / 工单查询**——让 agent 拉取关联的 Jira / GitHub issue,核对变更是否 +- **Issue / 工单查询** —— 让 agent 拉取关联的 Jira / GitHub issue,核对变更是否 符合声明的需求。 -- **文档 / 知识库**——拉取内部 API 文档或编码规范,让评论引用真正的团队约定。 -- **自定义分析**——把 linter、schema 校验器或依赖检查器暴露为工具,供审查器按需 +- **文档 / 知识库** —— 拉取内部 API 文档或编码规范,让评论引用真正的团队约定。 +- **自定义分析** —— 把 linter、schema 校验器或依赖检查器暴露为工具,供审查器按需 调用。 -如果你只需要读仓库本身,内置工具就够了——MCP 是为了触达 checkout **之外**的东西。 - -## 工作原理 - -- OCR 通过 **stdio 传输**连接每个配置的 server:把 server 作为子进程启动,通过其 - stdin/stdout 说 MCP 协议。 -- 子进程的**工作目录设为仓库根目录**,并继承 OCR 的环境变量,外加你配置的 `env`。 -- 启动时 OCR 列出该 server 的工具,并注册进内置工具所用的同一个工具注册表。已注册的 - MCP 工具在 **plan 阶段和 main task 阶段都可用**。 -- server 在整个审查期间保持存活,审查结束时关闭。 - -如果某个 server 启动失败(或其 `setup` 命令失败),OCR 会打印警告并**在不使用它的 -情况下继续审查**——损坏的 MCP server 绝不会阻塞审查。 +如果你只需要读仓库本身,内置工具就够了 —— MCP 是为了触达 checkout 之外的东西。 ## 配置 -MCP server 配置在用户配置文件(`~/.opencodereview/config.json`)的 `mcp_servers` -键下。每一项以你自选的名字为 key,接受以下字段: - -| 字段 | 类型 | 必填 | 说明 | -|---|---|---|---| -| `command` | string | ✓ | 启动 MCP server 的可执行文件(如 `npx`、`uvx`、绝对路径)。 | -| `args` | string 数组 | | 传给 `command` 的参数。 | -| `env` | string 数组 | | 额外环境变量,`KEY=VALUE` 形式。 | -| `tools` | string 数组 | | 要注册的工具名白名单。为空 = 注册该 server 提供的全部工具。 | -| `setup` | string | | server 启动前运行一次的 shell 命令(如安装依赖)。在仓库根目录运行,超时 5 分钟。 | - -### 使用 CLI +#### 添加 MCP server `ocr config set` 命令以非交互方式写入这些字段。数组字段(`args`、`env`、`tools`) 接受 JSON 数组字符串: @@ -61,44 +34,38 @@ ocr config set mcp_servers.docs.command npx # 参数 ocr config set mcp_servers.docs.args '["-y", "@acme/docs-mcp-server"]' -# 环境变量(KEY=VALUE 条目) -ocr config set mcp_servers.docs.env '["DOCS_TOKEN=secret", "DOCS_REGION=eu"]' - # 限制暴露给审查器的工具 ocr config set mcp_servers.docs.tools '["search_docs", "get_page"]' # server 启动前运行的 setup 命令 ocr config set mcp_servers.docs.setup "npm install -g @acme/docs-mcp-server" + +# 环境变量(KEY=VALUE 条目) +ocr config set mcp_servers.docs.env '["DOCS_TOKEN=secret", "DOCS_REGION=eu"]' ``` +#### 移除 MCP server + 用 `unset` 移除某个 server: ```bash ocr config unset mcp_servers.docs ``` -### 手动编辑 +MCP server 配置在用户配置文件(`~/.opencodereview/config.json`)的 `mcp_servers` 键下。 -等价的 JSON 配置: - -```json -{ - "mcp_servers": { - "docs": { - "command": "npx", - "args": ["-y", "@acme/docs-mcp-server"], - "env": ["DOCS_TOKEN=secret", "DOCS_REGION=eu"], - "tools": ["search_docs", "get_page"], - "setup": "npm install -g @acme/docs-mcp-server" - } - } -} -``` +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `command` | string | ✓ | 启动 MCP server 的可执行文件(如 `npx`、`uvx`、绝对路径)。 | +| `args` | string 数组 | | 传给 `command` 的参数。 | +| `tools` | string 数组 | | 要注册的工具名白名单。为空 = 注册该 server 提供的全部工具。 | +| `setup` | string | | server 启动前运行一次的 shell 命令(如安装依赖)。在仓库根目录运行,超时 5 分钟。 | +| `env` | string 数组 | | 额外环境变量,`KEY=VALUE` 形式。 | ## 过滤工具 默认注册 server 声明的每个工具。当 server 暴露的工具超出审查器所需时,用 `tools` -设一个白名单——更少、更精准的工具能让 agent 更专注,也降低 token 成本。白名单里 +设一个白名单 —— 更少、更精准的工具能让 agent 更专注,也降低 token 成本。白名单里 server 实际没有提供的名字会被跳过并给出警告,因此拼写错误会显示在 stderr 上,而不是 悄无声息地什么都不做。 @@ -125,16 +92,16 @@ MCP 工具名与内置工具共享同一个命名空间。如果某个 server 所有 MCP 诊断信息都输出到 **stderr**,以 `[ocr]` 前缀标记,因此绝不会污染 stdout 上 的 `--format json` 输出: -- `Running setup for MCP server "x": …`——正在执行 setup 命令。 -- `failed to start MCP server "x": …`——子进程未在 30 秒初始化超时内连接成功,或 +- `Running setup for MCP server "x": …` —— 正在执行 setup 命令。 +- `failed to start MCP server "x": …` —— 子进程未在 30 秒初始化超时内连接成功,或 `command` 不在 `PATH` 中。 -- `tool "y" conflicts with built-in tool, skipping`——重命名该 server 的工具,或将其 +- `tool "y" conflicts with built-in tool, skipping` —— 重命名该 server 的工具,或将其 从 `tools` 中去掉。 -- `allowed tool "y" not found in server's tool list`——`tools` 中的名字与 server 提供 +- `allowed tool "y" not found in server's tool list` —— `tools` 中的名字与 server 提供 的任何工具都不匹配;检查拼写。 ## 另见 -- [工具](../tools/)——MCP 工具与之并列的六个内置工具。 -- [配置](../configuration/)——完整的配置文件与每个键。 -- [CLI 参考](../cli-reference/)——`ocr config` 与 review 参数。 +- [工具](../tools/) —— MCP 工具与之并列的六个内置工具。 +- [配置](../configuration/) —— 完整的配置文件与每个键。 +- [CLI 参考](../cli-reference/) —— `ocr config` 与 review 参数。 From b82fcc31f2f5cbb560779a9e921532acab0470e1 Mon Sep 17 00:00:00 2001 From: munsunouk <52026496+munsunouk@users.noreply.github.com> Date: Fri, 3 Jul 2026 14:05:34 +0200 Subject: [PATCH 61/87] feat: add Astro-specific review rules (#289) --- internal/config/rules/rule_docs/astro.md | 45 ++++++++++++++++++++++ internal/config/rules/system_rules.json | 1 + internal/config/rules/system_rules_test.go | 14 ++++++- 3 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 internal/config/rules/rule_docs/astro.md diff --git a/internal/config/rules/rule_docs/astro.md b/internal/config/rules/rule_docs/astro.md new file mode 100644 index 0000000..5bdb8d9 --- /dev/null +++ b/internal/config/rules/rule_docs/astro.md @@ -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. +- `