feat: init

This commit is contained in:
kite 2026-05-18 13:10:38 +08:00
commit 7c8b8562aa
99 changed files with 112913 additions and 0 deletions

55
.github/workflows/deploy-pages.yml vendored Normal file
View file

@ -0,0 +1,55 @@
name: Deploy Pages
on:
push:
branches: [main]
paths:
- 'pages/**'
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: "pages"
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Install dependencies
working-directory: pages
run: npm install
- name: Build
working-directory: pages
run: npm run build
- name: Prepare deployment artifact
run: |
mkdir -p _site
cp -r pages/dist/* _site/
- uses: actions/upload-pages-artifact@v3
with:
path: _site
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
needs: build
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

82
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,82 @@
name: Release
on:
push:
tags: ['v*']
permissions:
contents: write
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
include:
- goos: linux
goarch: amd64
- goos: linux
goarch: arm64
- goos: darwin
goarch: amd64
- goos: darwin
goarch: arm64
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Build
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: '0'
run: |
VERSION=${GITHUB_REF_NAME}
GIT_COMMIT=$(git rev-parse --short HEAD)
BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
LD_FLAGS="-s -w -X main.Version=${VERSION} -X main.GitCommit=${GIT_COMMIT} -X main.BuildDate=${BUILD_DATE}"
go build -ldflags "${LD_FLAGS}" -o opencodereview-${{ matrix.goos }}-${{ matrix.goarch }} ./cmd/opencodereview
- uses: actions/upload-artifact@v4
with:
name: binary-${{ matrix.goos }}-${{ matrix.goarch }}
path: opencodereview-${{ matrix.goos }}-${{ matrix.goarch }}
release:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
pattern: binary-*
merge-multiple: true
- name: Generate checksums
run: sha256sum opencodereview-* | sort > sha256sum.txt
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
files: |
opencodereview-*
sha256sum.txt
generate_release_notes: true
npm-publish:
needs: release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
registry-url: https://registry.npmjs.org
- name: Publish to npm
run: npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

33
.gitignore vendored Normal file
View file

@ -0,0 +1,33 @@
# IDEA
.idea/*
*.iml
*.ipr
*.iws
dist/
.claude/
CLAUDE.md
pages/package-lock.json
scripts/hooks/
CHANGELOG.md
verdor/
temp/
# Ignore downloaded Go binary, but keep JS entry point
!bin/ocr.js
bin/opencodereview
/opencodereview
# Mac OS X
.DS_Store
# Eclipse git ignore#
.project
.classpath
.settings/
*.factorypath
*.run.xml
**/spoon.classpath.tmp

17
.npmignore Normal file
View file

@ -0,0 +1,17 @@
cmd/
internal/
dist/
pages/
Makefile
go.mod
go.sum
scripts/hooks/
.claude/
.git/
.idea/
.github/
.gitignore
.ossutil_checkpoint/
publish-strategy.md
opencodereview
CLAUDE.md

201
LICENSE Normal file
View file

@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this definition, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a complaint) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that you distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act on
Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "{}"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the same
"printed page" as the copyright notice for easier identification within
third-party archives.
Copyright 2026 Alibaba
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

70
Makefile Normal file
View file

@ -0,0 +1,70 @@
.PHONY: build test clean run help \
build-all dist sha256sum version-info \
build-linux-amd64 build-linux-arm64 build-darwin-amd64 build-darwin-arm64
BINARY_NAME := opencodereview
GO := go
DIST_DIR := ./dist
# Version info — use git tag if available, fallback to short commit hash
GIT_TAG := $(shell git describe --tags --abbrev=0 2>/dev/null || echo "")
GIT_COMMIT := $(shell git rev-parse --short HEAD)
BUILD_DATE := $(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
VERSION ?= $(if $(GIT_TAG),$(GIT_TAG),v0.0.0-$(GIT_COMMIT))
LD_FLAGS := -s -w \
-X main.Version=$(VERSION) \
-X main.GitCommit=$(GIT_COMMIT) \
-X main.BuildDate=$(BUILD_DATE)
define BUILD_PLATFORM
GOOS=$(1) GOARCH=$(2) CGO_ENABLED=0 $(GO) build -ldflags "$(LD_FLAGS)" \
-o $(DIST_DIR)/$(BINARY_NAME)-$(VERSION)-$(1)-$(2) \
./cmd/opencodereview
endef
# ── Development targets ──────────────────────────────────────────────────────
build:
$(GO) build -ldflags "$(LD_FLAGS)" -o $(DIST_DIR)/$(BINARY_NAME) ./cmd/opencodereview
test:
$(GO) test -v -race -count=1 ./...
clean:
rm -rf $(DIST_DIR)
run: build
$(DIST_DIR)/$(BINARY_NAME) --staged
help: build
$(DIST_DIR)/$(BINARY_NAME) -h
# ── Cross-platform targets ───────────────────────────────────────────────────
build-linux-amd64:
$(call BUILD_PLATFORM,linux,amd64)
build-linux-arm64:
$(call BUILD_PLATFORM,linux,arm64)
build-darwin-amd64:
$(call BUILD_PLATFORM,darwin,amd64)
build-darwin-arm64:
$(call BUILD_PLATFORM,darwin,arm64)
build-all: build-linux-amd64 build-linux-arm64 build-darwin-amd64 build-darwin-arm64
# Generate SHA256 checksums for all release binaries
sha256sum: build-all
cd $(DIST_DIR) && shasum -a 256 $(BINARY_NAME)-$(VERSION)-* | sort > sha256sum-$(VERSION).txt
# Full release: clean → build all platforms → checksums
dist: clean build-all sha256sum
@echo $(VERSION) > $(DIST_DIR)/VERSION
version-info:
@echo "Version: $(VERSION)"
@echo "GitCommit: $(GIT_COMMIT)"
@echo "BuildDate: $(BUILD_DATE)"
@echo "LD_FLAGS: $(LD_FLAGS)"

95
NPM-README.md Normal file
View file

@ -0,0 +1,95 @@
# OpenCodeReview CLI
AI-powered code review tool that reads Git diffs, sends changed files to a configurable LLM via OpenAI-compatible API, and generates structured review comments. It goes beyond surface-level analysis — the Agent can read project context for deep reviews.
## Install
```bash
npm install -g @alibaba/open-code-review
```
After installation, the `ocr` command is available globally.
### Version Control
```bash
# Install specific version
OCR_VERSION=v1.0.0 npm install -g @alibaba/open-code-review
```
## Prerequisites
**You must configure an LLM provider before using `ocr`.** The tool requires access to an OpenAI-compatible API endpoint (OpenAI, Claude, local models, etc.).
```bash
ocr config set llm.url https://api.anthropic.com/v1/messages \
&& ocr config set llm.auth_token {{your-api-key}} \
&& ocr config set llm.model claude-opus-4-6 \
&& ocr config set llm.use_anthropic true \
&& ocr config set language Chinese
```
Config is stored in `~/.open-code-review/config.json`.
Or via environment variables:
```bash
export OCR_LLM_URL=https://api.anthropic.com/v1/messages
export OCR_LLM_TOKEN=your-api-key
export OCR_LLM_MODEL=claude-opus-4-6
```
### Test Connectivity
```bash
ocr llm test
```
## Quick Start
Navigate to any Git repository and run:
```bash
# Review all workspace changes
ocr review
# Review diff between two branches
ocr review --from main --to feature-branch
# Review a single commit
ocr review --commit abc123
```
## Commands
| Command | Description |
|---------|-------------|
| `ocr review` / `ocr r` | Start code review |
| `ocr config set <key> <value>` | Manage configuration |
| `ocr llm test` | Test LLM connectivity |
| `ocr viewer` | Start WebUI session viewer |
| `ocr version` | Show version info |
## Common Options
| Flag | Shorthand | Default | Description |
|------|-----------|---------|-------------|
| `--repo` | | current dir | Git repository root |
| `--from` | | | Source ref (e.g., `main`) |
| `--to` | | | Target ref (e.g., `feature-branch`) |
| `--commit` | `-c` | | Review a single commit |
| `--format` | `-f` | `text` | Output format: `text` or `json` |
| `--concurrency` | | `4` | Max concurrent file reviews |
| `--timeout` | | `10` | Per-file timeout (minutes) |
## Features
- **Three review modes**: workspace changes, branch range, single commit
- **Context-aware**: Agent reads arbitrary files, searches code via `git grep`, inspects diffs
- **Plan phase**: Large changes (>50 lines) get risk analysis first
- **Any LLM**: Works with OpenAI, Claude-compatible endpoints, local models
- **Concurrent**: Files reviewed in parallel (configurable workers)
## License
Apache-2.0

224
README.md Normal file
View file

@ -0,0 +1,224 @@
# OpenCodeReview
AI-powered code review CLI that reads Git diffs, sends changed files to a configurable LLM via an agent with tool-use capabilities, and generates structured review comments with line-level precision.
The agent can read full file contents, search the codebase, inspect other changed files for context, and produce deep reviews — not just surface-level diff feedback.
## Install
### Via NPM (Recommended)
```bash
npm install -g @alibaba/open-code-review
```
After installation, the `ocr` command is available globally.
### From Source
```bash
git clone https://github.com/alibaba/open-code-review.git
cd open-code-review
make build
sudo cp dist/ocr-*/ocr /usr/local/bin/ocr
```
## Quick Start
### 1. Configure LLM
**You must configure an LLM before reviewing code.**
```bash
# Option A: Interactive config
ocr config set llm.url https://api.anthropic.com/v1/messages
ocr config set llm.auth_token your-api-key-here
ocr config set llm.model claude-opus-4-6
ocr config set llm.use_anthropic true
# Option B: Environment variables (highest priority)
export OCR_LLM_URL=https://api.anthropic.com/v1/messages
export OCR_LLM_TOKEN=your-api-key-here
export OCR_LLM_MODEL=claude-opus-4-6
export OCR_USE_ANTHROPIC=true
```
Config is stored in `~/.open-code-review/config.json`.
The tool also falls back to Claude Code environment variables (`ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_MODEL`) and parses `~/.zshrc` / `~/.bashrc` for those exports.
### 2. Test Connectivity
```bash
ocr llm test
```
### 3. Review
```bash
cd your-project
# Workspace mode — review all staged, unstaged, and untracked changes
ocr review
# Branch range — compare two refs
ocr review --from main --to feature-branch
# Single commit
ocr review --commit abc123
```
## Commands
| Command | Alias | Description |
|---------|-------|-------------|
| `ocr review` | `ocr r` | Start a code review |
| `ocr config set <key> <value>` | — | Set configuration values |
| `ocr llm test` | — | Test LLM connectivity |
| `ocr viewer` | `ocr v` | Launch WebUI session viewer on `localhost:5483` |
| `ocr version` | — | Show version info |
### `ocr review` Flags
| Flag | Shorthand | Default | Description |
|------|-----------|---------|-------------|
| `--repo` | — | current dir | Git repository root |
| `--from` | — | — | Source ref (e.g., `main`) |
| `--to` | — | — | Target ref (e.g., `feature-branch`) |
| `--commit` | `-c` | — | Single commit to review |
| `--format` | `-f` | `text` | Output format: `text` or `json` |
| `--concurrency` | — | `8` | Max concurrent file reviews |
| `--timeout` | — | `10` | Concurrent task timeout in minutes |
| `--audience` | — | `human` | `human` (show progress) or `agent` (summary only) |
| `--rule` | — | — | Path to custom JSON review rules |
| `--tools` | — | — | Path to custom JSON tools config |
## Examples
```bash
# Review workspace changes with default settings
ocr review
# Review branch diff with higher concurrency
ocr review --from main --to my-feature --concurrency 4
# Review a specific commit with verbose JSON output
ocr review --commit abc123 --format json --audience agent
# Use custom review rules
ocr review --rule /path/to/my-rules.json
# View review session history in browser
ocr viewer
ocr viewer --addr :3000
```
## Architecture
The review agent follows a **three-phase workflow**:
1. **Plan Phase** — For changes exceeding 50 lines, the agent performs risk analysis before reviewing. Smaller diffs skip directly to the main phase.
2. **Main Task Loop** — Each changed file gets its own goroutine. The LLM interacts with built-in tools (read files, search code, read diffs, submit comments) in a conversation loop until it calls `task_done`.
3. **Memory Compression** — When prompt context exceeds token thresholds (60% async, 80% sync), the agent uses three-zone partitioning (frozen / compress / active) to manage context window size.
### Key Design Decisions
- **Concurrent per-file processing** — Files are reviewed in parallel (default 8 workers). Timeout prevents any single file from blocking others.
- **Dual protocol support** — Both Anthropic Messages API and OpenAI Chat Completions API are supported, with automatic URL normalization.
- **Tool-use agent** — The LLM has access to domain-specific tools (`code_search`, `file_read`, `code_comment`, `file_find`, `file_read_diff`), enabling cross-referential context-aware reviews rather than isolated diff scanning.
## Configuration Reference
Config file: `~/.open-code-review/config.json`
| Key | Type | Example |
|-----|------|---------|
| `llm.url` | string | `https://api.openai.com/v1/chat/completions` |
| `llm.auth_token` | string | `sk-xxxxxxx` |
| `llm.model` | string | `claude-opus-4-6` |
| `llm.use_anthropic` | boolean | `true` \| `false` |
| `language` | string | `English` \| `Chinese` (default: Chinese) |
| `telemetry.enabled` | boolean | `true` \| `false` |
| `telemetry.exporter` | string | `console` \| `otlp` |
| `telemetry.otlp_endpoint` | string | OTLP collector address |
| `telemetry.content_logging` | boolean | Include prompts in telemetry |
Environment variables take precedence over the config file.
### Environment Variables
| Variable | Purpose |
|----------|---------|
| `OCR_LLM_URL` | LLM API endpoint URL |
| `OCR_LLM_TOKEN` | API key / auth token |
| `OCR_LLM_MODEL` | Model name |
| `OCR_USE_ANTHROPIC` | `true` = Anthropic, `false` = OpenAI |
### Template Parameters
Internal defaults defined in `internal/config/template/task_template.json`:
| Parameter | Default | Description |
|-----------|---------|-------------|
| `MAX_TOKENS` | 58888 | Max tokens per LLM request |
| `MAX_TOOL_REQUEST_TIMES` | 20 | Max tool-use iterations per file |
| `PLAN_MODE_LINE_THRESHOLD` | 50 | Skip plan phase below this line count |
| `TOOL_REQUEST_WAIT_TIME_MS` | 10000 | Per-tool-request timeout |
## Built-in Tools
Tools the LLM agent can invoke during review:
| Tool | Phases | Purpose |
|------|--------|---------|
| `task_done` | main_task | Terminate the review (DONE/FAILED) |
| `code_comment` | main_task | Submit a line-level review comment |
| `file_read` | main_task | Read file content at a line range |
| `code_search` | plan + main | Search text/regex across files |
| `file_read_diff` | plan + main | View diff content for other changed files |
| `file_find` | plan + main | Find files by filename keyword |
## System Review Rules
Built-in glob-pattern-matched review checklists per file type, defined in `internal/config/rules/system_rules.json`:
| Pattern | Focus Areas |
|---------|-------------|
| `*.java` | NPE risks, dead loops, switch fallthrough, N+1 queries, thread safety |
| `*.{ts,js,tsx,jsx}` | Quality, React best practices, async norms, XSS/security |
| `*.kt` | Null safety, coroutine usage, idiomatic patterns |
| `*{go,py,ets,lua,dart,swift,groovy}` | Logic bugs, typos |
| `*{cpp,cc,hpp}` | Smart pointers, RAII, STL, const correctness |
| `*.c` | malloc/free pairing, buffer overflow |
| `pom.xml` / `build.gradle` | SNAPSHOT version prevention |
| `package.json` | Latest/wildcard versions, dependency conflicts |
| `*mapper*.xml` / `*dao*.xml` | SQL injection, performance, logic errors |
| `*.properties` | Typo detection, duplicate keys, security issues |
Override with `--rule path/to/rules.json`.
## Telemetry
OpenTelemetry integration for observability (spans, metrics). Disabled by default.
```bash
ocr config set telemetry.enabled true
ocr config set telemetry.exporter otlp
ocr config set telemetry.otlp_endpoint localhost:4317
```
Set `telemetry.content_logging` to include LLM prompts and responses in exported data.
## Development
```bash
make build # Build for current platform
make test # Run tests with race detection
make clean # Remove dist/
make build-all # Cross-compile (linux/amd64, linux/arm64, darwin/amd64, darwin/arm64)
make dist # Full release pipeline
```
## License
[Apache-2.0](LICENSE) — Copyright 2026 Alibaba

14
bin/ocr.js Executable file
View file

@ -0,0 +1,14 @@
#!/usr/bin/env node
"use strict";
const { spawnSync } = require("child_process");
const path = require("path");
const binaryPath = path.join(__dirname, "opencodereview");
const result = spawnSync(binaryPath, process.argv.slice(2), {
stdio: "inherit",
env: process.env,
});
process.exit(result.status ?? (result.error ? 1 : 0));

View file

@ -0,0 +1,168 @@
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
)
// Default config file location: ~/.open-code-review/config.json
func defaultConfigPath() string {
home, err := os.UserHomeDir()
if err != nil {
return "open-code-review.json"
}
return filepath.Join(home, ".open-code-review", "config.json")
}
func runConfig(args []string) error {
if len(args) == 0 {
printConfigUsage()
return nil
}
action, err := parseConfigArgs(args)
if err != nil {
return err
}
switch action.subCmd {
case "set":
return runConfigSet(action.key, action.value)
default:
return fmt.Errorf("unknown config sub-command: %s", action.subCmd)
}
}
func runConfigSet(key, value string) error {
configPath := defaultConfigPath()
cfg, err := loadOrCreateConfig(configPath)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
if err := setConfigValue(cfg, key, value); err != nil {
return err
}
dir := filepath.Dir(configPath)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("create config dir: %w", err)
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return fmt.Errorf("marshal config: %w", err)
}
if err := os.WriteFile(configPath, data, 0o644); err != nil {
return fmt.Errorf("write config: %w", err)
}
fmt.Printf("Set %s = %s\n", key, value)
return nil
}
// Config represents the user-level configuration file (~/.open-code-review/config.json).
type Config struct {
Llm LlmConfig `json:"llm,omitempty"`
Language string `json:"language,omitempty"` // Output language, defaults to Chinese when empty
Telemetry *TelemetryConfig `json:"telemetry,omitempty"` // Telemetry/observability settings
}
type LlmConfig struct {
URL string `json:"url,omitempty"`
AuthToken string `json:"auth_token,omitempty"`
Model string `json:"model,omitempty"`
UseAnthropic *bool `json:"use_anthropic,omitempty"` // nil = default true; false = OpenAI protocol
}
// TelemetryConfig holds telemetry-specific settings.
type TelemetryConfig struct {
Enabled bool `json:"enabled,omitempty"` // Master switch for telemetry
Exporter string `json:"exporter,omitempty"` // "console" or "otlp"
OTLPEndpoint string `json:"otlp_endpoint,omitempty"` // OTLP collector address
ContentLog bool `json:"content_logging,omitempty"` // Include prompt/response content
}
func loadOrCreateConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return &Config{}, nil
}
return nil, err
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parse config: %w", err)
}
return &cfg, nil
}
// LoadAppConfig loads config from path. Returns nil, nil if file does not exist.
func LoadAppConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("read app config %s: %w", path, err)
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parse app config: %w", err)
}
return &cfg, nil
}
func setConfigValue(cfg *Config, key, value string) error {
switch key {
case "llm.url", "llm.URL":
cfg.Llm.URL = value
case "llm.auth_token", "llm.AuthToken":
cfg.Llm.AuthToken = value
case "llm.model", "llm.Model":
cfg.Llm.Model = value
case "llm.use_anthropic", "llm.UseAnthropic":
b, err := strconv.ParseBool(value)
if err != nil {
return fmt.Errorf("invalid boolean for llm.use_anthropic: %w", err)
}
cfg.Llm.UseAnthropic = &b
case "language", "Language":
cfg.Language = value
case "telemetry.enabled", "telemetry.Enabled":
b, err := strconv.ParseBool(value)
if err != nil {
return fmt.Errorf("invalid boolean for telemetry.enabled: %w", err)
}
cfg.ensureTelemetry()
cfg.Telemetry.Enabled = b
case "telemetry.exporter", "telemetry.Exporter":
cfg.ensureTelemetry()
cfg.Telemetry.Exporter = value
case "telemetry.otlp_endpoint", "telemetry.OTLPEndpoint":
cfg.ensureTelemetry()
cfg.Telemetry.OTLPEndpoint = value
case "telemetry.content_logging", "telemetry.ContentLog":
b, err := strconv.ParseBool(value)
if err != nil {
return fmt.Errorf("invalid boolean for telemetry.content_logging: %w", err)
}
cfg.ensureTelemetry()
cfg.Telemetry.ContentLog = b
default:
return fmt.Errorf("unknown config key: %s\nSupported keys: llm.url, llm.auth_token, llm.model, llm.use_anthropic, language, telemetry.enabled, telemetry.exporter, telemetry.otlp_endpoint, telemetry.content_logging", key)
}
return nil
}
func (c *Config) ensureTelemetry() {
if c.Telemetry == nil {
c.Telemetry = &TelemetryConfig{}
}
}

243
cmd/opencodereview/flags.go Normal file
View file

@ -0,0 +1,243 @@
package main
import (
"flag"
"fmt"
"time"
)
// --- custom flag set that supports short flags (-c, -f etc.) ---
type ocrFlagSet struct {
fs *flag.FlagSet
shortMap map[string]string // maps short key "c" -> full name "commit"
showHelp bool
}
func newOcrFlagSet(name string) *ocrFlagSet {
return &ocrFlagSet{
fs: flag.NewFlagSet(name, flag.ContinueOnError),
shortMap: make(map[string]string),
}
}
// StringVarP registers --name with optional short form -s.
func (a *ocrFlagSet) StringVarP(p *string, name, shorthand string, value, usage string) {
suffix := ""
if shorthand != "" {
a.shortMap[shorthand] = name
suffix = fmt.Sprintf(" (shorthand: -%s)", shorthand)
}
a.fs.StringVar(p, name, value, usage+suffix)
}
// BoolVarP registers --name with optional short form -s.
func (a *ocrFlagSet) BoolVarP(p *bool, name, shorthand string, value bool, usage string) {
suffix := ""
if shorthand != "" {
a.shortMap[shorthand] = name
suffix = fmt.Sprintf(" (shorthand: -%s)", shorthand)
}
a.fs.BoolVar(p, name, value, usage+suffix)
}
func (a *ocrFlagSet) StringVar(p *string, name string, value string, usage string) {
a.fs.StringVar(p, name, value, usage)
}
func (a *ocrFlagSet) BoolVar(p *bool, name string, value bool, usage string) {
a.fs.BoolVar(p, name, value, usage)
}
func (a *ocrFlagSet) IntVar(p *int, name string, value int, usage string) {
a.fs.IntVar(p, name, value, usage)
}
func (a *ocrFlagSet) DurationVar(p *time.Duration, name string, value time.Duration, usage string) {
a.fs.DurationVar(p, name, value, usage)
}
func (a *ocrFlagSet) PrintDefaults() {
a.fs.PrintDefaults()
}
func (a *ocrFlagSet) Parse(arguments []string) error {
expanded := expandShortFlags(arguments, a.shortMap)
for _, arg := range expanded {
if arg == "-h" || arg == "--help" {
a.showHelp = true
return nil
}
}
return a.fs.Parse(expanded)
}
// expandShortFlags replaces standalone -X args with their long equivalents.
// Only triggers when the arg is exactly -N (single char after dash).
func expandShortFlags(args []string, shortMap map[string]string) []string {
out := make([]string, 0, len(args))
for _, arg := range args {
if len(arg) == 2 && arg[0] == '-' && arg[1] != '-' {
key := string(arg[1])
if full, ok := shortMap[key]; ok {
out = append(out, "--"+full)
continue
}
}
out = append(out, arg)
}
return out
}
// --- review subcommand options ---
type reviewOptions struct {
toolConfigPath string
rulePath string
repoDir string
from string
to string
commit string
outputFormat string
audience string // --audience: "human" (default) or "agent"
background string // --background: optional requirement context
concurrency int
perFileTimeout int
showHelp bool
}
func parseReviewFlags(args []string) (reviewOptions, error) {
a := newOcrFlagSet("ocr review")
opts := reviewOptions{}
a.StringVar(&opts.toolConfigPath, "tools", "", "path to JSON tools config file (default: embedded)")
a.StringVar(&opts.rulePath, "rule", "", "path to JSON file with system review rules")
a.StringVar(&opts.repoDir, "repo", "", "root directory of the git repository (default: current dir)")
a.StringVar(&opts.from, "from", "", "source ref to start diff from (e.g., 'main')")
a.StringVar(&opts.to, "to", "", "target ref to end diff at (e.g., 'feature-branch')")
a.StringVarP(&opts.commit, "commit", "c", "", "single commit hash or tag to review (vs its parent)")
a.StringVarP(&opts.outputFormat, "format", "f", "text", "output format: text or json")
a.IntVar(&opts.concurrency, "concurrency", 8, "max concurrent file reviews")
a.IntVar(&opts.perFileTimeout, "timeout", 10, "concurrent task timeout in minutes")
a.StringVar(&opts.audience, "audience", "human", "output audience: human (show progress) or agent (summary only)")
a.StringVarP(&opts.background, "background", "b", "", "optional requirement/business context for the review")
if err := a.Parse(args); err != nil {
return opts, fmt.Errorf("parse flags: %w", err)
}
opts.showHelp = a.showHelp
if opts.showHelp {
return opts, nil
}
modeCount := 0
if opts.from != "" || opts.to != "" {
modeCount++
}
if opts.commit != "" {
modeCount++
}
// modeCount == 0 → workspace mode (no error, allowed)
if modeCount > 1 {
return opts, fmt.Errorf("only one review mode allowed (--from/--to or --commit)")
}
if opts.from != "" && opts.to == "" {
return opts, fmt.Errorf("--to is required when --from is specified")
}
switch opts.audience {
case "human", "agent":
default:
return opts, fmt.Errorf("invalid --audience value %q: must be 'human' or 'agent'", opts.audience)
}
return opts, nil
}
func printReviewUsage() {
fmt.Println(`OpenCodeReview - AI-Powered Code Review CLI
Usage:
ocr review [flags]
ocr r [flags] (alias)
Examples:
# Review staged + unstaged + untracked changes in current workspace
ocr review
# Review a branch against its base (merge-base mode)
ocr review --from master --to dev-ref
# Review a specific commit
ocr review --commit abc123
ocr review -c abc123
# Output JSON format
ocr review --format json
ocr review -f json
# Agent mode (summary only, no progress lines)
ocr review --audience agent
Flags:
--audience string output audience: human (show progress) or agent (summary only) (default "human")
-b, --background string optional requirement/business context for the review
-c, --commit string single commit hash or tag to review (vs its parent)
-f, --format string output format: text or json (default "text")
--concurrency int max concurrent file reviews (default 8)
--from string source ref to start diff from (e.g., 'main')
--repo string root directory of the git repository (default: current dir)
--rule string path to JSON file with system review rules
--timeout int concurrent task timeout in minutes (default 10)
--to string target ref to end diff at (e.g., 'feature-branch')
--tools string path to JSON tools config file (default: embedded)`)
}
// --- config subcommand ---
type configAction struct {
subCmd string // "set"
key string
value string
}
func parseConfigArgs(args []string) (configAction, error) {
if len(args) == 0 {
return configAction{}, fmt.Errorf("usage: ocr config set <key> <value>\ne.g., ocr config set llm.model claude-opus-4-6")
}
subCmd := args[0]
switch subCmd {
case "set":
if len(args) < 3 {
return configAction{}, fmt.Errorf("usage: ocr config set <key> <value>\ne.g., ocr config set llm.model claude-opus-4-6")
}
return configAction{
subCmd: "set",
key: args[1],
value: args[2],
}, nil
default:
return configAction{}, fmt.Errorf("unknown config sub-command: %s\nAvailable: set", subCmd)
}
}
func printConfigUsage() {
fmt.Println(`Configuration management.
Usage:
ocr config set <key> <value>
Examples:
ocr config set llm.url https://xx/v1/openai/chat/completions
ocr config set llm.auth_token xxxxxxxxxx
ocr config set llm.model claude-opus-4-6
ocr config set language English
ocr config set telemetry.enabled true
Supported keys: llm.url, llm.auth_token, llm.model, llm.use_anthropic, language, telemetry.enabled, telemetry.exporter, telemetry.otlp_endpoint, telemetry.content_logging`)
}

11
cmd/opencodereview/git.go Normal file
View file

@ -0,0 +1,11 @@
package main
import (
"os/exec"
)
func runGitCmd(repoDir string, args ...string) ([]byte, error) {
fullArgs := append([]string{"-C", repoDir}, args...)
cmd := exec.Command("git", fullArgs...)
return cmd.CombinedOutput()
}

View file

@ -0,0 +1,98 @@
package main
import (
"context"
"fmt"
"time"
"github.com/open-code-review/open-code-review/internal/config/template"
"github.com/open-code-review/open-code-review/internal/config/testconnection"
"github.com/open-code-review/open-code-review/internal/llm"
)
func runLLM(args []string) error {
if len(args) == 0 {
printLLMUsage()
return nil
}
switch args[0] {
case "test":
return runLLMTest()
default:
return fmt.Errorf("unknown llm sub-command: %s\nRun 'ocr llm' for usage", args[0])
}
}
func runLLMTest() error {
appCfg, err := LoadAppConfig(defaultConfigPath())
if err != nil {
return fmt.Errorf("load config: %w", err)
}
ep, err := llm.ResolveEndpoint(defaultConfigPath())
if err != nil {
return fmt.Errorf("resolve LLM endpoint: %w", err)
}
task, err := testconnection.LoadDefault()
if err != nil {
return fmt.Errorf("load test task config: %w", err)
}
if appCfg != nil {
task.ApplyLanguage(appCfg.Language)
}
timeout := 30 * time.Second
if task.Timeout > 0 {
timeout = time.Duration(task.Timeout) * time.Second
}
tpl, err := template.LoadDefault()
if err != nil {
return fmt.Errorf("load default template: %w", err)
}
llmClient := llm.NewLLMClient(ep)
messages := make([]llm.Message, 0, len(task.Messages))
for _, m := range task.Messages {
messages = append(messages, llm.Message{Role: m.Role, Content: m.Content})
}
resp, err := func() (*llm.ChatResponse, error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
return llmClient.CompletionsWithCtx(ctx, llm.ChatRequest{
Model: ep.Model,
Messages: messages,
MaxTokens: tpl.MaxTokens,
})
}()
if err != nil {
return fmt.Errorf("llm request failed: %w", err)
}
model := ep.Model
if resp.Model != "" {
model = resp.Model
}
fmt.Printf("Source: %s\n", ep.Source)
fmt.Printf("URL: %s\n", ep.URL)
fmt.Printf("Model: %s\n", model)
fmt.Printf("%s\n", resp.Content())
return nil
}
func printLLMUsage() {
fmt.Println(`LLM utility commands.
Usage:
ocr llm <sub-command>
Sub-commands:
test Send a test conversation to the configured LLM model
Examples:
ocr llm test Verify LLM connectivity and configuration`)
}

View file

@ -0,0 +1,86 @@
// OpenCodeReview is an AI-powered code review CLI tool.
// It reads git diffs, sends them to a configurable LLM service, and generates review comments.
package main
import (
"context"
"fmt"
"os"
"time"
"github.com/open-code-review/open-code-review/internal/llm"
"github.com/open-code-review/open-code-review/internal/telemetry"
)
func main() {
llm.AppVersion = Version
llm.InitEmbeddedLoader()
ctx := context.Background()
if telemetry.Init(ctx) {
defer telemetry.ShutdownWithTimeout(ctx, 5*time.Second)
}
if err := dispatch(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
// dispatch routes top-level subcommands or global flags.
func dispatch() error {
args := os.Args[1:]
// No args → default to review with empty args (will trigger usage/help)
if len(args) == 0 {
printTopLevelUsage()
return nil
}
switch args[0] {
case "--version", "-V":
printVersion()
return nil
case "version":
printVersion()
return nil
case "review", "r":
return runReview(args[1:])
case "config":
return runConfig(args[1:])
case "llm":
return runLLM(args[1:])
case "viewer":
return runViewer(args[1:])
case "-h", "--help":
printTopLevelUsage()
return nil
default:
return fmt.Errorf("unknown command: %s\nRun 'ocr' for usage", args[0])
}
}
func printTopLevelUsage() {
fmt.Println(`OpenCodeReview - AI-Powered Code Review CLI
Usage:
ocr [command]
Commands:
review, r Start a code review
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 set llm.model opus-4-6 Set a config value
ocr llm test Test LLM connectivity
ocr version Show version info
Use "ocr review -h" for more information about review.
Use "ocr config" for more information about config.
Use "ocr llm" for more information about LLM utilities.`)
}

View file

@ -0,0 +1,191 @@
package main
import (
"encoding/json"
"fmt"
"os"
"strings"
"github.com/open-code-review/open-code-review/internal/agent"
"github.com/open-code-review/open-code-review/internal/model"
"github.com/open-code-review/open-code-review/internal/suggestdiff"
)
func outputText(comments []model.LlmComment) {
if len(comments) == 0 {
fmt.Println("No comments generated. Looks good to me.")
return
}
for _, c := range comments {
renderComment(c)
}
}
func outputTextWithWarnings(comments []model.LlmComment, warnings []agent.AgentWarning) {
outputText(comments)
if len(warnings) > 0 {
for _, w := range warnings {
fmt.Fprintf(os.Stderr, "[ocr] WARNING [%s] %s: %s\n", w.Type, w.File, w.Message)
}
}
}
func renderComment(comment model.LlmComment) {
lines := buildDiffLines(comment)
if len(lines) == 0 && comment.Content == "" {
return
}
fmt.Printf("\n\033[2m─── %s:%d-%d ───\033[0m\n", comment.Path, comment.StartLine, comment.EndLine)
if comment.Content != "" {
for _, ln := range wrapByRunes(comment.Content, 100) {
fmt.Printf("%s\n", ln)
}
fmt.Println()
}
if len(lines) > 0 {
for _, dl := range lines {
switch dl.Type {
case suggestdiff.DiffAdded:
printDiffLine("+", dl.Content, "\033[92m", "\033[48;2;0;60;0m")
case suggestdiff.DiffDeleted:
printDiffLine("-", dl.Content, "\033[91m", "\033[48;2;70;0;0m")
case suggestdiff.DiffContext:
printDiffLine(" ", dl.Content, "\033[2m", "\033[48;2;38;38;38m")
}
}
}
fmt.Println()
}
// printDiffLine renders a single diff line with colored prefix and background on content.
func printDiffLine(prefix, content, fgColor, bgColor string) {
fmt.Printf("%s%s%s %s%s\033[0m\n", fgColor+bgColor, prefix, "\033[0m"+bgColor, content, "\033[0m")
}
// wrapByRunes splits text into lines that fit within maxWidth **rune** columns.
// Respects existing newlines and wraps at word boundaries.
func wrapByRunes(text string, maxW int) []string {
if text == "" {
return nil
}
var result []string
for _, para := range strings.Split(text, "\n") {
result = append(result, wrapSingleRuneLine(para, maxW)...)
}
return result
}
// wrapSingleRuneLine breaks one paragraph (no newlines) into rune-width-constrained lines.
func wrapSingleRuneLine(line string, maxW int) []string {
runes := []rune(line)
if visibleRunesLen(runes) <= maxW {
return []string{line}
}
var result []string
for len(runes) > 0 {
cut := runeWrapCut(runes, maxW)
result = append(result, string(runes[:cut]))
runes = runes[cut:]
// trim leading spaces of next segment
for len(runes) > 0 && runes[0] == ' ' {
runes = runes[1:]
}
}
return result
}
// runeWrapCut returns a rune index suitable for breaking the line at ~maxW display width.
func runeWrapCut(runes []rune, maxW int) int {
if visibleRunesLen(runes) <= maxW {
return len(runes)
}
best := maxW
if best >= len(runes) {
return len(runes)
}
for i := best; i > 0; i-- {
if runes[i] == ' ' || runes[i] == '\t' {
return i
}
}
return best
}
func visibleRunesLen(runes []rune) int {
n := 0
for _, r := range runes {
if r >= 32 && r != 127 {
n++
}
}
return n
}
func splitToLines(s string) []string {
lines := strings.Split(strings.ReplaceAll(s, "\r\n", "\n"), "\n")
if len(lines) > 0 && lines[len(lines)-1] == "" {
lines = lines[:len(lines)-1]
}
return lines
}
func buildDiffLines(comment model.LlmComment) []suggestdiff.DiffLine {
if comment.SuggestionCode == "" || comment.ExistingCode == "" {
return nil
}
oldLines := splitToLines(comment.ExistingCode)
newLines := splitToLines(comment.SuggestionCode)
return suggestdiff.ComputeLineDiff(oldLines, newLines)
}
type jsonOutput struct {
Status string `json:"status"`
Message string `json:"message,omitempty"`
Comments []model.LlmComment `json:"comments"`
Warnings []agent.AgentWarning `json:"warnings,omitempty"`
}
func outputJSON(comments []model.LlmComment) error {
out := jsonOutput{
Status: "success",
Comments: comments,
}
if len(comments) == 0 {
out.Message = "No comments generated. Looks good to me."
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(out)
}
func outputJSONWithWarnings(comments []model.LlmComment, warnings []agent.AgentWarning) error {
out := jsonOutput{
Status: "success",
Comments: comments,
}
if len(comments) == 0 {
out.Message = "No comments generated. Looks good to me."
}
if len(warnings) > 0 {
out.Warnings = warnings
out.Status = "completed_with_warnings"
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(out)
}
func outputJSONNoFiles() error {
out := jsonOutput{
Status: "skipped",
Message: "No supported files changed.",
Comments: []model.LlmComment{},
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(out)
}

View file

@ -0,0 +1,219 @@
package main
import (
"context"
"fmt"
"os"
"path/filepath"
"time"
"github.com/open-code-review/open-code-review/internal/agent"
"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/config/toolsconfig"
"github.com/open-code-review/open-code-review/internal/diff"
"github.com/open-code-review/open-code-review/internal/llm"
"github.com/open-code-review/open-code-review/internal/stdout"
"github.com/open-code-review/open-code-review/internal/telemetry"
"github.com/open-code-review/open-code-review/internal/tool"
)
func runReview(args []string) error {
opts, err := parseReviewFlags(args)
if err != nil {
return fmt.Errorf("parse flags: %w", err)
}
if opts.showHelp {
printReviewUsage()
return nil
}
if err := requireGitRepo(opts.repoDir); err != nil {
return err
}
tpl, err := template.LoadDefault()
if err != nil {
return fmt.Errorf("load default template: %w", err)
}
if err := tpl.Validate(); err != nil {
return fmt.Errorf("invalid config: %w", err)
}
sysRule, err := rules.LoadDefault()
if err != nil {
return fmt.Errorf("load default system rule: %w", err)
}
if opts.rulePath != "" {
sysRule, err = loadSystemRule(opts.rulePath)
if err != nil {
return fmt.Errorf("load system rule: %w", err)
}
}
toolEntries, err := toolsconfig.Load(opts.toolConfigPath)
if err != nil {
return fmt.Errorf("load tools: %w", err)
}
planToolDefs := agent.BuildToolDefs(toolEntries, true)
mainToolDefs := agent.BuildToolDefs(toolEntries, false)
repoDir, err := resolveRepoDir(opts.repoDir)
if err != nil {
return fmt.Errorf("resolve repo: %w", err)
}
appCfg, err := LoadAppConfig(defaultConfigPath())
if err != nil {
return fmt.Errorf("load app config: %w", err)
}
if appCfg != nil {
tpl.ApplyLanguage(appCfg.Language)
}
ep, err := llm.ResolveEndpoint(defaultConfigPath())
if err != nil {
return fmt.Errorf("resolve LLM endpoint: %w", err)
}
llmClient := llm.NewLLMClient(ep)
model := ep.Model
collector := tool.NewCommentCollector()
mode := tool.ParseReviewMode(opts.from, opts.to, opts.commit)
ref, _ := mode.RefValue(opts.to, opts.commit)
diffMap := make(map[string]string)
fileReader := &tool.FileReader{
RepoDir: repoDir,
Mode: mode,
Ref: ref,
}
tools := buildToolRegistry(collector, fileReader, diffMap)
ag := agent.New(agent.Args{
RepoDir: repoDir,
From: opts.from,
To: opts.to,
Commit: opts.commit,
DiffMap: diffMap,
Template: *tpl,
SystemRule: sysRule,
LLMClient: llmClient,
Tools: tools,
PlanToolDefs: planToolDefs,
MainToolDefs: mainToolDefs,
CommentCollector: collector,
CommentWorkerPool: agent.NewCommentWorkerPool(opts.concurrency),
MaxConcurrency: opts.concurrency,
ConcurrentTaskTimeout: opts.perFileTimeout,
Model: model,
Background: opts.background,
})
// Silence progress output during execution; restore before Summary in agent mode.
var unsilence func()
if opts.outputFormat == "json" || opts.audience == "agent" {
unsilence = stdout.Quiet()
defer func() {
if unsilence != nil {
unsilence()
}
}()
}
ctx, span := telemetry.StartSpan(context.Background(), "review.run")
defer span.End()
startTime := time.Now()
comments, err := ag.Run(ctx)
if err != nil {
telemetry.SetAttr(span, "error", err.Error())
return fmt.Errorf("review failed: %w", err)
}
// Resolve line numbers by matching existing_code against diff hunks.
comments = diff.ResolveLineNumbers(comments, ag.Diffs())
// Record summary metrics (files_reviewed is refined by agent.Run).
duration := time.Since(startTime)
telemetry.RecordReviewDuration(ctx, duration)
if len(comments) > 0 {
telemetry.RecordCommentsGenerated(ctx, int64(len(comments)))
}
// If no files were reviewed (e.g. workspace has no changes), inform the caller in JSON mode.
if opts.outputFormat == "json" && len(comments) == 0 && ag.FilesReviewed() == 0 {
return outputJSONNoFiles()
}
// In agent mode, restore stdout so Summary reaches the terminal.
if opts.audience == "agent" && unsilence != nil {
unsilence()
unsilence = nil
}
telemetry.PrintTraceSummary(ag.FilesReviewed(), int64(len(comments)), ag.TotalInputTokens(), ag.TotalOutputTokens(), ag.TotalTokensUsed(), duration)
if opts.outputFormat == "json" {
return outputJSONWithWarnings(comments, ag.Warnings())
}
if opts.audience == "agent" {
outputTextWithWarnings(comments, ag.Warnings())
return nil
}
outputTextWithWarnings(comments, ag.Warnings())
return nil
}
func loadSystemRule(path string) (*rules.SystemRule, error) {
return rules.LoadFile(path)
}
func resolveRepoDir(input string) (string, error) {
if input == "" {
var err error
input, err = os.Getwd()
if err != nil {
return "", fmt.Errorf("get working directory: %w", err)
}
}
absPath, err := filepath.Abs(input)
if err != nil {
return "", fmt.Errorf("resolve absolute path: %w", err)
}
out, err := runGitCmd(absPath, "rev-parse", "--git-dir")
if err != nil || len(out) == 0 {
return "", fmt.Errorf("%s is not a git repository", absPath)
}
return absPath, nil
}
// requireGitRepo validates that the given directory is part of a git repository.
func requireGitRepo(dir string) error {
repoDir, err := filepath.Abs(dir)
if err != nil {
return fmt.Errorf("resolve path: %w", err)
}
out, err := runGitCmd(repoDir, "rev-parse", "--git-dir")
if err != nil || len(out) == 0 {
return fmt.Errorf("%s is not a git repository, code review requires a valid git repository", repoDir)
}
return nil
}
func buildToolRegistry(collector *tool.CommentCollector, fr *tool.FileReader, diffMap map[string]string) tool.Registry {
reg := tool.NewRegistry()
reg.Register(tool.NewFileRead(fr))
reg.Register(tool.NewFileFind(fr))
reg.Register(tool.NewFileReadDiff())
reg.Register(tool.NewCodeSearch(fr))
reg.Register(&tool.CodeCommentProvider{Collector: collector})
// Wire up FileReadDiffProvider with shared diffMap pointer so Agent's loadDiffs populates it.
if p, ok := reg[tool.FileReadDiff.Name()].(*tool.FileReadDiffProvider); ok {
p.DiffMap = diffMap
}
return reg
}

View file

@ -0,0 +1,24 @@
package main
import (
"fmt"
"runtime"
)
// Set via ldflags: -X main.Version=x.y.z -X main.GitCommit=abc123 -X main.BuildDate=2026-01-01T00:00:00Z
var (
Version = "dev"
GitCommit = ""
BuildDate = ""
)
func printVersion() {
fmt.Printf("open-code-review %s", Version)
if GitCommit != "" {
fmt.Printf(" (%s)", GitCommit)
}
fmt.Printf(" %s/%s\n", runtime.GOOS, runtime.GOARCH)
if BuildDate != "" {
fmt.Printf("built at: %s\n", BuildDate)
}
}

View file

@ -0,0 +1,55 @@
package main
import (
"fmt"
"github.com/open-code-review/open-code-review/internal/viewer"
)
type viewerOptions struct {
addr string
showHelp bool
}
func parseViewerFlags(args []string) (viewerOptions, error) {
a := newOcrFlagSet("ocr viewer")
opts := viewerOptions{}
a.StringVar(&opts.addr, "addr", "localhost:5483", "listen address")
if err := a.Parse(args); err != nil {
return opts, fmt.Errorf("parse flags: %w", err)
}
opts.showHelp = a.showHelp
return opts, nil
}
func runViewer(args []string) error {
opts, err := parseViewerFlags(args)
if err != nil {
return err
}
if opts.showHelp {
printViewerUsage()
return nil
}
fmt.Printf("Open Code Review Viewer starting on http://%s\n", opts.addr)
return viewer.StartServer(opts.addr)
}
func printViewerUsage() {
fmt.Println(`Session history WebUI viewer.
Usage:
ocr viewer [flags]
ocr v [flags] (alias)
Flags:
--addr <address> listen address (default: localhost:5483)
Examples:
ocr viewer # start on default port
ocr viewer --addr :3000 # bind to all interfaces on port 3000`)
}

187
cmd/testdiff/main.go Normal file
View file

@ -0,0 +1,187 @@
package main
// go build ./cmd/testdiff/ -o /tmp/testdiff && /tmp/testdiff ...
// Or just: go run ./cmd/testdiff/ ...
import (
"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()
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)
case args.from != "" && args.to != "":
return diff.NewProvider(repoDir, args.from, args.to)
default:
return diff.NewWorkspaceProvider(repoDir)
}
}
// ---- 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()
}
}
}

37
go.mod Normal file
View file

@ -0,0 +1,37 @@
module github.com/open-code-review/open-code-review
go 1.25.0
require (
github.com/bmatcuk/doublestar/v4 v4.10.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
)
require (
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dlclark/regexp2 v1.10.0 // indirect
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
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/sys v0.42.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
google.golang.org/protobuf v1.36.11 // indirect
)

73
go.sum Normal file
View file

@ -0,0 +1,73 @@
github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
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/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
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/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/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/pkoukk/tiktoken-go v0.1.8 h1:85ENo+3FpWgAACBaEUVp+lctuTcYUO7BtmfhlN/QTRo=
github.com/pkoukk/tiktoken-go v0.1.8/go.mod h1:9NiV+i9mJKGj1rYOT+njbv+ZwA/zJxYdewGl6qVatpg=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
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/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=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
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/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.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=
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/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

1177
internal/agent/agent.go Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,34 @@
package allowedext
import (
_ "embed"
"encoding/json"
"strings"
"sync"
)
//go:embed supported_file_types.json
var defaultData []byte
var (
supported map[string]bool
initOnce sync.Once
)
func initMap() {
var exts []string
if err := json.Unmarshal(defaultData, &exts); err != nil {
panic("allowedext: failed to parse supported_file_types.json: " + err.Error())
}
supported = make(map[string]bool, len(exts))
for _, e := range exts {
supported[strings.ToLower(e)] = true
}
}
// IsAllowedExt returns true when the given file extension is in the supported types list.
// The check is case-insensitive.
func IsAllowedExt(ext string) bool {
initOnce.Do(initMap)
return supported[strings.ToLower(ext)]
}

View file

@ -0,0 +1,67 @@
[
".java",
".kt",
".kts",
".scala",
".groovy",
".py",
".pyi",
".js",
".jsx",
".ts",
".tsx",
".mjs",
".cjs",
".c",
".h",
".cpp",
".cc",
".cxx",
".hpp",
".hxx",
".cs",
".vb",
".fs",
".go",
".rs",
".rb",
".rake",
".gemspec",
".php",
".swift",
".m",
".mm",
".sh",
".bash",
".zsh",
".fish",
".ps1",
".sql",
".css",
".scss",
".sass",
".less",
".html",
".htm",
".vue",
".svelte",
".xml",
".yaml",
".yml",
".json",
".toml",
".ini",
".env",
".gradle",
".cmake",
".r",
".lua",
".pl",
".pm",
".ex",
".exs",
".erl",
".hrl",
".dart",
".tf"
]

View file

@ -0,0 +1,142 @@
// Package rules loads system review rules and matches file paths against glob patterns.
package rules
import (
_ "embed"
"encoding/json"
"fmt"
"os"
"strings"
"github.com/bmatcuk/doublestar/v4"
)
// PathRule is a single pattern→rule entry preserving declaration order.
type PathRule struct {
Pattern string
Rule string
}
// SystemRule holds review rules loaded from an external JSON config.
type SystemRule struct {
DefaultRule string `json:"default_rule"`
PathRules []PathRule // ordered; first match wins
}
// UnmarshalJSON preserves the key order from JSON's path_rule_map object.
func (r *SystemRule) UnmarshalJSON(data []byte) error {
// Decode default_rule normally.
var wrapper struct {
DefaultRule string `json:"default_rule"`
}
if err := json.Unmarshal(data, &wrapper); err != nil {
return err
}
r.DefaultRule = wrapper.DefaultRule
// Use json.Decoder with UseNumber to preserve order of path_rule_map keys.
var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
mapData, ok := raw["path_rule_map"]
if !ok || len(mapData) == 0 || string(mapData) == "null" {
return nil
}
// Parse ordered keys using a streaming decoder.
dec := json.NewDecoder(strings.NewReader(string(mapData)))
// Read opening '{'
t, err := dec.Token()
if err != nil {
return fmt.Errorf("expected '{' in path_rule_map: %w", err)
}
if t != json.Delim('{') {
return fmt.Errorf("expected '{' in path_rule_map, got %v", t)
}
for dec.More() {
// Read key
keyTok, err := dec.Token()
if err != nil {
return fmt.Errorf("read path_rule_map key: %w", err)
}
key, ok := keyTok.(string)
if !ok {
return fmt.Errorf("expected string key in path_rule_map, got %T", keyTok)
}
// Read value
var value string
if err := dec.Decode(&value); err != nil {
return fmt.Errorf("read path_rule_map value for %q: %w", key, err)
}
r.PathRules = append(r.PathRules, PathRule{Pattern: key, Rule: value})
}
return nil
}
//go:embed system_rules.json
var defaultSystemRules []byte
// LoadDefault parses the embedded system_rules.json.
func LoadDefault() (*SystemRule, error) {
var rule SystemRule
if err := json.Unmarshal(defaultSystemRules, &rule); err != nil {
return nil, fmt.Errorf("unmarshal default system rules: %w", err)
}
return &rule, nil
}
// LoadFile parses a system_rules.json file from disk.
func LoadFile(path string) (*SystemRule, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read rule file %s: %w", path, err)
}
var rule SystemRule
if err := json.Unmarshal(data, &rule); err != nil {
return nil, fmt.Errorf("unmarshal rule file: %w", err)
}
return &rule, nil
}
// Resolve returns the rule text for a given file path.
// Patterns with brace expansion like "*.{go,py}" are expanded into "*.go", "*.py".
// The first match wins; if none match, it falls back to DefaultRule.
// Supports full glob syntax including ** for recursive directory matching.
func (r *SystemRule) Resolve(path string) string {
for _, pr := range r.PathRules {
expanded := expandBraces(pr.Pattern)
for _, p := range expanded {
if matched, _ := doublestar.Match(p, path); matched {
return pr.Rule
}
}
}
return r.DefaultRule
}
// expandBraces turns "{a,b,c}" style patterns into individual strings.
// e.g. "*.go.{java,kotlin}" → ["*.go.java", "*.go.kotlin"].
// If no braces exist, returns the original pattern unchanged.
func expandBraces(s string) []string {
openIdx := strings.IndexByte(s, '{')
if openIdx < 0 {
return []string{s}
}
closeIdx := strings.IndexByte(s[openIdx:], '}')
if closeIdx < 0 {
return []string{s}
}
closeIdx += openIdx
prefix := s[:openIdx]
suffix := s[closeIdx+1:]
options := strings.Split(s[openIdx+1:closeIdx], ",")
results := make([]string, 0, len(options))
for _, opt := range options {
results = append(results, prefix+opt+suffix)
}
return results
}

View file

@ -0,0 +1,20 @@
{
"default_rule": "新增的代码存在明确的错别字或拼写错误。",
"path_rule_map": {
"**/*.properties": "#### 明确的错别字或拼写错误识别\n- 键名中的拼写错误,特别是常用配置项的标准拼写\n\n#### 配置错误识别\n- 当前文件中存在可见范围内重复的键定义导致配置覆盖问题\n- 键值对格式错误(缺少等号、多余空格等)\n- 特殊字符未正确转义如路径中的反斜杠、Unicode字符等\n\n#### 严重的安全问题\n- 敏感信息密码、API密钥、数据库连接串等以明文形式存储",
"**/*mapper*.xml": "#### 明确拼写错误识别\n- SQL关键字的拼写错误\n- mapper接口方法名与XML中id不匹配的拼写错误\n- 动态SQL标签中的属性名拼写错误如 `test` 条件中的字段名)\n\n#### SQL逻辑错误识别\n- **条件判断错误**WHERE条件中的逻辑运算符误用AND/OR混淆\n- **JOIN条件错误**:关联条件使用错误的字段或缺少必要的关联条件\n- **动态SQL逻辑错误**`<if test=\"\">` 条件判断错误,如空值判断、类型判断错误\n- **SQL语法错误**:明显的语法错误,如缺少逗号、括号不匹配\n\n#### 严重的性能问题\n- **全表扫描风险**WHERE条件缺失\n- **大数据量查询缺少分页**可能返回大量数据但未使用LIMIT或分页机制\n- **重复子查询**同一个子查询在多处使用建议提取为临时表或优化SQL结构\n\n#### SQL注入安全风险识别\n\n**应该报告的真实安全风险:**\n- **直接字符串拼接**:使用`${}`拼接用户输入的参数到SQL语句中存在SQL注入风险\n- **LIKE查询拼接**直接拼接LIKE条件而非使用安全的参数绑定方式\n\n**不应该报告的情况:**\n- **正确使用 #{} 参数绑定**MyBatis会自动进行参数转义安全性有保障\n- **静态SQL语句**不涉及动态参数的固定SQL语句\n\n**审查原则:**\n- 重点关注可能导致数据错误、性能问题或安全风险的严重问题\n- 考虑SQL语句的实际执行效率和数据库性能影响\n- 优先识别可能导致生产环境故障的关键问题\n- 上下文不明时保持谨慎当无法确定SQL执行的完整上下文时应选择忽略而不是误报\n- 需要充分证据:只有在有明确证据表明存在问题时才进行报告\n- 宁可漏报不要误报:保持高精度的问题识别,避免因过多误报而干扰对真实问题的关注",
"**/*dao*.xml": "#### 明确拼写错误识别\n- SQL关键字的拼写错误\n- mapper接口方法名与XML中id不匹配的拼写错误\n- 动态SQL标签中的属性名拼写错误如 `test` 条件中的字段名)\n\n#### SQL逻辑错误识别\n- **条件判断错误**WHERE条件中的逻辑运算符误用AND/OR混淆\n- **JOIN条件错误**:关联条件使用错误的字段或缺少必要的关联条件\n- **动态SQL逻辑错误**`<if test=\"\">` 条件判断错误,如空值判断、类型判断错误\n- **SQL语法错误**:明显的语法错误,如缺少逗号、括号不匹配\n\n#### 严重的性能问题\n- **全表扫描风险**WHERE条件缺失\n- **大数据量查询缺少分页**可能返回大量数据但未使用LIMIT或分页机制\n- **重复子查询**同一个子查询在多处使用建议提取为临时表或优化SQL结构\n\n#### SQL注入安全风险识别\n\n**应该报告的真实安全风险:**\n- **直接字符串拼接**:使用`${}`拼接用户输入的参数到SQL语句中存在SQL注入风险\n- **LIKE查询拼接**直接拼接LIKE条件而非使用安全的参数绑定方式\n\n**不应该报告的情况:**\n- **正确使用 #{} 参数绑定**MyBatis会自动进行参数转义安全性有保障\n- **静态SQL语句**不涉及动态参数的固定SQL语句\n\n**审查原则:**\n- 重点关注可能导致数据错误、性能问题或安全风险的严重问题\n- 考虑SQL语句的实际执行效率和数据库性能影响\n- 优先识别可能导致生产环境故障的关键问题\n- 上下文不明时保持谨慎当无法确定SQL执行的完整上下文时应选择忽略而不是误报\n- 需要充分证据:只有在有明确证据表明存在问题时才进行报告\n- 宁可漏报不要误报:保持高精度的问题识别,避免因过多误报而干扰对真实问题的关注",
"**/pom.xml": "新增的代码中version不允许包含snapshot字段只允许引入其他的任何版本。注意代码中没有声明版本号是因为版本号在父pom中管理当版本号并非新增的代码行时请忽略。",
"**/build.gradle": "避免引入snapshot版本的依赖发布线上环境建议引入具体的版本号。注意版本号并非新增的代码行时请忽略。",
"**/package.json": "- 避免引入版本为 latest 或 *,建议引入具体的版本号。注意:版本号并非新增的代码行时请忽略\n- 依赖冲突或重复声明:同一依赖同时存在于 dependencies 和 devDependencies\n- 必需工具依赖未声明scripts 里出现如 eslint、jest、prettier 等工具名称但未在 devDependencies 中体现",
"**/*.json": "检查JSON文件中的json-key是否存在拼写错误忽略json-value的内容。",
"**/*.{yaml,yml}": "检查YAML文件中的yaml-key是否存在拼写错误忽略yaml-value的内容。",
"**/*.java": "#### 明确的错别字或拼写错误\n- 定义处的变量名、方法名、类名中的拼写错误(通过`code.search`搜索同类命名规范确认)\n- 日志或异常信息中的字符串包含影响阅读理解的拼写错误\n- 请不要报告引用处的拼写错误,因为这些通常是由定义决定的\n\n#### 逻辑错误识别\n- if 条件逻辑错误(使用`file.read`查看上下文确认预期逻辑)\n- 边界条件处理错误(特别关注索引、数组长度判断等)\n- 布尔逻辑运算符误用(优先级、短路评估问题)\n- 明显的死循环或递归中无终止条件\n- 在不应该退出的地方使用 return/break/continue\n- switch case 缺少 break 语句导致的意外穿透\n- 有意的穿透但缺少注释说明\n- 可能导致NPE的代码模式通过`file.read`和`code.search`检查数据来源调用链确认风险)\n- 逻辑表达式缺少括号导致执行顺序可能与预期不一致\n\n#### 严重性能隐患\n- 在循环内部执行数据库查询(通过`code.search`确认方法调用是否涉及数据库操作)\n- N+1查询问题应建议批量查询优化\n- 未分页的大数据集处理(可通过`file.read`了解数据规模和处理上下文)\n- 嵌套循环中的低效算法实现O(n²)及以上复杂度但有更优解法)\n\n#### 线程安全问题识别\n仅在以下情况下才指出线程安全问题\n- **竞态条件**:存在\"检查-然后-执行\"模式,中间状态可能被其他线程改变\n- **复合操作原子性缺失**:多步操作需要保证原子性但未使用同步机制\n- **不安全的懒加载**:单例模式或缓存实现中的双重检查锁定缺陷\n- **线程不安全集合的并发写操作**多线程环境下对ArrayList、HashMap等非线程安全集合的修改\n\n以下情况下不应报告\n- **方法内部的局部变量**:这些天然线程安全,每个线程都有独立副本\n- **单线程上下文的使用**:未发现明显的多线程调用(可通过`code.search`搜索相关调用上下文确认)\n- **只读操作**:即使使用非线程安全的数据结构,如果只进行读取操作\n- **不可变对象**final字段且指向不可变对象的引用\n- **已有适当同步机制**代码已使用synchronized、Lock、原子类等正确同步\n- **设计为单线程使用的组件**如Builder模式的构建过程、临时数据传输对象等",
"**/*.{ts,js,tsx,jsx}": "#### 明确的错别字或拼写错误识别\n- 变量名、函数名、组件名、Props 属性名中的拼写错误\n- 日志或错误信息中的字符串包含影响阅读理解的拼写错误\n\n#### 代码质量检查\n- **重复代码**:检查是否有可以抽取的公共逻辑\n- **代码注释**:复杂业务逻辑是否有清晰的注释说明(避免显而易见的代码注释)\n- **硬编码检查**:禁止使用业务相关的硬编码字符串,特别是 URL 路径、业务数字;简单 UI 文本可适当放宽\n- **变量声明**:严格禁止使用 var必须使用 let 或 const\n- **相等比较**:禁止使用==和!=,必须使用严格相等===和!==\n- **TypeScript 类型**:避免使用 any 类型,如需使用请注释说明原因\n- **空值判断**:在取值或解构赋值时进行空值判断,避免空指针异常\n- **三元表达式**:不允许嵌套三元表达式\n \n#### React 最佳实践\n- **Hooks 使用**:是否遵循 Hooks 规则(只在顶层调用、只在 React 函数中调用)\n- **状态管理**:状态是否放在合适的层级,避免不必要的状态提升\n- **副作用处理**useEffect 是否正确处理依赖和清理函数\n- **性能优化**:是否合理使用 React.memo、useMemo、useCallback基于性能分析避免过度优化\n- **render 副作用**:严格禁止在 React 组件的 render 方法中进行副作用操作(如 API 调用、DOM 操作\n- **内联样式**:避免使用内联 style 样式,动态样式除外\n- **内部组件**:禁止在组件内部声明一个新组件,如有需要请使用方法的方式来渲染,如`renderItem` ,而不是`<Item/>`\n \n#### 异步处理规范\n- **错误处理**:异步函数必须包含适当的错误处理,并提供用户友好的错误提示\n- **async/await 优先**:相较于 Promise 更推荐使用 async/await禁止使用回调地狱\n- **循环异步**:区分独立异步操作(用 Promise.all 并行)和依赖性异步操作(用串行),优先考虑 Promise.all 来提升性能\n \n#### 代码安全性检查\n- **XSS 防护**:是否对用户输入进行适当的转义\n- **innerHTML 安全**:禁止使用 innerHTML 直接插入用户输入内容,必须使用 textContent 或进行 XSS 防护\n- **代码注入防护**:严格禁止使用 eval()、Function()构造函数和 setTimeout/setInterval 的字符串参数形式\n- **危险方法**:禁止使用 document.write(),会导致页面重绘和安全问题\n- **敏感信息**:是否暴露了 API 密钥或敏感数据\n- **原型链安全**:禁止修改原生对象的原型链(如 Array.prototype、Object.prototype",
"**/*.{kt}": "### 1. 空安全Null Safety\n- **问题**:未正确处理可空类型,导致潜在的`NullPointerException`。\n- **检查点**\n - 避免滥用`!!`(非空断言),优先使用安全调用`?.`或Elvis操作符`?:`。\n - 数据类或API返回的可空属性是否合理处理。\n- **坏例子**\n ```kotlin\n val length: Int = text!!.length // 风险text可能为null\n ```\n- **改进**\n ```kotlin\n val length: Int = text?.length ?: 0 // 安全处理\n ```\n\n### 2. 函数与表达式简洁性\n- **问题**冗余代码破坏Kotlin的简洁性。\n- **检查点**\n - 单表达式函数是否用`=`简化(如`fun sum(a: Int, b: Int) = a + b`)。\n - 是否用`when`替代复杂`if-else`链。\n - 避免不必要的`return`如Lambda中直接使用表达式结果。\n- **坏例子**\n ```kotlin\n fun getGrade(score: Int): String {\n if (score >= 90) return \"A\"\n else if (score >= 80) return \"B\"\n else return \"C\"\n }\n ```\n- **改进**\n ```kotlin\n fun getGrade(score: Int) = when {\n score >= 90 -> \"A\"\n score >= 80 -> \"B\"\n else -> \"C\"\n }\n ```\n\n### 3. 集合操作优化\n- **问题**:低效的集合操作导致性能问题。\n- **检查点**\n - 优先使用`Sequence`处理大型集合(惰性求值减少中间对象)。\n - 避免重复操作(如多次`filter`合并为一次)。\n - 使用`groupBy`、`associate`等替代手动迭代。\n- **坏例子**\n ```kotlin\n val evenSquares = listOf(1, 2, 3).map { it * it }.filter { it % 2 == 0 } // 产生中间集合\n ```\n- **改进**\n ```kotlin\n val evenSquares = listOf(1, 2, 3).asSequence()\n .map { it * it }\n .filter { it % 2 == 0 }\n .toList() // 惰性计算\n ```\n\n---\n\n### 4. 协程Coroutines正确使用\n- **问题**:协程泄漏或异常处理不当。\n- **检查点**\n - 使用结构化并发(`coroutineScope`或`supervisorScope`管理生命周期)。\n - 避免`GlobalScope`(易导致资源泄漏)。\n - 异常处理:用`try/catch`包裹`withContext`或`async`。\n- **坏例子**\n ```kotlin\n GlobalScope.launch { // 脱离作用域,可能泄漏\n fetchData()\n }\n ```\n- **改进**\n ```kotlin\n viewModelScope.launch { // 结构化并发\n try {\n withContext(Dispatchers.IO) { fetchData() }\n } catch (e: Exception) { /* 处理异常 */ }\n }\n ```\n\n### 5. 类与对象设计\n- **问题**未合理使用Kotlin特性导致冗余。\n- **检查点**\n - **数据类**:纯数据对象用`data class`(自动生成`equals`/`hashCode`)。\n - **密封类/接口**:受限类型层次用`sealed class`(完善`when`分支检查)。\n - **委托**:属性委托(如`by lazy`)或类委托(`by`实现装饰器模式)。\n- **坏例子**\n ```kotlin\n class User(val name: String) {\n // 手动实现toString()/equals()...\n }\n ```\n- **改进**\n ```kotlin\n data class User(val name: String) // 自动生成标准方法\n ```\n\n### 6. 资源管理与作用域函数\n- **问题**:资源未释放或作用域函数滥用。\n- **检查点**\n - 文件/网络资源用`use`自动关闭(如`FileInputStream().use { ... }`)。\n - 作用域函数(`let`、`apply`等)需保持可读性,避免嵌套过深。\n- **坏例子**\n ```kotlin\n val file = File(\"path\")\n val reader = BufferedReader(FileReader(file))\n // 忘记调用reader.close()\n ```\n- **改进**\n ```kotlin\n File(\"path\").inputStream().use { stream ->\n // 自动关闭资源\n }\n ```\n\n### 7. 性能陷阱\n- **问题**:隐藏的性能开销。\n- **检查点**\n - 内联函数:高阶函数用`inline`减少Lambda开销但避免对大函数内联。\n - 常量:编译时常量用`const val`(取代`val`)。\n - 避免在循环中创建对象(如正则表达式`Regex`)。\n\n### 8. 互操作性Java交互\n- **问题**Java调用Kotlin代码时的兼容性问题。\n- **检查点**\n - 暴露API时用`@JvmStatic`、`@JvmOverloads`优化Java调用。\n - 空安全注解:`@Nullable`/`@NonNull`辅助Java识别可空性。\n\n### 9. 其他关键点\n- **不可变性**:优先使用`val`而非`var`。\n- **字符串处理**:用字符串模板(`\"Value: $value\"`)替代拼接。",
"**/*.{go,py,ets,lua,dart,swift,groovy}": "1.新增的代码存在严重的逻辑问题。比如if中的条件写反了bool类型变量赋值了相反的值错误的使用了依赖库的接口或者逻辑表达式缺少括号导致执行顺序跟预期不一致等。\n2.新增的代码存在明确的错别字或拼写错误。",
"**/*.{h,m,mm}": "1.新增的代码存在严重的逻辑问题。比如if中的条件写反了bool类型变量赋值了相反的值错误的使用了依赖库的接口或者逻辑表达式缺少括号导致执行顺序跟预期不一致等。\n2.新增的代码存在明确的错别字或拼写错误。\n3.新增的代码中存在确定的数组越界问题,由于上下文不足无法确认数组会越界时请忽略。",
"**/*.{cpp,cc,hpp}": "#### 明确的错别字或拼写错误识别\n- 定义处的变量名、常量名、函数名中包含拼写错误,请不要报告调用处的拼写错误\n- 日志或异常信息中的字符串包含影响阅读理解的拼写错误\n\n#### 智能指针使用\n**检查要点:**\n- 优先使用`std::unique_ptr`管理独占资源\n- 使用`std::shared_ptr`管理共享资源\n- 避免使用裸指针管理动态内存\n- 正确使用`std::weak_ptr`打破循环引用\n\n**示例:**\n```cpp\n// ❌ 避免使用裸指针\nWidget* widget = new Widget();\ndelete widget; // 容易忘记或异常时不执行\n\n// ✅ 使用智能指针\nauto widget = std::make_unique<Widget>();\n// 自动析构,异常安全\n```\n\n#### RAII原则\n**检查要点:**\n- 资源在构造函数中获取\n- 资源在析构函数中释放\n- 使用栈对象管理资源\n- 避免手动资源管理\n\n#### STL容器和算法\n**检查要点:**\n- 优先使用STL容器而非数组\n- 使用STL算法而非手写循环\n- 选择合适的容器类型\n- 了解容器的性能特征\n\n**示例:**\n```cpp\n// ❌ 手写循环\nstd::vector<int> vec = {1, 2, 3, 4, 5};\nfor (int i = 0; i < vec.size(); ++i) {\n vec[i] *= 2;\n}\n\n// ✅ 使用算法\nstd::transform(vec.begin(), vec.end(), vec.begin(),\n [](int x) { return x * 2; });\n```\n\n#### auto关键字\n**检查要点:**\n- 类型复杂时使用auto\n- 避免在简单类型上滥用auto\n- 使用auto&和const auto&避免拷贝\n\n#### 异常处理完整性\n**检查要点:**\n- 捕获具体异常类型而非...\n- 异常处理不要忽略错误\n\n#### const正确性\n**检查要点:**\n- 成员函数const修饰\n- 参数const引用传递\n- 指针和引用的const位置\n- const成员变量合理使用",
"**/*.c": "#### 明确的错别字或拼写错误识别\n- 定义处的变量名、常量名、函数名中包含拼写错误,请不要报告调用处的拼写错误\n- 日志或异常信息中的字符串包含影响阅读理解的拼写错误\n\n#### malloc/free配对使用\n**检查要点:**\n- 每个`malloc()`都有对应的`free()`\n- 避免重复释放同一内存块\n- 释放后将指针设为NULL\n\n**示例:**\n```c\n// ❌ 错误示例\nchar* buffer = malloc(1024);\n// 使用buffer...\n// 忘记释放内存\n\n// ✅ 正确示例\nchar* buffer = malloc(1024);\nif (buffer != NULL) {\n // 使用buffer...\n free(buffer);\n buffer = NULL;\n}\n```\n\n#### 内存泄漏检查\n**检查要点:**\n- 所有分配的内存在函数退出前都被释放\n- 错误处理路径中也要释放内存\n- 使用工具如Valgrind进行检测\n\n#### 缓冲区溢出防护\n**检查要点:**\n- 数组访问前检查边界\n- 字符串操作使用安全函数\n- 循环边界条件正确\n\n**示例:**\n```c\n// ❌ 危险操作\nchar buffer[100];\nstrcpy(buffer, user_input); // 可能溢出\n\n// ✅ 安全操作\nchar buffer[100];\nstrncpy(buffer, user_input, sizeof(buffer) - 1);\nbuffer[sizeof(buffer) - 1] = '\\0';\n```\n\n#### 字符串操作安全\n**推荐使用的安全函数:**\n- `strncpy()` 替代 `strcpy()`\n- `strncat()` 替代 `strcat()`\n- `snprintf()` 替代 `sprintf()`\n\n#### 命名规范\n**要求:**\n- 使用snake_case命名方式\n- 变量名要有意义\n- 常量使用UPPER_CASE"
}
}

View file

@ -0,0 +1,174 @@
package rules
import (
"strings"
"testing"
)
func TestExpandBraces_NoBraces(t *testing.T) {
got := expandBraces("*.java")
if len(got) != 1 || got[0] != "*.java" {
t.Errorf("expected [*.java], got %v", got)
}
}
func TestExpandBraces_SingleGroup(t *testing.T) {
got := expandBraces("*.{go,py}")
want := []string{"*.go", "*.py"}
if len(got) != len(want) {
t.Fatalf("expected %d items, got %d: %v", len(want), len(got), got)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("index %d: expected %q, got %q", i, want[i], got[i])
}
}
}
func TestExpandBraces_MultipleOptions(t *testing.T) {
got := expandBraces("**/*.{ts,js,tsx,jsx}")
want := []string{"**/*.ts", "**/*.js", "**/*.tsx", "**/*.jsx"}
if len(got) != len(want) {
t.Fatalf("expected %d items, got %d: %v", len(want), len(got), got)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("index %d: expected %q, got %q", i, want[i], got[i])
}
}
}
func TestExpandBraces_UnclosedBrace(t *testing.T) {
got := expandBraces("*.{go,py")
if len(got) != 1 || got[0] != "*.{go,py" {
t.Errorf("expected original pattern, got %v", got)
}
}
func TestResolve_DefaultRules(t *testing.T) {
rule, err := LoadDefault()
if err != nil {
t.Fatalf("LoadDefault: %v", err)
}
tests := []struct {
path string
wantSubstr string // substring that should appear in the matched rule
}{
{"src/main/java/com/example/foo.java", "逻辑错误识别"},
{"foo.java", "逻辑错误识别"},
{"internal/agent/agent.go", "逻辑问题"},
{"scripts/deploy.py", "逻辑问题"},
{"src/main/resources/mapper/usermapper.xml", "SQL逻辑错误识别"},
{"src/main/resources/dao/userdao.xml", "SQL逻辑错误识别"},
{"pom.xml", "snapshot"},
{"submodule/pom.xml", "snapshot"},
{"src/main/resources/application.properties", "配置错误识别"},
{"frontend/package.json", "latest"},
{"config/app.yaml", "yaml-key"},
{"deploy/values.yml", "yaml-key"},
{"src/components/app.tsx", "React"},
{"lib/utils.ts", "TypeScript"},
{"app.kt", "空安全"},
{"src/main/handler.cpp", "智能指针"},
{"driver.c", "malloc"},
{"ios/ViewController.m", "数组越界"},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
got := rule.Resolve(tt.path)
if !strings.Contains(got, tt.wantSubstr) {
t.Errorf("Resolve(%q): expected rule containing %q, got %q",
tt.path, tt.wantSubstr, truncate(got, 80))
}
})
}
}
func TestResolve_FallbackToDefault(t *testing.T) {
rule, err := LoadDefault()
if err != nil {
t.Fatalf("LoadDefault: %v", err)
}
paths := []string{
"readme.md",
"docs/architecture.txt",
"Makefile",
"src/unknown.rs",
}
for _, path := range paths {
t.Run(path, func(t *testing.T) {
got := rule.Resolve(path)
if got != rule.DefaultRule {
t.Errorf("Resolve(%q): expected DefaultRule, got %q", path, truncate(got, 80))
}
})
}
}
func TestResolve_CustomRule_FirstMatchWins(t *testing.T) {
rule := &SystemRule{
DefaultRule: "default",
PathRules: []PathRule{
{Pattern: "**/special.java", Rule: "special-rule"},
{Pattern: "**/*.java", Rule: "java-rule"},
},
}
// special.java matches both patterns, but "special-rule" is first.
got := rule.Resolve("src/special.java")
if got != "special-rule" {
t.Errorf("expected special-rule, got %q", got)
}
// Other java files match the second pattern.
got = rule.Resolve("src/foo.java")
if got != "java-rule" {
t.Errorf("expected java-rule, got %q", got)
}
}
func TestResolve_CustomRule_DefaultFallback(t *testing.T) {
rule := &SystemRule{
DefaultRule: "fallback-rule",
PathRules: []PathRule{
{Pattern: "**/*.java", Rule: "java-rule"},
},
}
got := rule.Resolve("main.go")
if got != "fallback-rule" {
t.Errorf("expected fallback-rule, got %q", got)
}
}
func TestResolve_CaseSensitivity(t *testing.T) {
rule := &SystemRule{
DefaultRule: "default",
PathRules: []PathRule{
{Pattern: "**/*.java", Rule: "java-rule"},
},
}
// agent.go calls strings.ToLower(newPath) before Resolve,
// so uppercase extensions should NOT match if not lowercased.
got := rule.Resolve("Foo.Java")
if got != "default" {
t.Errorf("expected default for uppercase extension, got %q", got)
}
got = rule.Resolve("foo.java")
if got != "java-rule" {
t.Errorf("expected java-rule for lowercase, got %q", got)
}
}
func truncate(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen] + "..."
}

View file

@ -0,0 +1,46 @@
{
"MAIN_TASK": {
"messages": [
{
"role": "system",
"content": "## Role\nYou are a code review assistant developed by Alibaba. You are skilled at code review in the software development process and are responsible for providing professional review feedback for code changes that are about to be submitted. Your feedback perfectly combines detailed analysis with contextual explanations.\nYou are working in an IDE with editor concepts for open files and an integrated terminal. The user's developed code is stored in the IDE's staging area.\nBefore users commit staged code to remote repositories, they will send you tasks to help them complete the process successfully. Each time a user sends a task, it will be placed in <user_task>, and you will use <tool> to interact with the real world when executing tasks.\nPlease keep your responses concise and objective.\n\n## Capabilities\n- Think step by step progressively.\n- First understand the code changes to be reviewed. Code changes are provided in Unified Diff format, where lines starting with `-` indicate deleted code, lines starting with `+` indicate added code, consecutive `-` and `+` lines represent modified code, and other lines represent unchanged code.\n- Be objective and neutral, make judgments based on facts and logic, avoid subjective assumptions. When the context is unclear, use tools to obtain contextual information rather than judging based on assumptions.\n- For the current code changes, provide feedback opinions, pointing out areas for improvement or potential issues. Focus on issues in newly added code.\n- Avoid commenting on correct code or unchanged code.\n- Avoid commenting on deleted code; deleted code serves only as reference context.\n- Focus on clarity, practicality, and comprehensiveness.\n- Use developer-friendly terminology and analogies in explanations.\n- Focus primarily on the actual code logic and functionality. Avoid commenting on or providing feedback about non-functional elements such as code comments, tool-generated indicators (like @Generated annotations), or other metadata, unless the user explicitly requests you to review these elements.\n\n## Reply limit\n- If the current code review task is complete, call `task_done` to end the task.\n- If a code issue has been identified and confirmed, call the `code_comment` tool to provide feedback.\n- If additional context is needed to confirm the issue, call the appropriate context tool."
},
{
"role": "user",
"content": "// The following is the list of other files changed in this update.\n<other_changed_files>\n{{change_files}}\n</other_changed_files>\n\n<current_file_path>{{current_file_path}}</current_file_path>\n\n<current_file_diff>\n{{diff}}\n</current_file_diff>\n\nCurrent time in the real world: {{current_system_date_time}}\n\n<user_task>\n### Requirement Background (Optional)\n{{requirement_background}}\n\n### Review Checklist\n{{system_rule}}\n\n### Review Plan (Optional)\n{{plan_guidance}}\n\nNow please review the code changes in <current_file_diff>\n</user_task>"
}
],
"timeout": 120
},
"PLAN_TASK": {
"messages": [
{
"role": "system",
"content": "You are an expert in code review task planning. You have access to a set of tools for retrieving relevant context about code changes, and your responsibility is to analyze those changes and produce a structured review plan.\n\n## Core Responsibilities\nAnalyze code change content, identify potential risk points, and plan appropriate tool-calling strategies for each risk point.\n\n## Tool Descriptions\n{{plan_tools}}\n\n## Output Format\nStrictly follow the JSON format below. Do not include any additional explanatory text:\n\n{\n \"change_summary\": \"A brief description of the purpose and scope of this code change\",\n \"issues\": [\n {\n \"severity\": \"high|medium|low\",\n \"description\": \"A clear description of the specific problem and its potential impact for this risk point\",\n \"tool_guidance\": [\n {\n \"name\": \"Tool name\",\n \"reason\": \"Explain the purpose of calling this tool and its relevance to the current issue\",\n \"arguments\": \"Invocation arguments\"\n }\n ]\n }\n ]\n}\n\n## Analysis Rules\n1. **Scope**: Only analyze newly added and modified code; ignore deleted code\n2. **Ordering**: The issues list must be sorted by severity in descending order (high → medium → low)\n3. **Severity Definitions**:\n - `high`: May cause security vulnerabilities, data loss, system crashes, or critical functional failures\n - `medium`: May affect performance, maintainability, or involve potential edge-case problems\n - `low`: Code style, readability, or non-critical best practice suggestions\n4. **Tool Usage**: Tools are for reference purposes only and must not be actually invoked; describe the calling intent within tool_guidance\n5. **Description Requirements**: Each description must cover three dimensions — problem location, nature of the problem, and potential impact"
},
{
"role": "user",
"content": "// The following is the list of other files changed in this update.\n<other_changed_files>\n{{change_files}}\n</other_changed_files>\n\n<current_file_path>{{current_file_path}}</current_file_path>\n\n<current_file_diff>\n{{diff}}\n</current_file_diff>\n\nCurrent time in the real world: {{current_system_date_time}}\n\n### Requirement Background (Optional)\n{{requirement_background}}\n\n### Review Checklist\n{{system_rule}}\n\n### Task\nPlease analyze the code changes above and output a structured review plan. Start with ```json"
}
],
"timeout": 180
},
"MEMORY_COMPRESSION_TASK": {
"messages": [
{
"role": "system",
"content": "## Goal\nYou are a professional code review conversation summarization assistant. You will receive a conversation history between a code review assistant and an LLM model (including tool calls and their results). Compress this conversation into a structured summary so that the code review assistant can continue from the current state without restarting.\n\n## Output Format Requirements\nOrganize the summary using the following five dimensions, separated by explicit headings:\n\n### Identified Code Issues\nList all confirmed issues sorted by severity (HIGH / MEDIUM / LOW). Each entry should include: file path, issue type, severity, brief description. Example:\n- [HIGH] `UserService.go:45` — map concurrent read-write access without lock, suggest adding sync.RWMutex\n- [MEDIUM] `config_loader.go:12` — error handling is incomplete, may swallow critical information\n\n### Tool Call Conclusions\nSummarize key findings and conclusions from each tool invocation. Example:\n- get_function_info(UserService): confirmed concurrent write-to-map logic within this function\n- search_file(\"database\"): no other related configuration issues found\n\n### Completed Tasks\nList items that have been completed and require no further follow-up.\n\n### Pending Tasks\nList items that have been started but not yet completed and still need attention.\n\n### Current Focus\nSummarize in one sentence the core matter currently being investigated or handled.\n\n## Rules\n1. Do not include specific code details; only reference file paths and issue types\n2. Avoid repetitive or redundant information\n3. Omit any dimension that has no relevant content\n4. Completed/pending task list items should be described as complete sentences\n5. current_focus should be concise, no more than one sentence"
},
{
"role": "user",
"content": "{{context}}"
}
],
"timeout": 120
},
"TOOL_REQUEST_WAIT_TIME_MS": 10000,
"MAX_TOOL_REQUEST_TIMES": 20,
"MAX_SUBTASK_EXECUTION_TIME_MINUTES": 5,
"PLAN_MODE_LINE_THRESHOLD": 50,
"MAX_TOKENS": 58888
}

View file

@ -0,0 +1,85 @@
// Package template loads and validates task prompt templates for the code review agent.
package template
import (
_ "embed"
"encoding/json"
"fmt"
)
// Template holds the native agent task template configuration.
// Mirrors NativeAgentTemplate from the Java implementation, loaded via JSON at runtime.
type Template struct {
MainTask LlmConversation `json:"MAIN_TASK"`
PlanTask *LlmConversation `json:"PLAN_TASK,omitempty"`
MemoryCompressionTask LlmConversation `json:"MEMORY_COMPRESSION_TASK"`
MaxTokens int `json:"MAX_TOKENS"`
ToolRequestWaitTimeMs int `json:"TOOL_REQUEST_WAIT_TIME_MS"`
MaxToolRequestTimes int `json:"MAX_TOOL_REQUEST_TIMES"`
MaxSubtaskExecMinutes int `json:"MAX_SUBTASK_EXECUTION_TIME_MINUTES"`
PlanModeLineThreshold int `json:"PLAN_MODE_LINE_THRESHOLD"`
}
//go:embed task_template.json
var defaultTemplate []byte
// LoadDefault parses the embedded task_template.json.
func LoadDefault() (*Template, error) {
var tpl Template
if err := json.Unmarshal(defaultTemplate, &tpl); err != nil {
return nil, fmt.Errorf("unmarshal default template: %w", err)
}
return &tpl, nil
}
// applyLanguage appends instruction to all system-role messages in conv.
func applyLanguage(conv *LlmConversation, instruction string) {
for i := range conv.Messages {
if conv.Messages[i].Role == "system" {
conv.Messages[i].Content += instruction
}
}
}
// resolveLang returns the resolved language name for the instruction.
func resolveLang(lang string) string {
if lang == "" {
return "Chinese"
}
return lang
}
// ApplyLanguage injects a language directive into all system-role messages
// across MAIN_TASK, PLAN_TASK (if set), and MEMORY_COMPRESSION_TASK.
func (t *Template) ApplyLanguage(lang string) {
instruction := "\n\nAlways respond in " + resolveLang(lang) + "."
applyLanguage(&t.MainTask, instruction)
if t.PlanTask != nil {
applyLanguage(t.PlanTask, instruction)
}
applyLanguage(&t.MemoryCompressionTask, instruction)
}
func (t *Template) Validate() error {
if t.MaxTokens <= 0 {
return fmt.Errorf("max_tokens must be positive")
}
if t.MaxToolRequestTimes <= 0 {
return fmt.Errorf("max_tool_request_times must be positive")
}
if len(t.MainTask.Messages) == 0 {
return fmt.Errorf("main_task.messages must not be empty")
}
return nil
}
// LlmConversation mirrors LlmConversation from the Java side — a preset prompt with settings.
type LlmConversation struct {
Timeout int `json:"timeout"`
Messages []ChatMessage `json:"messages"`
}
// ChatMessage represents a single message in a conversation.
type ChatMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}

View file

@ -0,0 +1,15 @@
{
"TEST_TASK": {
"messages": [
{
"role": "system",
"content": "## Role\nYou are open-code-review, a code review assistant developed by Alibaba, running in the user's command-line environment.\n\n## Quick Start\nRun `ocr --help` in the command-line environment."
},
{
"role": "user",
"content": "One sentence to answer who you are."
}
],
"timeout": 120
}
}

View file

@ -0,0 +1,54 @@
// Package testconnection loads the LLM test connection task configuration.
package testconnection
import (
_ "embed"
"encoding/json"
"fmt"
)
// TestTask holds the conversation template for the LLM connectivity test.
type TestTask struct {
TestTask LlmConversation `json:"TEST_TASK"`
}
// LlmConversation represents a single conversation preset for testing.
type LlmConversation struct {
Timeout int `json:"timeout"`
Messages []ChatMessage `json:"messages"`
}
// ChatMessage represents a single message in a conversation.
type ChatMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
//go:embed task.json
var defaultTask []byte
// LoadDefault parses the embedded task.json and returns the TEST_TASK conversation.
func LoadDefault() (*LlmConversation, error) {
var tasks TestTask
if err := json.Unmarshal(defaultTask, &tasks); err != nil {
return nil, fmt.Errorf("unmarshal test task config: %w", err)
}
return &tasks.TestTask, nil
}
func resolveLang(lang string) string {
if lang == "" {
return "Chinese"
}
return lang
}
// ApplyLanguage injects a language directive into all system-role messages of this conversation.
func (c *LlmConversation) ApplyLanguage(lang string) {
instruction := "\n\nAlways respond in " + resolveLang(lang) + "."
for i := range c.Messages {
if c.Messages[i].Role == "system" {
c.Messages[i].Content += instruction
}
}
}

View file

@ -0,0 +1,183 @@
[
{
"name": "task_done",
"plan_task": false,
"main_task": true,
"definition": {
"name": "task_done",
"description": "Call this tool to terminate task execution when you have completed the user's task, such as when no obvious code issues are found during code review.",
"parameters": {
"type": "object",
"properties": {
"state": {
"type": "string",
"enum": [
"DONE",
"FAILED"
],
"description": "Defaults to DONE. Return FAILED if the task cannot be completed using available tools."
}
},
"required": [
"state"
]
}
}
},
{
"name": "code_comment",
"plan_task": false,
"main_task": true,
"definition": {
"name": "code_comment",
"description": "When you discover that a code change could introduce code issue, please use this tool to report the issue. The tool will pinpoint your feedback to the precise code line (or block) in the current file by inserting a code comment.\n\n**Core Mechanism:**\nThis tool uses a dynamic sliding window algorithm to match corresponding consecutive lines in diff text based on your provided 'existing_code' parameter. Therefore, you must ensure the provided 'existing_code' actually exists in the diff text with exactly matching format. It should contain one or several consecutive lines of code most relevant to your comment.",
"parameters": {
"type": "object",
"properties": {
"comments": {
"type": "array",
"description": "A list of comments. Each item should contain 'content' and 'existing_code'.",
"items": {
"type": "object",
"properties": {
"content": {
"type": "string",
"description": "Comment content, typically a brief description of code issues and corresponding suggestions."
},
"existing_code": {
"type": "string",
"description": "Code snippet used to locate comment position. Only return newly added code lines, should not include deleted code or unchanged code lines. Maintain consistent style with diff code for IDE recognition and mounting in current file."
},
"suggestion_code": {
"type": "string",
"description": "Corresponding suggested code snippet, maintaining consistent code style."
}
},
"required": [
"content",
"existing_code"
]
}
}
},
"required": [
"comments"
]
}
}
},
{
"name": "file_read",
"plan_task": false,
"main_task": true,
"definition": {
"name": "file_read",
"description": "Use this tool to read file content when you need to get context for git diff. You can specify start_line and end_line to view specific parts of the file.\n\n**Line Range Strategy:**\n- Git diff hunk header provides guidance on how to get more relevant context.\n- Git diff hunk header \"@@-x,y +m,n@@\" indicates that the old file has y lines starting from line x, and the new file has n lines starting from line m.\n- For example, when you need to read 50 lines above and below the current changed code block in the new file, set start_line = m - 50, end_line = m + n + 50.\n\n**Example output:**\nFilepath/to/example.go (Total lines: 50)\nIS_TRUNCATED: false\nLINE_RANGE: 10-12\n// The following is the original content of the file\nfunc main() {\n fmt.Println(\"Hello, World!\")\n}\n\n**Limitations:**\n- If the specified range exceeds 500 lines, only 500 lines will be returned with a truncation notice.\n- This tool can only read file content from the modified version (after changes) in git diff.",
"parameters": {
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "The relative path of the file to open."
},
"start_line": {
"type": "integer",
"description": "The start line number to view. Defaults to 1."
},
"end_line": {
"type": "integer",
"description": "The end line number to view. Defaults to end line of file."
}
},
"required": [
"file_path"
]
}
}
},
{
"name": "code_search",
"plan_task": true,
"main_task": true,
"definition": {
"name": "code_search",
"description": "Use this tool to search for specific text within files. Supports searching in specific files, directories, or across the entire codebase with flexible file pattern filtering. Can use either exact string matching or regular expressions.\n\n**Example output:**\nSearch results for 'toolRequest' (case-insensitive):\nFile: path/to/example.java\n433| String name = toolRequest.get().getName();\n438| logToolRequest(newPath, tool, toolRequest.get());\n\n**Regular expression examples:**\n- Find classes that extend BaseModel: 'class.*extends.*BaseModel'\n- Find function: 'functionName(.*)'\n- Find the function call sites: '\\.functionName(.*)'\n- Match any of multiple strings: 'error|exception|fail'\n\n**File patterns examples:**\n- Single file: ['src/main.go']\n- Multiple files: ['src/main.go', 'lib/utils.js']\n- All Go files: ['*.go']\n- Exclude test files: [':(exclude)*_test.go']\n- Only in src directory: ['src/']\n- Multiple patterns: ['*.go', ':(exclude)vendor/']\n\n**Limitations:**\n- If more than 100 matches are found, only the first 100 results will be returned.\n- Empty search terms will return no results.\n- This tool searches in the current version of files.",
"parameters": {
"type": "object",
"properties": {
"search_text": {
"type": "string",
"description": "The text string or regular expression pattern to search for."
},
"file_patterns": {
"type": "array",
"items": {
"type": "string"
},
"description": "Array of patterns to include/exclude files in the search. Supports Git pathspec syntax for including and excluding files. If omitted, searches the entire codebase."
},
"case_sensitive": {
"type": "boolean",
"description": "Whether the search should be case-sensitive. Defaults to false (case-insensitive)."
},
"use_perl_regexp": {
"type": "boolean",
"description": "If true, treats search_text as a Perl-compatible regular expression pattern instead of literal text. Defaults to false."
}
},
"required": [
"search_text"
]
}
}
},
{
"name": "file_read_diff",
"plan_task": true,
"main_task": true,
"definition": {
"name": "file_read_diff",
"description": "The tool is used to view the changes made to other files in the list of modifications. Call this tool when you discover suspected code issues but need to check changes in other files to confirm whether the problem actually exists. This tool will respond in git diff format.\n\nOutput example:\n==== FILE: path/to/file1.txt ====\n--- a/path/to/file1.txt\n+++ b/path/to/file1.txt\n@@ -10,1 +10,1 @@\n- old content\n+ new content\n\n==== FILE: path/to/file2.txt ====\n@@ -5,1 +5,2 @@\n - old content\n + new content1\n + new content2",
"parameters": {
"type": "object",
"properties": {
"path_array": {
"type": "array",
"items": {
"type": "string"
},
"description": "List of file paths to view diff content."
}
},
"required": [
"path_array"
]
}
}
},
{
"name": "file_find",
"plan_task": true,
"main_task": true,
"definition": {
"name": "file_find",
"description": "Search for matching files in the current project based on filename keywords. Use this tool when you cannot find the files you need to view in the current change file list.\n\nThis tool searches for filenames containing specified keywords in the project directory and returns a list of matching file paths. Search is case-insensitive by default, adjustable via case_sensitive parameter.\n\nNote: This tool only supports returning the first 100 matching file paths; excess will be truncated.\n\nExample:\nInput:\nquery_name: UserService\nOutput:\nsrc/main/java/UserService.java\nsrc/test/java/UserServiceTest.java",
"parameters": {
"type": "object",
"properties": {
"query_name": {
"type": "string",
"description": "Filename keyword to search for, supports partial matching."
},
"case_sensitive": {
"type": "boolean",
"description": "Whether to perform case-sensitive search. Defaults to false."
}
},
"required": [
"query_name"
]
}
}
}
]

View file

@ -0,0 +1,54 @@
// Package toolsconfig loads tool definitions from JSON config files.
package toolsconfig
import (
_ "embed"
"encoding/json"
"fmt"
"os"
)
// ToolConfigEntry holds a single tool definition loaded from tools.json.
type ToolConfigEntry struct {
Name string `json:"name"`
PlanTask bool `json:"plan_task"`
MainTask bool `json:"main_task"`
Definition json.RawMessage `json:"definition"`
}
//go:embed tools.json
var defaultToolsJSON []byte
// Load parses the tools config file. When path is empty, falls back to
// the embedded default tools configuration.
func Load(path string) ([]ToolConfigEntry, error) {
var data []byte
var err error
if path == "" {
data = defaultToolsJSON
} else {
data, err = os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read tools file %s: %w", path, err)
}
}
var tools []ToolConfigEntry
if err := json.Unmarshal(data, &tools); err != nil {
return nil, fmt.Errorf("unmarshal tools file: %w", err)
}
return tools, nil
}
// ToolDefsByPhase returns the parsed tool definitions filtered by phase.
// planOnly=true returns only tools with plan_task:true.
// planOnly=false returns only tools with main_task:true.
func (t *ToolConfigEntry) ToolDefsByPhase(planOnly bool) (json.RawMessage, bool) {
switch {
case planOnly && t.PlanTask:
return t.Definition, true
case !planOnly && t.MainTask:
return t.Definition, true
default:
return nil, false
}
}

332
internal/diff/git.go Normal file
View file

@ -0,0 +1,332 @@
package diff
import (
"bytes"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/open-code-review/open-code-review/internal/model"
)
// DiffContextLines defines the number of context lines around each changed hunk.
const DiffContextLines = 3
// providerDirIgnoreDirs: directory prefixes to always exclude from diff results.
var providerDirIgnoreDirs = []string{
".idea/",
".vscode/",
".svn/",
".git/",
"vendor/",
"node_modules/",
"target/",
".happypack/",
".cachefile/",
"_packages/",
"rpm/",
"pkgs/",
}
// Mode defines how the diff is retrieved.
type Mode int
const (
ModeWorkspace Mode = iota // current workspace (staged + unstaged + untracked)
ModeCommit // single commit vs its parent
ModeRange // merge-base(from,to)..to
)
// Provider retrieves and parse git diffs from a repository.
type Provider struct {
repoDir string
mode Mode
// Range mode parameters
from, to string // from/to refs for range comparison
// Commit mode parameter
commit string // single commit hash/ref
mergeBase string // cached common ancestor for range mode
}
// NewProvider creates a Provider for range mode: from..to (via merge-base).
func NewProvider(repoDir, from, to string) *Provider {
return &Provider{
repoDir: repoDir,
mode: ModeRange,
from: from,
to: to,
}
}
// NewCommitProvider creates a Provider for commit mode: show changes introduced by a single commit.
func NewCommitProvider(repoDir, commit string) *Provider {
return &Provider{
repoDir: repoDir,
mode: ModeCommit,
commit: commit,
}
}
// NewWorkspaceProvider creates a Provider for workspace mode (current uncommitted changes).
func NewWorkspaceProvider(repoDir string) *Provider {
return &Provider{
repoDir: repoDir,
mode: ModeWorkspace,
}
}
// IsRangeMode returns true when comparing two refs.
func (p *Provider) IsRangeMode() bool {
return p.mode == ModeRange
}
// IsCommitMode returns true when analyzing a single commit.
func (p *Provider) IsCommitMode() bool {
return p.mode == ModeCommit
}
// MergeBase returns the computed merge-base commit hash for range mode.
func (p *Provider) MergeBase() string {
if p.mode != ModeRange || p.mergeBase != "" {
return p.mergeBase
}
p.mergeBase = p.computeMergeBase(p.from, p.to)
return p.mergeBase
}
// GetDiff returns all changes as parsed model.Diff structs.
func (p *Provider) GetDiff() ([]model.Diff, error) {
var combined strings.Builder
switch p.mode {
case ModeRange:
base := p.MergeBase()
if base == "" {
return nil, fmt.Errorf("cannot find merge-base between %s and %s", p.from, p.to)
}
out, err := p.runGit("diff", "--no-color", "-U"+fmt.Sprint(DiffContextLines), base, p.to, "--")
if err != nil {
return nil, fmt.Errorf("git diff failed: %w", err)
}
combined.WriteString(out)
case ModeCommit:
out, err := p.runGit("show", "--no-color", "-U"+fmt.Sprint(DiffContextLines), p.commit)
if err != nil {
return nil, fmt.Errorf("git show failed: %w", err)
}
combined.WriteString(out)
case ModeWorkspace:
tracked, err := p.workspaceTrackedDiff()
if err != nil {
return nil, fmt.Errorf("workspace tracked diff failed: %w", err)
}
combined.WriteString(tracked)
untracked, err := p.untrackedFileDiffs()
if err != nil {
return nil, fmt.Errorf("untracked file diff failed: %w", err)
}
for _, ud := range untracked {
combined.WriteString(ud)
combined.WriteString("\n\n")
}
}
diffs, err := ParseDiffText(combined.String(), p.repoDir)
if err != nil {
return nil, err
}
return p.filterDiffs(diffs), nil
}
// loadGitignorePatterns reads and parses .gitignore patterns from the repo root.
func (p *Provider) loadGitignorePatterns() []string {
data, err := os.ReadFile(filepath.Join(p.repoDir, ".gitignore"))
if err != nil {
return nil
}
var patterns []string
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
patterns = append(patterns, line)
}
return patterns
}
// isPathExcluded returns true when the given relative file path should be skipped
// based on hardcoded dir rules or .gitignore patterns.
func (p *Provider) isPathExcluded(relPath string, gitignorePatterns []string) bool {
// Hardcoded directory prefix checks
for _, prefix := range providerDirIgnoreDirs {
dirPart := strings.TrimSuffix(prefix, "/")
if relPath == dirPart || strings.HasPrefix(relPath, prefix) {
return true
}
}
// .gitignore pattern matching
for _, pat := range gitignorePatterns {
if matchGitignorePattern(relPath, pat) {
return true
}
}
return false
}
// matchGitignorePattern checks if relPath matches a single .gitignore pattern.
func matchGitignorePattern(relPath, pat string) bool {
// Directory-only patterns (trailing /)
if strings.HasSuffix(pat, "/") {
dirName := strings.TrimSuffix(pat, "/")
// Match if any path segment equals the dir name
segments := strings.Split(relPath, "/")
for _, seg := range segments {
if seg == dirName {
return true
}
}
return false
}
// Negation patterns are not needed for exclusion purposes
if strings.HasPrefix(pat, "!") {
return false
}
// Patterns without / match basename
if !strings.Contains(pat, "/") {
base := filepath.Base(relPath)
if matched, _ := filepath.Match(pat, base); matched {
return true
}
return false
}
// Patterns with / match against the full relative path
if matched, _ := filepath.Match(pat, relPath); matched {
return true
}
// Also try matching against suffix of path
if strings.HasSuffix(relPath, pat) {
return true
}
return false
}
// filterDiffs removes diffs whose file paths are excluded.
func (p *Provider) filterDiffs(diffs []model.Diff) []model.Diff {
patterns := p.loadGitignorePatterns()
var result []model.Diff
for _, d := range diffs {
path := d.NewPath
if path == "/dev/null" {
path = d.OldPath
}
if !p.isPathExcluded(path, patterns) {
result = append(result, d)
}
}
return result
}
// ---- Internal helpers ----
func (p *Provider) computeMergeBase(from, to string) string {
out, err := p.runGit("merge-base", from, to)
if err != nil {
return ""
}
return strings.TrimSpace(out)
}
func (p *Provider) workspaceTrackedDiff() (string, error) {
out, err := p.runGit("diff", "HEAD", "--no-color", "-U"+fmt.Sprint(DiffContextLines), "--")
if err == nil && out != "" {
return out, nil
}
return p.runGit("diff", "--staged", "--no-color", "-U"+fmt.Sprint(DiffContextLines), "--")
}
func (p *Provider) untrackedFileDiffs() ([]string, error) {
files, err := p.untrackedFilesList()
if err != nil {
return nil, err
}
var results []string
for _, f := range files {
fullPath := filepath.Join(p.repoDir, f)
stat, serr := os.Stat(fullPath)
if serr != nil || stat.IsDir() {
continue
}
content, rerr := os.ReadFile(fullPath)
if rerr != nil {
continue
}
lineCount := bytes.Count(content, []byte{'\n'})
if len(content) > 0 && content[len(content)-1] != '\n' {
lineCount++
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("diff --git a/%s b/%s\n", f, f))
sb.WriteString("--- /dev/null\n")
sb.WriteString(fmt.Sprintf("+++ b/%s\n", f))
sb.WriteString(fmt.Sprintf("@@ -0,0 +1,%d @@\n", lineCount))
lines := bytes.Split(content, []byte{'\n'})
if len(lines) > 0 && len(lines[len(lines)-1]) == 0 {
lines = lines[:len(lines)-1]
}
for _, line := range lines {
sb.WriteByte('+')
sb.Write(line)
sb.WriteByte('\n')
}
results = append(results, sb.String())
}
return results, nil
}
func (p *Provider) untrackedFilesList() ([]string, error) {
out, err := p.runGit("ls-files", "--others", "--exclude-standard")
if err != nil || out == "" {
return nil, nil
}
patterns := p.loadGitignorePatterns()
var files []string
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
if !p.isPathExcluded(line, patterns) {
files = append(files, line)
}
}
return files, nil
}
func (p *Provider) runGit(args ...string) (string, error) {
cmd := exec.Command("git", args...)
cmd.Dir = p.repoDir
out, err := cmd.CombinedOutput()
outStr := string(out)
if err != nil {
return outStr, err
}
return outStr, nil
}

110
internal/diff/hunk.go Normal file
View file

@ -0,0 +1,110 @@
package diff
import (
"regexp"
"strconv"
"strings"
)
// HunkLineType represents the type of a line in a diff hunk.
type HunkLineType int
const (
HunkContext HunkLineType = iota // ' ' prefix: unchanged context line
HunkAdded // '+' prefix: added line
HunkDeleted // '-' prefix: removed line
)
// HunkLine is a single line within a hunk.
type HunkLine struct {
Type HunkLineType
Content string // content without the leading +/-/ marker
}
// Hunk represents one @@ ... @@ block in a unified diff.
type Hunk struct {
OldStart int // starting line in the old file (1-indexed)
OldCount int // number of lines in the old file
NewStart int // starting line in the new file (1-indexed)
NewCount int // number of lines in the new file
Lines []HunkLine // all lines in sequence
}
var hunkHeaderRe = regexp.MustCompile(`^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@`)
// ParseHunks parses raw unified diff text for a single file into a slice of Hunks.
// Lines before the first @@ header (file-level headers like "diff --git", "---", "+++") are ignored.
func ParseHunks(rawDiffText string) []Hunk {
lines := strings.Split(rawDiffText, "\n")
var hunks []Hunk
var current *Hunk
for _, line := range lines {
if m := hunkHeaderRe.FindStringSubmatch(line); m != nil {
// Flush previous hunk
if current != nil {
hunks = append(hunks, *current)
}
oldStart, _ := strconv.Atoi(m[1])
oldCount := 1
if m[2] != "" {
oldCount, _ = strconv.Atoi(m[2])
}
newStart, _ := strconv.Atoi(m[3])
newCount := 1
if m[4] != "" {
newCount, _ = strconv.Atoi(m[4])
}
current = &Hunk{
OldStart: oldStart,
OldCount: oldCount,
NewStart: newStart,
NewCount: newCount,
}
continue
}
if current == nil {
continue // skip file-level headers and preamble
}
// Skip metadata lines that can appear inside hunks
if strings.HasPrefix(line, "\\ No newline at end of file") {
continue
}
// Stop processing if we hit another file's diff header
if strings.HasPrefix(line, "diff --git ") {
break
}
switch {
case strings.HasPrefix(line, "+"):
current.Lines = append(current.Lines, HunkLine{
Type: HunkAdded,
Content: line[1:],
})
case strings.HasPrefix(line, "-"):
current.Lines = append(current.Lines, HunkLine{
Type: HunkDeleted,
Content: line[1:],
})
default:
// Context line (' ' prefix) or other — treat as context
content := line
if len(content) > 0 && content[0] == ' ' {
content = content[1:]
}
current.Lines = append(current.Lines, HunkLine{
Type: HunkContext,
Content: content,
})
}
}
// Flush last hunk
if current != nil {
hunks = append(hunks, *current)
}
return hunks
}

120
internal/diff/hunk_test.go Normal file
View file

@ -0,0 +1,120 @@
package diff
import (
"testing"
)
func TestParseHunks_SingleHunk(t *testing.T) {
raw := `diff --git a/pkg/example/handler.go b/pkg/example/handler.go
--- a/pkg/example/handler.go
+++ b/pkg/example/handler.go
@@ -10,7 +10,7 @@ func HandleRequest(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
- log.Print("handling request")
+ log.Printf("handling request: %s", r.URL.Path)
err := process(ctx)`
hunks := ParseHunks(raw)
if len(hunks) != 1 {
t.Fatalf("expected 1 hunk, got %d", len(hunks))
}
h := hunks[0]
if h.OldStart != 10 || h.OldCount != 7 {
t.Errorf("OldStart/OldCount: expected 10,7 got %d,%d", h.OldStart, h.OldCount)
}
if h.NewStart != 10 || h.NewCount != 7 {
t.Errorf("NewStart/NewCount: expected 10,7 got %d,%d", h.NewStart, h.NewCount)
}
if len(h.Lines) != 4 {
t.Fatalf("expected 4 lines, got %d", len(h.Lines))
}
// Check line types in order
expected := []HunkLineType{HunkContext, HunkDeleted, HunkAdded, HunkContext}
for i, lt := range expected {
if h.Lines[i].Type != lt {
t.Errorf("line[%d]: type %d expected %d", i, h.Lines[i].Type, lt)
}
}
}
func TestParseHunks_MultipleHunks(t *testing.T) {
raw := `diff --git a/pkg/example/handler.go b/pkg/example/handler.go
--- a/pkg/example/handler.go
+++ b/pkg/example/handler.go
@@ -10,3 +10,3 @@ func foo() {
a := 1
- b := 2
+ b := 3
c := 4
@@ -25,6 +25,8 @@ func bar() {
if err != nil {
return err
}
+ log.Print("ok")
+ log.Print("done")
return nil`
hunks := ParseHunks(raw)
if len(hunks) != 2 {
t.Fatalf("expected 2 hunks, got %d", len(hunks))
}
h1 := hunks[0]
if h1.OldStart != 10 || h1.NewStart != 10 {
t.Errorf("hunk 0: OldStart=%d NewStart=%d", h1.OldStart, h1.NewStart)
}
h2 := hunks[1]
if h2.OldStart != 25 || h2.NewStart != 25 {
t.Errorf("hunk 1: OldStart=%d NewStart=%d", h2.OldStart, h2.NewStart)
}
if h2.OldCount != 6 || h2.NewCount != 8 {
t.Errorf("hunk 1 counts: OldCount=%d NewCount=%d", h2.OldCount, h2.NewCount)
}
}
func TestParseHunks_NoNewlineMarker(t *testing.T) {
raw := `@@ -1,2 +1,2 @@
- old line
\ No newline at end of file
+ new line`
hunks := ParseHunks(raw)
if len(hunks) != 1 {
t.Fatalf("expected 1 hunk, got %d", len(hunks))
}
if len(hunks[0].Lines) != 2 {
t.Errorf("expected 2 lines (excluding no-newline marker), got %d", len(hunks[0].Lines))
}
}
func TestParseHunks_EmptyInput(t *testing.T) {
hunks := ParseHunks("")
if len(hunks) != 0 {
t.Errorf("expected 0 hunks, got %d", len(hunks))
}
}
func TestParseHunks_NewFileAllAdditions(t *testing.T) {
raw := `diff --git a/pkg/new.go b/pkg/new.go
new file mode 100644
--- /dev/null
+++ b/pkg/new.go
@@ -0,0 +1,3 @@
+package pkg
+
+func New() {}`
hunks := ParseHunks(raw)
if len(hunks) != 1 {
t.Fatalf("expected 1 hunk, got %d", len(hunks))
}
h := hunks[0]
for _, l := range h.Lines {
if l.Type != HunkAdded {
t.Errorf("expected all lines to be HunkAdded, got %d", l.Type)
}
}
}

89
internal/diff/parser.go Normal file
View file

@ -0,0 +1,89 @@
// Package diff parses unified git diff output into structured Diff objects.
package diff
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/open-code-review/open-code-review/internal/model"
)
var (
diffHeaderRe = regexp.MustCompile(`^diff --git a/(.+?) b/(.+)$`)
oldFileRe = regexp.MustCompile(`^--- a/(.+)$`)
newFileRe = regexp.MustCompile(`^\+\+\+ b/(.+)$`)
binaryRe = regexp.MustCompile(`Binary files `)
)
// ParseDiffText splits the unified diff text into per-file Diff structs.
func ParseDiffText(diffText string, repoDir string) ([]model.Diff, error) {
lines := strings.Split(diffText, "\n")
var diffs []model.Diff
var current *model.Diff
var buf strings.Builder
for _, line := range lines {
if m := diffHeaderRe.FindStringSubmatch(line); m != nil {
// Flush previous diff
if current != nil {
current.Diff = strings.TrimSuffix(buf.String(), "\n")
finalizeDiff(current, repoDir)
diffs = append(diffs, *current)
buf.Reset()
}
current = &model.Diff{
OldPath: m[1],
NewPath: m[2],
}
}
if current == nil {
continue
}
switch {
case binaryRe.MatchString(line):
current.IsBinary = true
case oldFileRe.MatchString(line):
if p := oldFileRe.FindStringSubmatch(line); len(p) > 1 && p[1] == "/dev/null" {
current.IsNew = true
}
case newFileRe.MatchString(line):
if p := newFileRe.FindStringSubmatch(line); len(p) > 1 && p[1] == "/dev/null" {
current.IsDeleted = true
}
case strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "+++"):
current.Insertions++
case strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "---"):
current.Deletions++
}
buf.WriteString(line)
buf.WriteString("\n")
}
// Flush last diff
if current != nil {
current.Diff = strings.TrimSuffix(buf.String(), "\n")
finalizeDiff(current, repoDir)
diffs = append(diffs, *current)
}
return diffs, nil
}
// finalizeDiff attempts to read the new file content from disk.
func finalizeDiff(d *model.Diff, repoDir string) {
if d.IsDeleted || d.NewPath == "/dev/null" {
d.NewPath = "/dev/null"
return
}
fullPath := filepath.Join(repoDir, d.NewPath)
content, err := os.ReadFile(fullPath)
if err != nil {
fmt.Fprintf(os.Stderr, "[ocr] WARNING: cannot read file %s for review: %v\n", d.NewPath, err)
return
}
d.NewFileContent = string(content)
}

174
internal/diff/resolver.go Normal file
View file

@ -0,0 +1,174 @@
package diff
import (
"strings"
"github.com/open-code-review/open-code-review/internal/model"
)
// ResolveLineNumbers populates StartLine/EndLine on each comment by matching
// the ExistingCode against the corresponding file's diff hunks (primary), or
// falling back to scanning the full new-file content line-by-line.
func ResolveLineNumbers(comments []model.LlmComment, diffs []model.Diff) []model.LlmComment {
if len(comments) == 0 || len(diffs) == 0 {
return comments
}
// Build lookup: newPath -> *Diff
diffByPath := make(map[string]*model.Diff, len(diffs))
for i := range diffs {
d := &diffs[i]
if d.NewPath != "/dev/null" && d.NewPath != "" {
diffByPath[d.NewPath] = d
}
if d.OldPath != "/dev/null" && d.OldPath != "" {
diffByPath[d.OldPath] = d
}
}
result := make([]model.LlmComment, len(comments))
copy(result, comments)
for i := range result {
cm := &result[i]
if cm.StartLine > 0 || cm.EndLine > 0 {
continue
}
if cm.ExistingCode == "" {
continue
}
d, ok := diffByPath[cm.Path]
if !ok {
continue
}
// Primary: try matching from deleted/context lines in diff hunks
if resolveFromHunk(d, cm) {
continue
}
// Fallback: scan the new file content for consecutive matches
resolveFromFileContent(d, cm)
}
return result
}
// resolveFromHunk tries to find the startLine/endLine by matching ExistingCode
// against "from" side lines (context + deleted) in the diff hunks.
// Returns true on success (comments fields are mutated in place).
func resolveFromHunk(d *model.Diff, cm *model.LlmComment) bool {
// TODO: re-track with llm
hunks := ParseHunks(d.Diff)
if len(hunks) == 0 {
return false
}
targetLines := splitAndNormalize(cm.ExistingCode)
if len(targetLines) == 0 {
return false
}
for i := range hunks {
hunk := &hunks[i]
offset := 0 // tracks position within old-file range
lines := hunk.Lines
for j := 0; j < len(lines); j++ {
line := lines[j]
switch line.Type {
case HunkAdded:
// Added lines are TO-side only; don't affect old-file offset
continue
case HunkContext, HunkDeleted:
// Both are FROM-side candidates
if matchAt(lines, j, targetLines) {
startLine := hunk.OldStart + offset
endLine := startLine + len(targetLines) - 1
cm.StartLine = startLine
cm.EndLine = endLine
return true
}
offset++
}
}
}
return false
}
// matchAt checks whether targetLines[i] matches the normalized content of
// lines[startIndex+i] for all i, where each matched line must be a "from" side
// line (context or deleted).
func matchAt(lines []HunkLine, startIndex int, targetLines []string) bool {
for i, target := range targetLines {
idx := startIndex + i
if idx >= len(lines) {
return false
}
l := lines[idx]
// Must be a "from" side line
if l.Type != HunkContext && l.Type != HunkDeleted {
return false
}
if normalizeLine(l.Content) != target {
return false
}
}
return true
}
// resolveFromFileContent scans the new file content line-by-line for consecutive
// matches of the normalized existing_code. Ported from Java's findConsecutiveLines.
func resolveFromFileContent(d *model.Diff, cm *model.LlmComment) bool {
if d.NewFileContent == "" {
return false
}
fileLines := strings.Split(d.NewFileContent, "\n")
targetLines := splitAndNormalize(cm.ExistingCode)
if len(targetLines) == 0 || len(fileLines) < len(targetLines) {
return false
}
for i := 0; i <= len(fileLines)-len(targetLines); i++ {
matched := true
for j, target := range targetLines {
if normalizeLine(strings.TrimRight(fileLines[i+j], "\r")) != target {
matched = false
break
}
}
if matched {
cm.StartLine = i + 1
cm.EndLine = i + len(targetLines)
return true
}
}
return false
}
// splitAndNormalize splits code text into lines and normalizes each one.
func splitAndNormalize(code string) []string {
raw := strings.Split(code, "\n")
result := make([]string, 0, len(raw))
for _, line := range raw {
n := normalizeLine(line)
if n == "" {
continue
}
result = append(result, n)
}
return result
}
// normalizeLine removes leading/trailing whitespace and strips any leading
// '+' or '-' diff marker (mirrors Java's processTargetLineCode).
func normalizeLine(s string) string {
s = strings.TrimSpace(s)
s = strings.TrimPrefix(s, "+")
s = strings.TrimPrefix(s, "-")
return strings.TrimSpace(s)
}

View file

@ -0,0 +1,234 @@
package diff
import (
"testing"
"github.com/open-code-review/open-code-review/internal/model"
)
const testDiff = `diff --git a/pkg/example/handler.go b/pkg/example/handler.go
--- a/pkg/example/handler.go
+++ b/pkg/example/handler.go
@@ -10,7 +10,7 @@ func HandleRequest(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
- log.Print("handling request")
+ log.Printf("handling request: %s", r.URL.Path)
err := process(ctx)`
func TestResolveLineNumbers_SingleLineHunkMatch(t *testing.T) {
diffs := []model.Diff{
{NewPath: "pkg/example/handler.go", Diff: testDiff},
}
comments := []model.LlmComment{
{Path: "pkg/example/handler.go", ExistingCode: ` log.Print("handling request")`},
}
result := ResolveLineNumbers(comments, diffs)
if len(result) != 1 {
t.Fatalf("expected 1 comment, got %d", len(result))
}
cm := result[0]
if cm.StartLine == 0 || cm.EndLine == 0 {
t.Errorf("expected non-zero line numbers, got StartLine=%d EndLine=%d", cm.StartLine, cm.EndLine)
}
// The existing code is at old-file line 11.
// Diff: @@ -10,7 → context "ctx := r.Context()" is old line 10, offset becomes 1.
// Then deleted line "log.Print..." matches → OldStart(10) + offset-before-match...
// Actually offset increments AFTER each FROM-side check, so it's still 0 when we hit line 0 (context).
// After context line, offset=1. Deleted line at index 1 tries match with offset=1 → startLine=11.
// Wait — need to trace carefully: ctx line is HunkContext, offset++ makes it 1 before next iteration.
// So deleted line sees offset=1, startLine = 10+1 = 11 ✓
if cm.StartLine != 11 || cm.EndLine != 11 {
t.Errorf("expected 11..11, got %d..%d", cm.StartLine, cm.EndLine)
}
}
func TestResolveLineNumbers_WhitespaceTolerant(t *testing.T) {
diffs := []model.Diff{
{NewPath: "pkg/example/handler.go", Diff: testDiff},
}
// LLM may return indented or differently formatted code
comments := []model.LlmComment{
{Path: "pkg/example/handler.go", ExistingCode: `log.Print("handling request")`},
}
result := ResolveLineNumbers(comments, diffs)
cm := result[0]
if cm.StartLine != 11 || cm.EndLine != 11 {
t.Errorf("whitespace-tolerant match: expected 11..11, got %d..%d", cm.StartLine, cm.EndLine)
}
}
func TestResolveLineNumbers_MultiLineHunkMatch(t *testing.T) {
rawMulti := `diff --git a/test.go b/test.go
--- a/test.go
+++ b/test.go
@@ -5,4 +5,4 @@ import "fmt"
func foo() {
- x := 1
- y := 2
+ x := 10
+ y := 20
}`
diffs := []model.Diff{
{NewPath: "test.go", Diff: rawMulti},
}
comments := []model.LlmComment{
{Path: "test.go", ExistingCode: ` x := 1
y := 2`},
}
result := ResolveLineNumbers(comments, diffs)
cm := result[0]
if cm.StartLine == 0 || cm.EndLine == 0 {
t.Errorf("multiline hunk match: expected non-zero lines, got StartLine=%d EndLine=%d", cm.StartLine, cm.EndLine)
}
if cm.StartLine != 6 || cm.EndLine != 7 {
t.Errorf("expected 6..7, got %d..%d", cm.StartLine, cm.EndLine)
}
}
func TestResolveLineNumbers_FallbackToFileContent(t *testing.T) {
// Code that doesn't appear in diff hunks but exists in file content
raw := `diff --git a/test.go b/test.go
--- a/test.go
+++ b/test.go
@@ -1,3 +1,4 @@
package main
+import "fmt"
func foo() {}`
diffs := []model.Diff{
{NewPath: "test.go", Diff: raw, NewFileContent: `package main
import "fmt"
func foo() {}`},
}
comments := []model.LlmComment{
{Path: "test.go", ExistingCode: `package main
import "fmt"`},
}
result := ResolveLineNumbers(comments, diffs)
cm := result[0]
// Fallback should find these consecutive lines starting at line 1
if cm.StartLine != 1 || cm.EndLine != 2 {
t.Errorf("fallback: expected 1..2, got %d..%d", cm.StartLine, cm.EndLine)
}
}
func TestResolveLineNumbers_NoMatchKeepsZero(t *testing.T) {
diffs := []model.Diff{
{NewPath: "test.go", Diff: testDiff},
}
comments := []model.LlmComment{
{Path: "test.go", ExistingCode: `totally unrelated code`},
}
result := ResolveLineNumbers(comments, diffs)
cm := result[0]
if cm.StartLine != 0 || cm.EndLine != 0 {
t.Errorf("no match: expected 0..0, got %d..%d", cm.StartLine, cm.EndLine)
}
}
func TestResolveLineNumbers_NoExistingCode(t *testing.T) {
diffs := []model.Diff{
{NewPath: "test.go", Diff: testDiff},
}
comments := []model.LlmComment{
{Path: "test.go", Content: "some comment without existing_code"},
}
result := ResolveLineNumbers(comments, diffs)
if result[0].StartLine != 0 {
t.Errorf("empty ExistingCode: expected 0, got %d", result[0].StartLine)
}
}
func TestResolveLineNumbers_PathNotFound(t *testing.T) {
diffs := []model.Diff{
{NewPath: "other.go", Diff: testDiff},
}
comments := []model.LlmComment{
{Path: "missing.go", ExistingCode: `some code`},
}
result := ResolveLineNumbers(comments, diffs)
if result[0].StartLine != 0 {
t.Errorf("path not found: expected 0, got %d", result[0].StartLine)
}
}
func TestResolveLineNumbers_EmptyInputs(t *testing.T) {
// No comments
r1 := ResolveLineNumbers([]model.LlmComment{}, []model.Diff{{}})
if len(r1) != 0 {
t.Errorf("empty comments: expected 0 results, got %d", len(r1))
}
// No diffs — returns comments unchanged (line numbers stay at 0)
r2 := ResolveLineNumbers([]model.LlmComment{{}}, []model.Diff{})
if len(r2) != 1 || r2[0].StartLine != 0 {
t.Errorf("empty diffs: expected 1 result with StartLine=0, got %d", len(r2))
}
}
func TestNormalizeLine(t *testing.T) {
tests := []struct {
input string
want string
}{
{" hello ", "hello"},
{"+added line", "added line"},
{"-deleted line", "deleted line"},
{"\tindented\t", "indented"},
{"", ""},
}
for _, tt := range tests {
got := normalizeLine(tt.input)
if got != tt.want {
t.Errorf("normalizeLine(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestSplitAndNormalize_SkipsEmptyLines(t *testing.T) {
lines := splitAndNormalize(`line1
line2`)
if len(lines) != 2 {
t.Errorf("expected 2 lines, got %d", len(lines))
}
if lines[0] != "line1" || lines[1] != "line2" {
t.Errorf("got %v", lines)
}
}
func TestResolveFromHunk_ContextLinesOnly(t *testing.T) {
// When existing_code matches context (unchanged) lines rather than deleted ones
raw := `diff --git a/test.go b/test.go
--- a/test.go
+++ b/test.go
@@ -3,3 +3,4 @@
func main() {
fmt.Println("hello")
+ fmt.Println("world")
}`
diffs := []model.Diff{{NewPath: "test.go", Diff: raw}}
comments := []model.LlmComment{
{Path: "test.go", ExistingCode: ` fmt.Println("hello")`},
}
result := ResolveLineNumbers(comments, diffs)
cm := result[0]
if cm.StartLine == 0 {
t.Errorf("context-line match: expected non-zero start, got 0")
}
if cm.StartLine != 4 {
t.Errorf("expected line 4, got %d", cm.StartLine)
}
}

File diff suppressed because it is too large Load diff

1004
internal/llm/client.go Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,75 @@
package llm
import (
"embed"
"encoding/base64"
"fmt"
"strconv"
"strings"
tiktoken "github.com/pkoukk/tiktoken-go"
)
//go:embed bpe_data/*.tiktoken
var embedFS embed.FS
const embedPrefix = "bpe_data/"
// initEmbeddedLoader configures tiktoken to use embedded BPE data instead of fetching from network.
// Call this once during application startup, before any GetEncoding/EncodingForModel calls.
func InitEmbeddedLoader() {
loader := &embeddedBpeLoader{}
tiktoken.SetBpeLoader(loader)
}
// embeddedBpeLoader implements tiktoken.BpeLoader interface.
// It maps known encoding URLs to local embedded files, eliminating network dependency.
type embeddedBpeLoader struct{}
var urlToFileMap = map[string]string{
"https://openaipublic.blob.core.windows.net/encodings/cl100k_base.tiktoken": "cl100k_base.tiktoken",
"https://openaipublic.blob.core.windows.net/encodings/o200k_base.tiktoken": "o200k_base.tiktoken",
"https://openaipublic.blob.core.windows.net/encodings/p50k_base.tiktoken": "p50k_base.tiktoken",
"https://openaipublic.blob.core.windows.net/encodings/r50k_base.tiktoken": "r50k_base.tiktoken",
}
func (l *embeddedBpeLoader) LoadTiktokenBpe(tiktokenBpeFile string) (map[string]int, error) {
if localName, ok := urlToFileMap[tiktokenBpeFile]; ok {
return loadFromEmbed(localName)
}
return nil, fmt.Errorf("tiktoken encoding file %q is not embedded and cannot be fetched offline", tiktokenBpeFile)
}
func loadFromEmbed(filename string) (map[string]int, error) {
data, err := embedFS.ReadFile(embedPrefix + filename)
if err != nil {
return nil, fmt.Errorf("embedded tiktoken file %q not found: %w", filename, err)
}
return parseBpeData(data)
}
// parseBpeData parses the base64-encoded BPE data format.
// Each line: <base64-token> <rank>
func parseBpeData(data []byte) (map[string]int, error) {
bpeRanks := make(map[string]int)
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
parts := strings.SplitN(line, " ", 2)
if len(parts) != 2 {
return nil, fmt.Errorf("invalid bpe data line: %q", line)
}
token, err := base64.StdEncoding.DecodeString(parts[0])
if err != nil {
return nil, fmt.Errorf("failed to decode token %q: %w", line, err)
}
rank, err := strconv.Atoi(parts[1])
if err != nil {
return nil, fmt.Errorf("invalid rank in line %q: %w", line, err)
}
bpeRanks[string(token)] = rank
}
return bpeRanks, nil
}

229
internal/llm/resolver.go Normal file
View file

@ -0,0 +1,229 @@
package llm
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
)
// ResolvedEndpoint holds the resolved LLM endpoint configuration.
type ResolvedEndpoint struct {
URL string
Token string
Model string
Protocol string // "anthropic" or "openai"
Source string // human-readable config source label
}
// Environment variable names for OCR-specific configuration.
const (
envOCRLLMURL = "OCR_LLM_URL"
envOCRLLMToken = "OCR_LLM_TOKEN"
envOCRLLMModel = "OCR_LLM_MODEL"
envOCRUseAnthropic = "OCR_USE_ANTHROPIC"
)
// Environment variable names from Claude Code configuration.
const (
envCCBaseURL = "ANTHROPIC_BASE_URL"
envCCToken = "ANTHROPIC_AUTH_TOKEN"
envCCModel = "ANTHROPIC_MODEL"
)
// ResolveEndpoint reads from 4 strategy sources in priority order.
// Each strategy requires all three fields (URL, Token, Model) to be non-empty.
// Returns the first valid strategy's result.
func ResolveEndpoint(configPath string) (ResolvedEndpoint, error) {
strategies := []struct {
name string
fn func() (ResolvedEndpoint, bool, error)
}{
{"OCR environment", tryOCREnv},
{"OCR config file", func() (ResolvedEndpoint, bool, error) { return tryOCRConfig(configPath) }},
{"Claude Code environment", tryCCEnv},
{"Shell rc file", tryShellRC},
}
for _, s := range strategies {
ep, ok, err := s.fn()
if err != nil {
return ResolvedEndpoint{}, fmt.Errorf("resolve %s: %w", s.name, err)
}
if ok && ep.URL != "" && ep.Token != "" && ep.Model != "" {
ep.Source = s.name
return ep, nil
}
}
return ResolvedEndpoint{}, fmt.Errorf("no valid LLM endpoint configured; one of OCR_LLM_URL/OCR_LLM_TOKEN/OCR_LLM_MODEL, ~/.open-code-review/config.json, or ANTHROPIC_BASE_URL/ANTHROPIC_AUTH_TOKEN/ANTHROPIC_MODEL must be set")
}
// tryOCREnv reads OCR-specific environment variables.
func tryOCREnv() (ResolvedEndpoint, bool, error) {
url := os.Getenv(envOCRLLMURL)
token := os.Getenv(envOCRLLMToken)
model := os.Getenv(envOCRLLMModel)
if url == "" || token == "" || model == "" {
return ResolvedEndpoint{}, false, nil
}
useAnthropic := true // default true
if v := os.Getenv(envOCRUseAnthropic); v != "" {
lower := strings.ToLower(v)
useAnthropic = lower == "true" || lower == "1" || lower == "yes"
}
protocol := "anthropic"
if !useAnthropic {
protocol = "openai"
}
return ResolvedEndpoint{URL: url, Token: token, Model: model, Protocol: protocol, Source: "OCR environment"}, true, nil
}
// llmFileConfig represents the llm section in config.json.
type llmFileConfig struct {
URL string `json:"url,omitempty"`
AuthToken string `json:"auth_token,omitempty"`
Model string `json:"model,omitempty"`
UseAnthropic *bool `json:"use_anthropic,omitempty"` // pointer to distinguish unset from false
}
type configFile struct {
Llm llmFileConfig `json:"llm,omitempty"`
}
// tryOCRConfig reads the OCR config file.
func tryOCRConfig(path string) (ResolvedEndpoint, bool, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return ResolvedEndpoint{}, false, nil
}
return ResolvedEndpoint{}, false, err
}
var cfg configFile
if err := json.Unmarshal(data, &cfg); err != nil {
return ResolvedEndpoint{}, false, fmt.Errorf("parse config: %w", err)
}
if cfg.Llm.URL == "" || cfg.Llm.AuthToken == "" || cfg.Llm.Model == "" {
return ResolvedEndpoint{}, false, nil
}
useAnthropic := true // default true
if cfg.Llm.UseAnthropic != nil {
useAnthropic = *cfg.Llm.UseAnthropic
}
protocol := "anthropic"
if !useAnthropic {
protocol = "openai"
}
return ResolvedEndpoint{URL: cfg.Llm.URL, Token: cfg.Llm.AuthToken, Model: cfg.Llm.Model, Protocol: protocol, Source: "OCR config file"}, true, nil
}
// tryCCEnv reads Claude Code environment variables.
func tryCCEnv() (ResolvedEndpoint, bool, error) {
baseURL := os.Getenv(envCCBaseURL)
token := os.Getenv(envCCToken)
model := os.Getenv(envCCModel)
if baseURL == "" || token == "" || model == "" {
return ResolvedEndpoint{}, false, nil
}
url := ensureMessagesSuffix(baseURL)
return ResolvedEndpoint{URL: url, Token: token, Model: model, Protocol: "anthropic", Source: "Claude Code environment"}, true, nil
}
// tryShellRC parses ~/.zshrc and ~/.bashrc for ANTHROPIC_* exports.
func tryShellRC() (ResolvedEndpoint, bool, error) {
files := shellRCFiles()
for _, f := range files {
ep, ok, err := parseShellRC(f)
if err != nil || ok {
return ep, ok, err
}
}
return ResolvedEndpoint{}, false, nil
}
func shellRCFiles() []string {
home, err := os.UserHomeDir()
if err != nil {
return nil
}
candidates := []string{
filepath.Join(home, ".zshrc"),
filepath.Join(home, ".bashrc"),
filepath.Join(home, ".bash_profile"),
filepath.Join(home, ".profile"),
}
var valid []string
for _, f := range candidates {
if _, err := os.Stat(f); err == nil {
valid = append(valid, f)
}
}
return valid
}
var exportRe = regexp.MustCompile(`^export\s+(ANTHROPIC_\w+)\s*=\s*(?:"([^"]*)"|'([^']*)'|(.+))\s*$`)
func parseShellRC(path string) (ResolvedEndpoint, bool, error) {
data, err := os.ReadFile(path)
if err != nil {
return ResolvedEndpoint{}, false, nil
}
var baseURL, token, model string
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
matches := exportRe.FindStringSubmatch(line)
if matches == nil {
continue
}
key := matches[1]
value := matches[2]
if value == "" {
value = matches[3]
}
if value == "" {
value = matches[4]
}
value = strings.TrimSpace(value)
switch key {
case "ANTHROPIC_BASE_URL":
baseURL = value
case "ANTHROPIC_AUTH_TOKEN":
token = value
case "ANTHROPIC_MODEL":
model = value
}
}
if baseURL == "" || token == "" || model == "" {
return ResolvedEndpoint{}, false, nil
}
url := ensureMessagesSuffix(baseURL)
return ResolvedEndpoint{URL: url, Token: token, Model: model, Protocol: "anthropic", Source: "Shell rc file"}, true, nil
}
// ensureMessagesSuffix appends /v1/messages to base URLs that lack a versioned path.
func ensureMessagesSuffix(rawURL string) string {
u := strings.TrimRight(rawURL, "/")
if strings.Contains(u, "/v1/") {
// Already has versioned path — don't modify.
return rawURL
}
return u + "/v1/messages"
}

View file

@ -0,0 +1,113 @@
package llm
import (
"encoding/json"
"strings"
)
// UsageInfo holds token usage extracted from an LLM API response.
type UsageInfo struct {
TotalTokens int64 `json:"total_tokens"`
PromptTokens int64 `json:"prompt_tokens"`
CompletionTokens int64 `json:"completion_tokens"`
CacheReadTokens int64 `json:"cache_read_tokens,omitempty"`
CacheWriteTokens int64 `json:"cache_write_tokens,omitempty"`
}
var promptTokensPaths = []string{
"usage.prompt_tokens", // OpenAI standard
"prompt_tokens", // flat at root
"data.usage.prompt_tokens", // wrapped in data layer
}
var completionTokensPaths = []string{
"usage.completion_tokens", // OpenAI standard
"completion_tokens", // flat at root
"data.usage.completion_tokens", // wrapped in data layer
}
var cacheReadTokensPaths = []string{
"usage.cache_read_input_tokens", // Anthropic
"cache_read_input_tokens", // flat at root
"usage.prompt_tokens_details.cache_tokens_hit", // some providers
"usage.prompt_tokens_details.cache_tokens", // some providers
}
var cacheWriteTokensPaths = []string{
"usage.cache_creation_input_tokens", // Anthropic / proxy
"cache_creation_input_tokens", // flat at root
}
// totalTokensPaths is an ordered list of JSON paths to try when extracting
// total token count from a response body. Paths are dot-separated keys that
// navigate through nested map[string]any objects. The first match wins.
var totalTokensPaths = []string{
"usage.total_tokens", // OpenAI standard
"total_tokens", // flat at root
"data.usage.total_tokens", // wrapped in data layer (some proxy APIs)
}
// resolveUsage parses raw JSON bytes into a map and extracts token usage
// by probing configured paths sequentially. Returns nil if no total_tokens found.
func resolveUsage(raw []byte) *UsageInfo {
var rawBody map[string]any
if err := json.Unmarshal(raw, &rawBody); err != nil {
return nil
}
total, hasAny := probePath(rawBody, totalTokensPaths)
prompt, _ := probePath(rawBody, promptTokensPaths)
completion, _ := probePath(rawBody, completionTokensPaths)
cacheRead, _ := probePath(rawBody, cacheReadTokensPaths)
cacheWrite, _ := probePath(rawBody, cacheWriteTokensPaths)
if !hasAny && prompt == 0 && completion == 0 {
return nil
}
ui := &UsageInfo{
TotalTokens: total,
PromptTokens: prompt,
CompletionTokens: completion,
CacheReadTokens: cacheRead,
CacheWriteTokens: cacheWrite,
}
// If TotalTokens wasn't explicitly available but we have prompt+completion, compute it.
if total == 0 && (prompt > 0 || completion > 0) {
ui.TotalTokens = prompt + completion + cacheRead + cacheWrite
}
return ui
}
// probePath walks through each candidate path in order, returning the first
// int64 value found along with true. Returns (0, false) if none match.
func probePath(root map[string]any, paths []string) (int64, bool) {
for _, p := range paths {
parts := strings.Split(p, ".")
var current any = root
for _, part := range parts {
obj, ok := current.(map[string]any)
if !ok {
goto next
}
current, ok = obj[part]
if !ok {
goto next
}
}
switch v := current.(type) {
case float64:
return int64(v), true
case int64:
return v, true
case int:
return int64(v), true
}
next:
}
return 0, false
}

14
internal/model/diff.go Normal file
View file

@ -0,0 +1,14 @@
package model
// Diff represents a single file change in a git diff.
type Diff struct {
OldPath string `json:"old_path"`
NewPath string `json:"new_path"`
Diff string `json:"diff"`
NewFileContent string `json:"new_file_content"`
IsBinary bool `json:"is_binary"`
IsDeleted bool `json:"is_deleted"`
IsNew bool `json:"is_new"`
Insertions int64 `json:"insertions"`
Deletions int64 `json:"deletions"`
}

20
internal/model/review.go Normal file
View file

@ -0,0 +1,20 @@
package model
// LlmComment represents a code review comment generated by the LLM.
type LlmComment struct {
Path string `json:"path"`
Content string `json:"content"`
SuggestionCode string `json:"suggestion_code,omitempty"`
ExistingCode string `json:"existing_code,omitempty"`
StartLine int `json:"start_line"`
EndLine int `json:"end_line"`
Thinking string `json:"thinking,omitempty"`
}
// CodeReviewResult holds raw LLM-generated review suggestion for a code segment.
type CodeReviewResult struct {
RelevantFile string `json:"relevant_file"`
SuggestionContent string `json:"suggestion_content"`
ExistingCode string `json:"existing_code"`
SuggestionCode string `json:"suggestion_code"`
}

265
internal/session/history.go Normal file
View file

@ -0,0 +1,265 @@
// Package session provides a session history mechanism for collecting conversation
// records during code review task execution. It organizes records by file path
// and request type (plan_task, main_task, memory_compression_task).
package session
import (
"fmt"
"sync"
"time"
"github.com/open-code-review/open-code-review/internal/llm"
)
// TaskType identifies the kind of LLM request within a file subtask.
type TaskType string
const (
PlanTask TaskType = "plan_task"
MainTask TaskType = "main_task"
MemoryCompressionTask TaskType = "memory_compression_task"
)
// SessionHistory is the top-level container for an entire CR run.
// It is safe for concurrent use by multiple goroutines.
type SessionHistory struct {
mu sync.Mutex
SessionID string
RepoDir string
GitBranch string
Model string
StartTime time.Time
EndTime time.Time
persist *jsonlWriter
FileSessions map[string]*FileSession
}
// FileSession represents the conversation records for a single file subtask.
type FileSession struct {
mu sync.Mutex
FilePath string
TaskRecords map[TaskType][]*TaskRecord
session *SessionHistory // back-reference for JSONL persistence
}
// TaskRecord captures a single LLM request-response cycle within a file subtask.
type TaskRecord struct {
Type TaskType
RequestNo int // sequential number within this task type
RequestMessages []llm.Message // messages sent to LLM
Response *ResponseRecord
ToolResults []ToolResultRecord
Duration time.Duration
Error string
fileSession *FileSession // back-reference for JSONL persistence
}
// TokenUsage holds estimated token usage for a single LLM request/response cycle.
// Calculated locally using tiktoken so it works with any model provider.
type TokenUsage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
CacheReadTokens int `json:"cache_read_tokens,omitempty"`
CacheWriteTokens int `json:"cache_write_tokens,omitempty"`
}
// ResponseRecord holds the parsed LLM response.
type ResponseRecord struct {
Content string
ToolCalls []llm.ToolCall
Model string
Usage *TokenUsage
}
// ToolResultRecord records the result of a tool call executed after the LLM response.
type ToolResultRecord struct {
ToolName string
Arguments string
Result string
}
// New creates a new SessionHistory with the given repo directory.
func New(repoDir, gitBranch, model string) *SessionHistory {
sessionID := generateUUID()
sh := &SessionHistory{
SessionID: sessionID,
RepoDir: repoDir,
GitBranch: gitBranch,
Model: model,
StartTime: time.Now(),
FileSessions: make(map[string]*FileSession),
}
p, err := newJSONLWriter(sessionID, repoDir, gitBranch, model)
if err != nil {
fmt.Printf("[ocr session] warning: failed to create session writer: %v\n", err)
} else {
sh.persist = p
p.WriteSessionStart(sh.StartTime)
}
return sh
}
// GetOrCreateFileSession returns the FileSession for the given file path,
// creating one if it doesn't exist yet.
func (sh *SessionHistory) GetOrCreateFileSession(filePath string) *FileSession {
sh.mu.Lock()
defer sh.mu.Unlock()
fs, ok := sh.FileSessions[filePath]
if !ok {
fs = &FileSession{
FilePath: filePath,
TaskRecords: make(map[TaskType][]*TaskRecord),
session: sh,
}
sh.FileSessions[filePath] = fs
}
return fs
}
// Finalize marks the session as complete, sets the end time, and persists
// the final summary record.
func (sh *SessionHistory) Finalize() {
sh.mu.Lock()
sh.EndTime = time.Now()
p := sh.persist
duration := sh.EndTime.Sub(sh.StartTime)
filesReviewed := make([]string, 0, len(sh.FileSessions))
for fp := range sh.FileSessions {
filesReviewed = append(filesReviewed, fp)
}
sh.mu.Unlock()
if p != nil {
p.WriteSessionEnd(duration, filesReviewed)
}
}
// AppendTaskRecord adds a new task record to the file session for the given
// file path and task type. It auto-assigns the RequestNo based on existing records
// and writes an llm_request record to the JSONL stream.
func (fs *FileSession) AppendTaskRecord(taskType TaskType, messages []llm.Message) *TaskRecord {
fs.mu.Lock()
defer fs.mu.Unlock()
rec := &TaskRecord{
Type: taskType,
RequestNo: len(fs.TaskRecords[taskType]) + 1,
RequestMessages: copyMessages(messages),
fileSession: fs,
}
fs.TaskRecords[taskType] = append(fs.TaskRecords[taskType], rec)
if p := fs.session.persist; p != nil {
p.WriteLLMRequest(fs.FilePath, taskType, rec.RequestNo, copyMessagesForJSON(messages))
}
return rec
}
// copyMessages returns a deep copy of a messages slice so that future mutations
// don't corrupt stored records.
func copyMessages(msgs []llm.Message) []llm.Message {
cp := make([]llm.Message, len(msgs))
for i, m := range msgs {
cp[i] = llm.Message{
Role: m.Role,
Content: m.Content,
ToolCallID: m.ToolCallID,
ToolCalls: append([]llm.ToolCall(nil), m.ToolCalls...),
}
}
return cp
}
// copyMessagesForJSON produces a JSON-friendly slice for persistence.
func copyMessagesForJSON(msgs []llm.Message) any {
type msg struct {
Role string `json:"role"`
Content any `json:"content"`
ToolCallID string `json:"tool_call_id,omitempty"`
}
out := make([]msg, 0, len(msgs))
for _, m := range msgs {
out = append(out, msg{
Role: m.Role,
Content: m.Content,
ToolCallID: m.ToolCallID,
})
}
return out
}
// SetResponse records the LLM response in the most recent TaskRecord of the given type.
// It computes estimated token usage locally using tiktoken, independent of any API format,
// and writes an llm_response record to the JSONL stream.
func (tr *TaskRecord) SetResponse(resp *llm.ChatResponse, duration time.Duration) {
if resp == nil || len(resp.Choices) == 0 {
tr.Error = "empty response"
tr.Duration = duration
return
}
choice := resp.Choices[0]
content := ""
if choice.Message.Content != nil {
content = *choice.Message.Content
}
// Estimate tokens using tiktoken — works with any model provider.
var promptTokens int
for _, m := range tr.RequestMessages {
promptTokens += llm.CountTokens(m.ExtractText())
}
completionTokens := llm.CountTokens(content)
usage := &TokenUsage{
PromptTokens: promptTokens,
CompletionTokens: completionTokens,
}
tr.Response = &ResponseRecord{
Content: content,
ToolCalls: choice.Message.ToolCalls,
Model: resp.Model,
Usage: usage,
}
tr.Duration = duration
if fs := tr.fileSession; fs != nil {
if p := fs.session.persist; p != nil {
toolCallsJSON := make([]map[string]any, 0, len(choice.Message.ToolCalls))
for _, tc := range choice.Message.ToolCalls {
toolCallsJSON = append(toolCallsJSON, map[string]any{
"id": tc.ID,
"name": tc.Function.Name,
"arguments": tc.Function.Arguments,
})
}
p.WriteLLMResponse(fs.FilePath, tr.Type, content, toolCallsJSON, resp.Model, promptTokens, completionTokens, duration)
}
}
}
// SetError records an error for this task record.
func (tr *TaskRecord) SetError(err error, duration time.Duration) {
tr.Error = err.Error()
tr.Duration = duration
}
// AddToolResult appends a tool call result to this task record and writes a
// tool_call record to the JSONL stream.
func (tr *TaskRecord) AddToolResult(toolName, arguments, result string) {
tr.ToolResults = append(tr.ToolResults, ToolResultRecord{
ToolName: toolName,
Arguments: arguments,
Result: result,
})
if fs := tr.fileSession; fs != nil {
if p := fs.session.persist; p != nil {
p.WriteToolCall(fs.FilePath, tr.Type, toolName, arguments, result, true, 0)
}
}
}

225
internal/session/persist.go Normal file
View file

@ -0,0 +1,225 @@
package session
import (
"bufio"
"crypto/rand"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
// jsonlWriter streams session records to a JSONL file under
// $HOME/.open-code-review/sessions/<encoded-repo-path>/<session-id>.jsonl.
// It is safe for concurrent use by multiple goroutines.
type jsonlWriter struct {
mu sync.Mutex
sessionID string
repoDir string
gitBranch string
model string
file *os.File
writer *bufio.Writer
lastUUID string // tracks chain of records via parentUuid
}
// newJSONLWriter creates and opens a new JSONL writer for the given session.
func newJSONLWriter(sessionID, repoDir, gitBranch, model string) (*jsonlWriter, error) {
jw := &jsonlWriter{
sessionID: sessionID,
repoDir: repoDir,
gitBranch: gitBranch,
model: model,
}
if err := jw.open(); err != nil {
return nil, err
}
return jw, nil
}
func generateUUID() string {
b := make([]byte, 16)
_, err := io.ReadFull(rand.Reader, b)
if err != nil {
// Fallback — extremely unlikely but keeps things working without panics.
return fmt.Sprintf("fallback-%d", time.Now().UnixNano())
}
b[6] = (b[6] & 0x0f) | 0x40 // version 4
b[8] = (b[8] & 0x3f) | 0x80 // variant 1
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
b[0:4], b[4:6], b[6:8], b[8:10], b[10:])
}
func encodeRepoPath(p string) string {
p = strings.TrimPrefix(p, "/")
return strings.ReplaceAll(p, "/", "-")
}
func (jw *jsonlWriter) open() error {
home, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("resolve home dir: %w", err)
}
sessionDir := filepath.Join(home, ".open-code-review", "sessions", encodeRepoPath(jw.repoDir))
if err := os.MkdirAll(sessionDir, 0755); err != nil {
return fmt.Errorf("create session dir: %w", err)
}
filename := filepath.Join(sessionDir, jw.sessionID+".jsonl")
f, err := os.OpenFile(filename, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
if err != nil {
return fmt.Errorf("open session file: %w", err)
}
jw.file = f
jw.writer = bufio.NewWriter(f)
return nil
}
func (jw *jsonlWriter) writeRecordLocked(rec map[string]any) {
data, err := json.Marshal(rec)
if err != nil {
fmt.Printf("[ocr session] failed to marshal record: %v\n", err)
return
}
jw.writer.Write(data)
jw.writer.WriteByte('\n')
}
// WriteSessionStart writes the initial session_start record.
func (jw *jsonlWriter) WriteSessionStart(startTime time.Time) string {
uuid := generateUUID()
rec := map[string]any{
"uuid": uuid,
"parentUuid": nil,
"type": "session_start",
"sessionId": jw.sessionID,
"timestamp": startTime.UTC().Format(time.RFC3339),
"cwd": jw.repoDir,
"gitBranch": jw.gitBranch,
"model": jw.model,
}
jw.mu.Lock()
defer jw.mu.Unlock()
jw.writeRecordLocked(rec)
jw.lastUUID = uuid
return uuid
}
// WriteLLMRequest writes a request entry with the resolved messages.
func (jw *jsonlWriter) WriteLLMRequest(filePath string, taskType TaskType, requestNo int, messages any) string {
uuid := generateUUID()
jw.mu.Lock()
defer jw.mu.Unlock()
rec := map[string]any{
"uuid": uuid,
"parentUuid": jw.lastUUID,
"type": "llm_request",
"sessionId": jw.sessionID,
"timestamp": time.Now().UTC().Format(time.RFC3339),
"filePath": filePath,
"taskType": string(taskType),
"request_no": requestNo,
"messages": messages,
}
jw.writeRecordLocked(rec)
jw.lastUUID = uuid
return uuid
}
// WriteLLMResponse writes a response entry with model, content, tool calls, usage.
func (jw *jsonlWriter) WriteLLMResponse(filePath string, taskType TaskType, content string, toolCalls []map[string]any, model string, promptTokens, completionTokens int, duration time.Duration) string {
uuid := generateUUID()
jw.mu.Lock()
defer jw.mu.Unlock()
rec := map[string]any{
"uuid": uuid,
"parentUuid": jw.lastUUID,
"type": "llm_response",
"sessionId": jw.sessionID,
"timestamp": time.Now().UTC().Format(time.RFC3339),
"filePath": filePath,
"taskType": string(taskType),
"model": model,
"content": content,
"tool_calls": toolCalls,
"duration_ms": duration.Milliseconds(),
"usage": map[string]int{
"prompt_tokens": promptTokens,
"completion_tokens": completionTokens,
},
}
jw.writeRecordLocked(rec)
jw.lastUUID = uuid
return uuid
}
// WriteToolCall writes a tool call result entry.
func (jw *jsonlWriter) WriteToolCall(filePath string, taskType TaskType, toolName, arguments, result string, ok bool, duration time.Duration) string {
uuid := generateUUID()
jw.mu.Lock()
defer jw.mu.Unlock()
rec := map[string]any{
"uuid": uuid,
"parentUuid": jw.lastUUID,
"type": "tool_call",
"sessionId": jw.sessionID,
"timestamp": time.Now().UTC().Format(time.RFC3339),
"filePath": filePath,
"taskType": string(taskType),
"tool_name": toolName,
"arguments": arguments,
"result": result,
"ok": ok,
"duration_ms": duration.Milliseconds(),
}
jw.writeRecordLocked(rec)
jw.lastUUID = uuid
return uuid
}
// WriteSessionEnd writes the final session_end summary record and closes the file.
func (jw *jsonlWriter) WriteSessionEnd(duration time.Duration, filesReviewed []string) {
uuid := generateUUID()
jw.mu.Lock()
defer jw.mu.Unlock()
rec := map[string]any{
"uuid": uuid,
"parentUuid": jw.lastUUID,
"type": "session_end",
"sessionId": jw.sessionID,
"timestamp": time.Now().UTC().Format(time.RFC3339),
"files_reviewed": filesReviewed,
"duration_seconds": duration.Seconds(),
}
jw.writeRecordLocked(rec)
jw.lastUUID = uuid
if jw.writer != nil {
jw.writer.Flush()
}
if jw.file != nil {
jw.file.Close()
}
}
func (jw *jsonlWriter) flushAndClose() {
jw.mu.Lock()
defer jw.mu.Unlock()
if jw.writer != nil {
jw.writer.Flush()
}
if jw.file != nil {
jw.file.Close()
}
}

40
internal/stdout/stdout.go Normal file
View file

@ -0,0 +1,40 @@
package stdout
import (
"io"
"os"
"sync"
)
var (
w io.Writer = os.Stdout
mu sync.RWMutex
)
// Writer returns the current stdout writer (real stdout or discard).
func Writer() io.Writer {
mu.RLock()
defer mu.RUnlock()
return w
}
// Quiet replaces stdout with io.Discard and returns a cleanup function.
// Usage:
//
// defer stdout.Quiet()()
//
// WARNING: Quiet must ONLY be called from the main goroutine, before spawning
// any concurrent work that writes to stdout, and its returned cleanup must be
// deferred in the same goroutine. Never call Quiet from multiple goroutines
// concurrently — it is not designed for nested or parallel silencing.
func Quiet() func() {
mu.Lock()
old := w
w = io.Discard
mu.Unlock()
return func() {
mu.Lock()
w = old
mu.Unlock()
}
}

View file

@ -0,0 +1,75 @@
// Package suggestdiff provides line-level diff computation between code snippets,
// used for CLI rendering of review suggestions with ANSI color codes.
package suggestdiff
import "strings"
// DiffLineType marks a line as context, added, or deleted.
type DiffLineType int
const (
DiffContext DiffLineType = iota
DiffAdded
DiffDeleted
)
// DiffLine is a single line in the diff result.
type DiffLine struct {
Type DiffLineType
Content string
}
// ComputeLineDiff returns a line-level diff between oldLines and newLines.
// Uses Myers-style LCS to find common subsequences, then emits context/added/deleted lines.
func ComputeLineDiff(oldLines, newLines []string) []DiffLine {
m, n := len(oldLines), len(newLines)
if m == 0 && n == 0 {
return nil
}
// LCS DP table
lcs := make([][]int, m+1)
for i := range lcs {
lcs[i] = make([]int, n+1)
}
for i := 1; i <= m; i++ {
for j := 1; j <= n; j++ {
if strings.EqualFold(strings.TrimSpace(oldLines[i-1]), strings.TrimSpace(newLines[j-1])) {
lcs[i][j] = lcs[i-1][j-1] + 1
} else {
lcs[i][j] = max(lcs[i-1][j], lcs[i][j-1])
}
}
}
// Backtrack to produce diff
var result []DiffLine
i, j := m, n
back := make([]DiffLine, 0, max(m, n)*2)
for i > 0 || j > 0 {
if i > 0 && j > 0 && strings.EqualFold(strings.TrimSpace(oldLines[i-1]), strings.TrimSpace(newLines[j-1])) {
back = append(back, DiffLine{Type: DiffContext, Content: oldLines[i-1]})
i--
j--
} else if j > 0 && (i == 0 || lcs[i][j-1] >= lcs[i-1][j]) {
back = append(back, DiffLine{Type: DiffAdded, Content: newLines[j-1]})
j--
} else {
back = append(back, DiffLine{Type: DiffDeleted, Content: oldLines[i-1]})
i--
}
}
// Reverse
for idx := len(back) - 1; idx >= 0; idx-- {
result = append(result, back[idx])
}
return result
}
func max(a, b int) int {
if a > b {
return a
}
return b
}

View file

@ -0,0 +1,131 @@
// Package telemetry provides OpenTelemetry-based observability for OpenCodeReview CLI.
// It supports console output (for personal use) and OTLP export (for system integration).
package telemetry
import (
"encoding/json"
"os"
"path/filepath"
)
const (
defaultServiceName = "open-code-review"
defaultOTLPEndpoint = ""
defaultExporter = "console"
defaultContentLogging = false
)
// Config holds resolved telemetry configuration.
type Config struct {
Enabled bool // Master switch; false when OCR_ENABLE_TELEMETRY is unset
ServiceName string // Service name in traces/metrics
Exporter string // "console" or "otlp"
OTLPEndpoint string // OTLP collector address (grpc/http)
OTLPProtocol string // "grpc", "http/protobuf", "http/json"
ContentLog bool // Include prompt/response content in log events
}
// DefaultConfig returns a disabled default configuration.
func DefaultConfig() Config {
return Config{
Enabled: false,
ServiceName: defaultServiceName,
Exporter: defaultExporter,
OTLPEndpoint: defaultOTLPEndpoint,
OTLPProtocol: "grpc",
ContentLog: defaultContentLogging,
}
}
// resolveEnv reads environment variables to override defaults.
// Environment takes highest priority.
func resolveEnv(cfg *Config) {
if os.Getenv("OCR_ENABLE_TELEMETRY") == "1" {
cfg.Enabled = true
}
if v := os.Getenv("OTEL_SERVICE_NAME"); v != "" {
cfg.ServiceName = v
}
if v := os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT"); v != "" {
cfg.Exporter = "otlp"
cfg.OTLPEndpoint = v
}
if v := os.Getenv("OTEL_EXPORTER_OTLP_PROTOCOL"); v != "" {
cfg.OTLPProtocol = v
}
if os.Getenv("OCR_CONTENT_LOGGING") == "1" {
cfg.ContentLog = true
}
}
// telemetrySection matches the telemetry key in ~/.open-code-review/config.json.
type telemetrySection struct {
Enabled *bool `json:"enabled,omitempty"`
Exporter *string `json:"exporter,omitempty"`
OTLPEndpoint *string `json:"otlp_endpoint,omitempty"`
ContentLog *bool `json:"content_logging,omitempty"`
}
// LoadFromJSON merges telemetry settings from a JSON config file if present.
// This has lower priority than environment variables.
func LoadFromJSON(cfg *Config, configPath string) error {
data, err := os.ReadFile(configPath)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
var root struct {
Telemetry *telemetrySection `json:"telemetry"`
}
if err := json.Unmarshal(data, &root); err != nil {
return nil // malformed JSON — skip silently
}
if root.Telemetry == nil {
return nil
}
t := root.Telemetry
if t.Enabled != nil {
cfg.Enabled = *t.Enabled
}
if t.Exporter != nil && cfg.Exporter == defaultExporter {
cfg.Exporter = *t.Exporter
}
if t.OTLPEndpoint != nil && cfg.OTLPEndpoint == "" {
cfg.OTLPEndpoint = *t.OTLPEndpoint
if cfg.Exporter == defaultExporter {
cfg.Exporter = "otlp"
}
}
if t.ContentLog != nil {
cfg.ContentLog = *t.ContentLog
}
return nil
}
// ResolveConfig builds the final Config by merging defaults, JSON config, and env vars.
// Precedence: defaults < config.json < environment variables.
func ResolveConfig(configPath string) Config {
cfg := DefaultConfig()
// Layer 1: JSON config file
if configPath != "" {
_ = LoadFromJSON(&cfg, configPath)
}
// Layer 2: Environment variables (highest priority)
resolveEnv(&cfg)
return cfg
}
// HomeConfigPath returns the default path to ~/.open-code-review/config.json.
func HomeConfigPath() string {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
return filepath.Join(home, ".open-code-review", "config.json")
}

View file

@ -0,0 +1,125 @@
package telemetry
import (
"context"
"fmt"
"os"
"strconv"
"strings"
"time"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
"github.com/open-code-review/open-code-review/internal/stdout"
)
// Event emits a structured event as a span with immediate end.
func Event(ctx context.Context, name string, attrs ...attribute.KeyValue) {
if !IsEnabled() || ctx == nil {
return
}
opts := []trace.SpanStartOption{trace.WithAttributes(attrs...)}
_, span := otel.GetTracerProvider().Tracer(serviceName).Start(ctx, "event."+name, opts...)
defer span.End()
}
// Eventf is like Event but includes a message attribute.
func Eventf(ctx context.Context, name string, msg string, attrs ...attribute.KeyValue) {
Event(ctx, name, append(attrs, attribute.String("message", msg))...)
}
// ErrorEvent emits an error event with error status.
func ErrorEvent(ctx context.Context, name string, err error, extraAttrs ...attribute.KeyValue) {
if !IsEnabled() || ctx == nil || err == nil {
return
}
attrs := append(extraAttrs, attribute.String("error", err.Error()))
_, span := otel.GetTracerProvider().Tracer(serviceName).Start(ctx, "event."+name,
trace.WithAttributes(attrs...))
span.SetStatus(codes.Error, err.Error())
span.RecordError(err)
span.End()
}
// PhaseEvent records a review phase completion with duration and optional error.
func PhaseEvent(ctx context.Context, phase string, filePath string, dur time.Duration, err error) {
attrs := []attribute.KeyValue{
attribute.String("phase", phase),
attribute.String("file.path", filePath),
attribute.Int64("duration_ms", dur.Milliseconds()),
}
if err != nil {
ErrorEvent(ctx, "phase.completed", err, attrs...)
} else {
Event(ctx, "phase.completed", attrs...)
}
}
// FormatDuration returns a human-readable duration string for console output.
func FormatDuration(dur time.Duration) string {
return dur.Round(time.Millisecond).String()
}
// PrintTraceSummary prints a one-line summary of the review to stdout.
func PrintTraceSummary(filesReviewed, commentsGenerated int64, inputTokens, outputTokens, totalTokens int64, duration time.Duration) {
elapsed := duration.Round(time.Second).String()
if inputTokens > 0 || outputTokens > 0 {
fmt.Fprintf(stdout.Writer(), "[ocr] Summary: %d file(s) reviewed, %d comment(s), ~%d token(s) used (input: ~%d, output: ~%d), %s elapsed\n",
filesReviewed, commentsGenerated, totalTokens, inputTokens, outputTokens, elapsed)
} else {
fmt.Fprintf(stdout.Writer(), "[ocr] Summary: %d file(s) reviewed, %d comment(s), ~%d token(s) used, %s elapsed\n",
filesReviewed, commentsGenerated, totalTokens, elapsed)
}
}
// PrintToolCallStarted prints a line when a tool begins execution.
// Args are summarized as key-value pairs (path, search terms, etc.).
// Example: [ocr] ▶ file_read "internal/config/rules/loader.go"
func PrintToolCallStarted(toolName string, args map[string]any) {
summary := summarizeArgs(args)
if summary != "" {
fmt.Fprintf(stdout.Writer(), "[ocr] ▶ %s %s\n", toolName, summary)
} else {
fmt.Fprintf(stdout.Writer(), "[ocr] ▶ %s\n", toolName)
}
}
// PrintToolCallFinished prints a line when a tool finishes successfully.
// Example: [ocr] ✔ file_read "internal/config/rules/loader.go" (12ms)
func PrintToolCallFinished(toolName string, dur time.Duration) {
fmt.Fprintf(stdout.Writer(), "[ocr] ✔ %s (%s)\n", toolName, FormatDuration(dur))
}
// PrintToolCallError prints a line when a tool fails.
// Example: [ocr] ✘ file_read "internal/config/rules/loader.go" failed: permission denied
func PrintToolCallError(toolName string, err error) {
fmt.Fprintf(os.Stderr, "[ocr] ✘ %s failed: %v\n", toolName, err)
}
// summarizeArgs extracts a concise key=value summary from tool arguments for console display.
// It picks the most human-readable fields depending on the argument keys.
func summarizeArgs(args map[string]any) string {
parts := make([]string, 0, len(args))
for k, v := range args {
s := fmt.Sprint(v)
switch k {
case "path":
return strconv.Quote(s)
case "search", "query", "pattern":
return strconv.Quote(s)
default:
if len(s) <= 50 {
parts = append(parts, fmt.Sprintf("%s=%s", k, s))
}
}
}
if len(parts) == 0 {
return ""
}
return strings.Join(parts, " ")
}

View file

@ -0,0 +1,88 @@
package telemetry
import (
"context"
"fmt"
"os"
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
stdoutmetric "go.opentelemetry.io/otel/exporters/stdout/stdoutmetric"
stdouttrace "go.opentelemetry.io/otel/exporters/stdout/stdouttrace"
otlpmetricgrpc "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
otlptracegrpc "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
)
// newStdoutTraceExporter creates a console trace exporter with pretty-print output.
func newStdoutTraceExporter() (*stdouttrace.Exporter, error) {
return stdouttrace.New(stdouttrace.WithPrettyPrint())
}
// newStdoutMetricExporter creates a console metric exporter with pretty-print output.
func newStdoutMetricExporter() (sdkmetric.Exporter, error) {
return stdoutmetric.New(stdoutmetric.WithPrettyPrint())
}
// 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),
)
if err != nil {
fmt.Fprintf(os.Stderr, "[ocr] WARNING: failed to create OTLP trace exporter: %v\n", err)
return
}
tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(traceExp),
sdktrace.WithResource(res),
)
tracerProvider = tp
shutdownFuncs = append(shutdownFuncs, func(ctx context.Context) error { return tp.Shutdown(ctx) })
metricExp, err := otlpmetricgrpc.New(ctx,
otlpmetricgrpc.WithEndpoint(cfg.OTLPEndpoint),
)
if err != nil {
fmt.Fprintf(os.Stderr, "[ocr] WARNING: failed to create OTLP metric exporter: %v\n", err)
return
}
mp := sdkmetric.NewMeterProvider(
sdkmetric.WithReader(sdkmetric.NewPeriodicReader(metricExp)),
sdkmetric.WithResource(res),
)
meterProvider = mp
shutdownFuncs = append(shutdownFuncs, func(ctx context.Context) error { return mp.Shutdown(ctx) })
}
// initConsoleProviders sets up stdout exporters for traces and metrics.
func initConsoleProviders(res *resource.Resource) {
traceExp, err := newStdoutTraceExporter()
if err != nil {
fmt.Fprintf(os.Stderr, "[ocr] WARNING: failed to create console trace exporter: %v\n", err)
return
}
tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(traceExp),
sdktrace.WithResource(res),
)
tracerProvider = tp
shutdownFuncs = append(shutdownFuncs, func(ctx context.Context) error { return tp.Shutdown(ctx) })
metricExp, err := newStdoutMetricExporter()
if err != nil {
fmt.Fprintf(os.Stderr, "[ocr] WARNING: failed to create console metric exporter: %v\n", err)
return
}
mp := sdkmetric.NewMeterProvider(
sdkmetric.WithReader(sdkmetric.NewPeriodicReader(metricExp)),
sdkmetric.WithResource(res),
)
meterProvider = mp
shutdownFuncs = append(shutdownFuncs, func(ctx context.Context) error { return mp.Shutdown(ctx) })
}

View file

@ -0,0 +1,114 @@
package telemetry
import (
"context"
"time"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
)
// Metric handles — initialized once on first use.
var (
initMetricsOnce bool
mReviewDuration metric.Int64Histogram
mFilesReviewed metric.Int64Counter
mCommentsGenerated metric.Int64Counter
mLLMRequests metric.Int64Counter
mLLMTokens metric.Int64Counter
mLLMDuration metric.Float64Histogram
mToolCalls metric.Int64Counter
mToolExecutionTime metric.Float64Histogram
)
func getMeter() metric.Meter {
return otel.GetMeterProvider().Meter(serviceName)
}
func ensureMetrics() {
if initMetricsOnce {
return
}
initMetricsOnce = true
m := getMeter()
var err error
mReviewDuration, err = m.Int64Histogram("ocr.review.duration_seconds",
metric.WithUnit("s"), metric.WithDescription("Total duration of a code review run"))
checkMetricErr(err)
mFilesReviewed, err = m.Int64Counter("ocr.files_reviewed_total",
metric.WithDescription("Number of files reviewed in this session"))
checkMetricErr(err)
mCommentsGenerated, err = m.Int64Counter("ocr.comments_generated_total",
metric.WithDescription("Number of review comments generated"))
checkMetricErr(err)
mLLMRequests, err = m.Int64Counter("ocr.llm.requests_total",
metric.WithDescription("Total LLM API requests made"))
checkMetricErr(err)
mLLMTokens, err = m.Int64Counter("ocr.llm.tokens_used",
metric.WithDescription("Tokens consumed by LLM requests"))
checkMetricErr(err)
mLLMDuration, err = m.Float64Histogram("ocr.llm.request_duration_seconds",
metric.WithUnit("s"), metric.WithDescription("Duration of individual LLM API requests"))
checkMetricErr(err)
mToolCalls, err = m.Int64Counter("ocr.tool.calls_total",
metric.WithDescription("Total tool calls made"))
checkMetricErr(err)
mToolExecutionTime, err = m.Float64Histogram("ocr.tool.execution_duration_seconds",
metric.WithUnit("s"), metric.WithDescription("Duration of tool executions"))
checkMetricErr(err)
}
func checkMetricErr(err error) {}
func RecordReviewDuration(ctx context.Context, dur time.Duration) {
if !IsEnabled() { return }
ensureMetrics()
if mReviewDuration != nil { mReviewDuration.Record(ctx, int64(dur.Seconds())) }
}
func RecordFilesReviewed(ctx context.Context, n int64) {
if !IsEnabled() { return }
ensureMetrics()
if mFilesReviewed != nil { mFilesReviewed.Add(ctx, n) }
}
func RecordCommentsGenerated(ctx context.Context, n int64) {
if !IsEnabled() { return }
ensureMetrics()
if mCommentsGenerated != nil { mCommentsGenerated.Add(ctx, n) }
}
func RecordLLMRequest(ctx context.Context, model string, dur time.Duration, totalTokens int64, status string) {
if !IsEnabled() { return }
ensureMetrics()
attrs := []attribute.KeyValue{attribute.String("model", model), attribute.String("status", status)}
if mLLMRequests != nil { mLLMRequests.Add(ctx, 1, metric.WithAttributes(attrs...)) }
if mLLMDuration != nil { mLLMDuration.Record(ctx, dur.Seconds(), metric.WithAttributes(attribute.String("model", model))) }
if mLLMTokens != nil && totalTokens > 0 {
mLLMTokens.Add(ctx, totalTokens, metric.WithAttributes(attribute.String("type", "total"), attribute.String("model", model)))
}
}
func RecordToolCall(ctx context.Context, name string, dur time.Duration, ok bool) {
if !IsEnabled() { return }
ensureMetrics()
status := "ok"
if !ok { status = "error" }
attrs := []attribute.KeyValue{attribute.String("tool.name", name), attribute.String("status", status)}
if mToolCalls != nil { mToolCalls.Add(ctx, 1, metric.WithAttributes(attrs...)) }
if mToolExecutionTime != nil { mToolExecutionTime.Record(ctx, dur.Seconds(), metric.WithAttributes(attribute.String("tool.name", name))) }
}

View file

@ -0,0 +1,76 @@
package telemetry
import (
"context"
"fmt"
"os"
"go.opentelemetry.io/otel"
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
)
// Global OTel providers. Set by Init, used via otel.GetTracerProvider() / otel.GetMeterProvider().
var (
tracerProvider *sdktrace.TracerProvider
meterProvider *sdkmetric.MeterProvider
shutdownFuncs []func(context.Context) error
initialized bool
)
// serviceName holds the name set during Init.
var serviceName = "open-code-review"
// Init initializes global TracerProvider and MeterProvider based on
// environment variables and optional config file. Returns true when enabled.
// Safe to call multiple times.
func Init(ctx context.Context) bool {
if initialized {
return len(shutdownFuncs) > 0
}
initialized = true
cfg := ResolveConfig(HomeConfigPath())
serviceName = cfg.ServiceName
if !cfg.Enabled {
return false
}
res, err := resource.New(ctx,
resource.WithFromEnv(),
resource.WithProcess(),
resource.WithOS(),
resource.WithHost(),
)
if err != nil {
fmt.Fprintf(os.Stderr, "[ocr] WARNING: failed to create OTel resource: %v\n", err)
res = resource.Default()
}
switch cfg.Exporter {
case "otlp":
initOTLPProviders(ctx, res, cfg)
default:
initConsoleProviders(res)
}
otel.SetTracerProvider(tracerProvider)
otel.SetMeterProvider(meterProvider)
return len(shutdownFuncs) > 0
}
// IsEnabled returns true when telemetry has been initialized with exporters.
func IsEnabled() bool {
return initialized && len(shutdownFuncs) > 0
}
// ContentLogging returns true when content logging is enabled via OCR_CONTENT_LOGGING.
func ContentLogging() bool {
if !IsEnabled() {
return false
}
cfg := ResolveConfig("")
return cfg.ContentLog
}

View file

@ -0,0 +1,39 @@
package telemetry
import (
"context"
"fmt"
"os"
"time"
)
// Shutdown flushes and shuts down all initialized providers.
// It should be called before process exit to ensure buffered data is exported.
func Shutdown(ctx context.Context) error {
if len(shutdownFuncs) == 0 {
return nil
}
var errs []error
for _, fn := range shutdownFuncs {
if err := fn(ctx); err != nil {
errs = append(errs, err)
}
}
shutdownFuncs = nil
if len(errs) > 0 {
return fmt.Errorf("telemetry shutdown: %v", errs)
}
return nil
}
// ShutdownWithTimeout creates a timeout context and calls Shutdown.
func ShutdownWithTimeout(ctx context.Context, timeout time.Duration) {
cctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
if err := Shutdown(cctx); err != nil {
fmt.Fprintf(os.Stderr, "[ocr] WARNING: telemetry shutdown error: %v\n", err)
}
}

View file

@ -0,0 +1,81 @@
package telemetry
import (
"context"
"fmt"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
)
func getTracer() trace.Tracer {
return otel.GetTracerProvider().Tracer(serviceName)
}
// StartSpan creates a new span from the given context. When telemetry is not enabled,
// it returns a no-op span so callers can safely defer .End().
func StartSpan(ctx context.Context, name string, opts ...trace.SpanStartOption) (context.Context, trace.Span) {
if !IsEnabled() { return ctx, trace.SpanFromContext(ctx) }
return getTracer().Start(ctx, name, opts...)
}
// EndSpan ends the span and records error status if present.
func EndSpan(span trace.Span, err error) {
if err != nil {
span.SetStatus(codes.Error, err.Error())
span.RecordError(err)
}
span.End()
}
// SetAttr sets a single attribute on a span.
func SetAttr(span trace.Span, key string, value interface{}) {
if span == nil { return }
switch v := value.(type) {
case string: span.SetAttributes(attribute.String(key, v))
case int: span.SetAttributes(attribute.Int64(key, int64(v)))
case int64: span.SetAttributes(attribute.Int64(key, v))
case bool: span.SetAttributes(attribute.Bool(key, v))
case float64: span.SetAttributes(attribute.Float64(key, v))
default: span.SetAttributes(attribute.String(key, ""))
}
}
// StartToolSpan creates a span for a tool execution with standard attributes.
func StartToolSpan(ctx context.Context, toolName string) (context.Context, trace.Span) {
return StartSpan(ctx, "tool.execute."+toolName,
trace.WithAttributes(attribute.String("tool.name", toolName)))
}
// RecordToolResult sets the outcome of a tool execution on the span.
func RecordToolResult(span trace.Span, toolName string, durationMs int64, err error) {
if span == nil { return }
SetAttr(span, "tool.duration_ms", durationMs)
if err != nil {
SetAttr(span, "tool.status", "error")
SetAttr(span, "tool.error", err.Error())
span.SetStatus(codes.Error, err.Error())
} else {
SetAttr(span, "tool.status", "ok")
}
}
// AnyToAttr converts an arbitrary value to an OTel attribute.KeyValue.
func AnyToAttr(k string, v interface{}) attribute.KeyValue {
switch val := v.(type) {
case string:
return attribute.String(k, val)
case int:
return attribute.Int64(k, int64(val))
case int64:
return attribute.Int64(k, val)
case bool:
return attribute.Bool(k, val)
case float64:
return attribute.Float64(k, val)
default:
return attribute.String(k, fmt.Sprintf("%v", v))
}
}

View file

@ -0,0 +1,66 @@
package tool
import (
"encoding/json"
"fmt"
"github.com/open-code-review/open-code-review/internal/model"
)
// CodeCommentProvider submits review comments to the per-Agent CommentCollector.
type CodeCommentProvider struct {
Collector *CommentCollector
}
func (p *CodeCommentProvider) Tool() Tool { return CodeComment }
func (p *CodeCommentProvider) Execute(args map[string]any) (string, error) {
if p.Collector == nil {
return "Error: comment collector is not configured", nil
}
// Parse the "comments" array from the tool call arguments.
// LLM sometimes double-encodes the array as a JSON string.
var rawComments []any
if arr, ok := args["comments"].([]any); ok && len(arr) > 0 {
rawComments = arr
} else if s, ok := args["comments"].(string); ok && s != "" {
_ = json.Unmarshal([]byte(s), &rawComments)
}
if len(rawComments) == 0 {
raw, _ := json.Marshal(args)
return fmt.Sprintf("Error: 'comments' array is required. Got args: %s", string(raw)), nil
}
for _, raw := range rawComments {
obj, ok := raw.(map[string]any)
if !ok {
continue
}
cm := model.LlmComment{}
if content, ok := obj["content"].(string); ok {
cm.Content = content
}
if suggestion, ok := obj["suggestion_code"].(string); ok {
cm.SuggestionCode = suggestion
}
if existing, ok := obj["existing_code"].(string); ok {
cm.ExistingCode = existing
}
if thinking, ok := obj["thinking"].(string); ok {
cm.Thinking = thinking
}
if path, ok := args["path"].(string); ok {
cm.Path = path
}
if cm.Path == "" || cm.Content == "" {
continue
}
p.Collector.Add(cm)
}
return CommentSucceed, nil
}

View file

@ -0,0 +1,127 @@
package tool
import (
"fmt"
"os/exec"
"strconv"
"strings"
)
const gitGrepMaxCount = 100
// CodeSearchProvider performs text search across the repository using git grep.
type CodeSearchProvider struct {
FileReader *FileReader
}
func NewCodeSearch(fr *FileReader) *CodeSearchProvider { return &CodeSearchProvider{FileReader: fr} }
func (p *CodeSearchProvider) Tool() Tool { return CodeSearch }
func (p *CodeSearchProvider) Execute(args map[string]any) (string, error) {
searchText, _ := args["search_text"].(string)
caseSensitive, _ := args["case_sensitive"].(bool)
usePerlRegexp, _ := args["use_perl_regexp"].(bool)
filePatternsIface, _ := args["file_patterns"].([]any)
var patterns []string
for _, item := range filePatternsIface {
if s, ok := item.(string); ok && s != "" {
patterns = append(patterns, s)
}
}
if strings.TrimSpace(searchText) == "" {
return "Error: search_text is blank", nil
}
result, err := p.gitGrep(searchText, caseSensitive, usePerlRegexp, patterns)
if err != nil {
return "", fmt.Errorf("code_search failed: %w", err)
}
return result, nil
}
func (p *CodeSearchProvider) gitGrep(searchText string, caseSensitive bool, usePerlRegexp bool, pathspec []string) (string, error) {
cmdArgs := []string{"--no-pager", "grep"}
if !caseSensitive {
cmdArgs = append(cmdArgs, "-i")
}
if usePerlRegexp {
cmdArgs = append(cmdArgs, "-P")
} else {
cmdArgs = append(cmdArgs, "-E")
}
cmdArgs = append(cmdArgs, "-n", "--no-color")
cmdArgs = append(cmdArgs, "--max-count", fmt.Sprintf("%d", gitGrepMaxCount))
// In range/commit mode, specify the ref so git grep searches at that ref instead of working tree.
if ref := p.FileReader.Ref; ref != "" {
cmdArgs = append(cmdArgs, ref)
}
cmdArgs = append(cmdArgs, "--", searchText)
cmdArgs = append(cmdArgs, pathspec...)
cmd := exec.Command("git", cmdArgs...)
cmd.Dir = p.FileReader.RepoDir
output, err := cmd.CombinedOutput()
outStr := string(output)
if err != nil && outStr == "" {
return "No matches found", nil
}
lines := strings.Split(strings.TrimRight(outStr, "\n"), "\n")
truncated := len(lines) >= gitGrepMaxCount
type match struct {
lineNum int
content string
}
fileMatches := make(map[string][]match)
var fileOrder []string
seen := make(map[string]bool)
var sb strings.Builder
if truncated {
sb.WriteString(fmt.Sprintf("Note: The results have been truncated. Only showing first %d results.\n", gitGrepMaxCount))
}
for _, line := range lines {
if line == "" {
continue
}
parts := strings.SplitN(line, ":", 3)
if len(parts) < 3 {
continue
}
fname := parts[0]
m := match{}
ln, parseErr := strconv.Atoi(parts[1])
if parseErr != nil {
continue
}
m.lineNum = ln
m.content = parts[2]
if !seen[fname] {
seen[fname] = true
fileOrder = append(fileOrder, fname)
}
fileMatches[fname] = append(fileMatches[fname], m)
}
for _, path := range fileOrder {
matches := fileMatches[path]
sb.WriteString(fmt.Sprintf("File: %s\nMatch lines: %d\n", path, len(matches)))
for _, m := range matches {
sb.WriteString(fmt.Sprintf("%d|%s\n", m.lineNum, m.content))
}
sb.WriteString("\n")
}
return sb.String(), nil
}

View file

@ -0,0 +1,35 @@
package tool
import (
"sync"
"github.com/open-code-review/open-code-review/internal/model"
)
// CommentCollector is a thread-safe, per-Agent comment store.
// Each Agent instance owns its own collector so reviews across different repos do not interfere.
type CommentCollector struct {
mu sync.Mutex
comments []model.LlmComment
}
// NewCommentCollector creates an empty collector.
func NewCommentCollector() *CommentCollector {
return &CommentCollector{}
}
// Add appends a comment to the collector.
func (c *CommentCollector) Add(cm model.LlmComment) {
c.mu.Lock()
defer c.mu.Unlock()
c.comments = append(c.comments, cm)
}
// Comments returns all collected comments.
func (c *CommentCollector) Comments() []model.LlmComment {
c.mu.Lock()
defer c.mu.Unlock()
out := make([]model.LlmComment, len(c.comments))
copy(out, c.comments)
return out
}

View file

@ -0,0 +1,82 @@
package tool
import "fmt"
// Tool represents a single review tool.
type Tool struct {
name string
}
var (
Unknown = Tool{name: "unknown"}
TaskDone = Tool{name: "task_done"}
CodeComment = Tool{name: "code_comment"}
FileRead = Tool{name: "file_read"}
FileFind = Tool{name: "file_find"}
FileReadDiff = Tool{name: "file_read_diff"}
CodeSearch = Tool{name: "code_search"}
)
func OfName(name string) Tool {
for _, t := range allTools() {
if t.name == name {
return t
}
}
return Unknown
}
func allTools() []Tool {
return []Tool{Unknown, TaskDone, CodeComment, FileRead, FileFind, FileReadDiff, CodeSearch}
}
// Name returns the tool's identifier name.
func (t Tool) Name() string { return t.name }
// IsKnown reports whether the tool is not UNKNOWN.
func (t Tool) IsKnown() bool {
return t != Unknown
}
// LookupResult holds the result of a single tool lookup.
type LookupResult struct {
Result string
Found bool
}
// Provider is the interface that all concrete tool implementations satisfy.
// Each tool handles one specific capability (read file, search code, etc.).
type Provider interface {
// Tool returns which tool this provider implements.
Tool() Tool
// Execute runs the tool with the given arguments and returns the result string.
Execute(args map[string]any) (string, error)
}
// Registry maps tool aliases to their providers. Users register their own implementations here.
type Registry map[string]Provider
// NewRegistry creates an empty registry.
func NewRegistry() Registry {
return make(Registry)
}
// Register adds a tool provider to the registry.
func (r Registry) Register(p Provider) {
r[p.Tool().name] = p
}
// Lookup finds a provider by name. Returns a zero-value LookupResult if not found.
func (r Registry) Lookup(name string) LookupResult {
p, ok := r[name]
if !ok {
return LookupResult{Found: false}
}
return LookupResult{Result: p.Tool().name, Found: true}
}
// ErrToolNotFound is returned when a tool alias cannot be resolved.
var ErrToolNotFound = fmt.Errorf("tool not found")
// NotAvailableError is the standard message returned when a tool is not registered.
const NotAvailableMsg = "Error: Tool not found. The tool you attempted to call does not exist or is not available. Please check the tool name and try again with a valid tool."

102
internal/tool/file_find.go Normal file
View file

@ -0,0 +1,102 @@
package tool
import (
"bytes"
"os/exec"
"strings"
)
const fileFindMaxCount = 100
// FileFindProvider finds files by name or pattern in the repository using git ls-files.
type FileFindProvider struct {
FileReader *FileReader
}
func NewFileFind(fr *FileReader) *FileFindProvider { return &FileFindProvider{FileReader: fr} }
func (p *FileFindProvider) Tool() Tool { return FileFind }
func (p *FileFindProvider) Execute(args map[string]any) (string, error) {
queryName, _ := args["query_name"].(string)
if strings.TrimSpace(queryName) == "" {
return "// The file was not found", nil
}
caseSensitive, _ := args["case_sensitive"].(bool)
files, err := p.listGitFiles()
if err != nil {
return "", err
}
var matched []string
for _, f := range files {
base := f
if idx := strings.LastIndex(f, "/"); idx != -1 {
base = f[idx+1:]
}
match := false
if caseSensitive {
match = strings.Contains(base, queryName)
} else {
match = strings.Contains(strings.ToLower(base), strings.ToLower(queryName))
}
if match {
matched = append(matched, f)
}
if len(matched) >= fileFindMaxCount {
break
}
}
if len(matched) == 0 {
return "// The file was not found", nil
}
return strings.Join(matched, "\n"), nil
}
// listGitFiles returns tracked and untracked files (respecting .gitignore) via git ls-files.
func (p *FileFindProvider) listGitFiles() ([]string, error) {
cmd := exec.Command("git", "ls-files", "--cached", "--others", "--exclude-standard")
cmd.Dir = p.FileReader.RepoDir
output, err := cmd.Output()
if err != nil {
return nil, err
}
var files []string
lines := bytes.Split(bytes.TrimRight(output, "\n"), []byte{'\n'})
for _, line := range lines {
if len(line) > 0 {
s := string(line)
// Skip binary-like files that lack meaningful extensions patterns
// and filter out paths in common generated/artifact directories.
if shouldSkipFile(s) {
continue
}
files = append(files, s)
}
}
return files, nil
}
// shouldSkipFile returns true if a git ls-files output path should be skipped.
// Keeps only widely useful files (those with recognizable extensions).
func shouldSkipFile(path string) bool {
// Keep extensionless build/config files like Makefile, Dockerfile, LICENSE
base := path
if idx := strings.LastIndex(path, "/"); idx != -1 {
base = path[idx+1:]
}
hasExt := strings.Contains(base, ".")
if !hasExt {
// Allow well-known extensionless files
switch base {
case "Makefile", "Dockerfile", "LICENSE", "Vagrantfile", "Containerfile":
return false
}
return true // skip other extensionless files
}
return false
}

View file

@ -0,0 +1,73 @@
package tool
import (
"fmt"
"strings"
)
const fileReadMaxLines = 500
// FileReadProvider reads file content at a given path and optional line range.
type FileReadProvider struct {
FileReader *FileReader
}
func NewFileRead(fr *FileReader) *FileReadProvider { return &FileReadProvider{FileReader: fr} }
func (p *FileReadProvider) Tool() Tool { return FileRead }
func (p *FileReadProvider) Execute(args map[string]any) (string, error) {
filePath, _ := args["file_path"].(string)
if filePath == "" {
return "Error: file_path is required", nil
}
startLine, hasStart := args["start_line"].(float64)
endLine, hasEnd := args["end_line"].(float64)
if !hasStart || startLine <= 0 {
startLine = 1
}
if !hasEnd || endLine <= 0 {
endLine = 0 // means "to end of file"
}
content, err := p.FileReader.Read(filePath)
if err != nil {
return "", fmt.Errorf("file %q not found: %w", filePath, err)
}
lines := strings.Split(content, "\n")
totalLines := len(lines)
// Adjust endLine: if not specified or beyond file length, clamp to file length.
actualEnd := int(endLine)
if actualEnd <= 0 || actualEnd > totalLines {
actualEnd = totalLines
}
start := int(startLine) - 1
if start >= totalLines {
return "", fmt.Errorf("file %q has only %d lines, requested range %d-%d", filePath, totalLines, int(startLine), int(endLine))
}
// Apply 500-line cap and track truncation.
truncated := false
requestedCount := actualEnd - start
if requestedCount > fileReadMaxLines {
actualEnd = start + fileReadMaxLines
truncated = true
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("File: %s (Total lines: %d)\n", filePath, totalLines))
sb.WriteString(fmt.Sprintf("IS_TRUNCATED: %t\n", truncated))
sb.WriteString(fmt.Sprintf("LINE_RANGE: %d-%d\n", int(startLine), actualEnd))
// The following is the original content of the file.
for i := start; i < actualEnd; i++ {
sb.WriteString(fmt.Sprintf("%d|%s\n", i+1, lines[i]))
}
if truncated {
sb.WriteString(fmt.Sprintf("\nNote: Results truncated to %d lines. Please narrow your line range.\n", fileReadMaxLines))
}
return sb.String(), nil
}

View file

@ -0,0 +1,42 @@
package tool
import "strings"
// FileReadDiffProvider retrieves diff content by file path from an already-parsed diff set.
// Translated from Java FileReadDiffTool — uses the existing diff parser instead of
// Repositories.getDiffs().
type FileReadDiffProvider struct {
DiffMap map[string]string // path -> diff text, same as Agent's internal diffMap
}
func NewFileReadDiff() *FileReadDiffProvider { return &FileReadDiffProvider{DiffMap: make(map[string]string)} }
func (p *FileReadDiffProvider) Tool() Tool { return FileReadDiff }
func (p *FileReadDiffProvider) Execute(args map[string]any) (string, error) {
pathArray, _ := args["path_array"].([]any)
if len(pathArray) == 0 {
return "Error: no files found", nil
}
var sb strings.Builder
for _, item := range pathArray {
path, ok := item.(string)
if !ok {
continue
}
if d, exists := p.DiffMap[path]; exists {
sb.WriteString("==== FILE: ")
sb.WriteString(path)
sb.WriteString(" ====\n")
sb.WriteString(d)
sb.WriteString("\n")
}
}
result := sb.String()
if result == "" {
return "Error: diff not found for the requested paths", nil
}
return result, nil
}

View file

@ -0,0 +1,92 @@
package tool
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"time"
)
// ReviewMode represents the active review mode.
type ReviewMode int
const (
// ModeWorkspace reads files from the current working tree.
ModeWorkspace ReviewMode = iota
// ModeRange reads files as they exist at a specific git ref (--to value).
ModeRange
// ModeCommit reads files as they exist at a specific commit hash.
ModeCommit
)
// ParseReviewMode returns the correct ReviewMode based on provided flag values.
func ParseReviewMode(from, to, commit string) ReviewMode {
if commit != "" {
return ModeCommit
}
if from != "" && to != "" {
return ModeRange
}
return ModeWorkspace
}
// RefValue returns the git ref that should be used for reading file contents
// in range or commit mode. Returns ("", false) for workspace mode.
func (m ReviewMode) RefValue(toRef, commit string) (string, bool) {
switch m {
case ModeRange:
return toRef, true
case ModeCommit:
return commit, true
default:
return "", false
}
}
// FileReader resolves file contents according to the active review mode.
type FileReader struct {
RepoDir string
Mode ReviewMode
// Ref is the git ref to use for ModeRange (--to) or ModeCommit (--commit).
// Empty for ModeWorkspace.
Ref string
}
// Read returns the full content of a file path (relative to RepoDir),
// resolved according to the active review mode.
// - Workspace: reads directly from the filesystem.
// - Range / Commit: uses `git show <Ref>:<path>` to read at the given ref.
func (fr *FileReader) Read(path string) (string, error) {
switch fr.Mode {
case ModeWorkspace:
return fr.readFromDisk(path)
case ModeRange, ModeCommit:
return fr.readFromGitShow(path)
default:
return fr.readFromDisk(path)
}
}
func (fr *FileReader) readFromDisk(path string) (string, error) {
fullPath := filepath.Join(fr.RepoDir, path)
content, err := os.ReadFile(fullPath)
if err != nil {
return "", fmt.Errorf("read file %q: %w", path, err)
}
return string(content), nil
}
func (fr *FileReader) readFromGitShow(path string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "git", "-c", "core.quotepath=false", "show", fr.Ref+":"+path)
cmd.Dir = fr.RepoDir
output, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("git show %s:%s: %w", fr.Ref, path, err)
}
return string(output), nil
}

View file

@ -0,0 +1,23 @@
package tool
// ToolCallResult holds a single tool call and its execution result.
type ToolCallResult struct {
ToolCallID string // OpenAI-compatible tool call ID
Name string // tool name (alias)
Result string // output from the tool
}
// TaskCheckpoint mirrors the Java TaskCheckPoint — signals completion or carries data back to the LLM.
type TaskCheckpoint struct {
Data string
Completed bool
}
// Complete returns a checkpoint signaling task completion.
func Complete() TaskCheckpoint { return TaskCheckpoint{Completed: true} }
// Of returns a checkpoint with data.
func Of(data string) TaskCheckpoint { return TaskCheckpoint{Data: data, Completed: false} }
const CommentSucceed = "Successfully commented."
const ToolNotFoundMsg = "Error: Tool not found. The tool you attempted to call does not exist or is not available. Please check the tool name and try again with a valid tool."

28
internal/tool/stub.go Normal file
View file

@ -0,0 +1,28 @@
package tool
// StubProvider is a no-op tool provider that returns "not available" for all tools.
// Useful as a fallback when users haven't registered real implementations.
type StubProvider struct {
tool Tool
}
func NewStub(t Tool) *StubProvider { return &StubProvider{tool: t} }
func (s *StubProvider) Tool() Tool { return s.tool }
func (s *StubProvider) Execute(args map[string]any) (string, error) {
return NotAvailableMsg, nil
}
// BuiltinToolProvider implements tools that don't require external system access.
type BuiltinToolProvider struct {
tool Tool
fn func(args map[string]any) (string, error)
}
func NewBuiltin(t Tool, fn func(args map[string]any) (string, error)) *BuiltinToolProvider {
return &BuiltinToolProvider{tool: t, fn: fn}
}
func (b *BuiltinToolProvider) Tool() Tool { return b.tool }
func (b *BuiltinToolProvider) Execute(args map[string]any) (string, error) { return b.fn(args) }

View file

@ -0,0 +1,79 @@
package viewer
import (
"fmt"
"net/http"
"path/filepath"
)
func handleRepos(w http.ResponseWriter, r *http.Request, root string) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
repos, err := DiscoverRepos(root)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
renderTemplate(w, "repos.html", map[string]any{
"Repos": repos,
})
}
type sessionsData struct {
EncodedRepo string
RepoName string
Sessions []SessionSummary
}
func handleSessions(w http.ResponseWriter, r *http.Request, root, repo string) {
summaries, err := ListSessions(root, repo)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Derive a display name from the first session's CWD
name := repo
for _, s := range summaries {
if s.CWD != "" {
name = filepath.Base(s.CWD)
break
}
}
renderTemplate(w, "sessions.html", sessionsData{
EncodedRepo: repo,
RepoName: name,
Sessions: summaries,
})
}
type sessionPageData struct {
EncodedRepo string
RepoName string
Session *ViewSession
}
func handleSession(w http.ResponseWriter, r *http.Request, root, repo, sessionID string) {
vs, err := LoadSession(root, repo, sessionID)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to load session: %v", err), http.StatusNotFound)
return
}
// Derive a display name
name := filepath.Base(vs.Summary.CWD)
if name == "." || name == "" {
name = repo
}
renderTemplate(w, "session.html", sessionPageData{
EncodedRepo: repo,
RepoName: name,
Session: vs,
})
}

166
internal/viewer/server.go Normal file
View file

@ -0,0 +1,166 @@
package viewer
import (
"embed"
"fmt"
"html/template"
"io/fs"
"net/http"
"strings"
"time"
)
//go:embed templates/*.html static/style.css
var assets embed.FS
func StartServer(addr string) error {
root, err := SessionsRoot()
if err != nil {
return fmt.Errorf("resolve sessions root: %w", err)
}
mux := http.NewServeMux()
// Static assets (must be registered before "/" catch-all)
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS()))))
// Routes
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
handleRepos(w, r, root)
})
mux.HandleFunc("/r/{repo}", func(w http.ResponseWriter, r *http.Request) {
repo := r.PathValue("repo")
if strings.Contains(repo, "..") || strings.Contains(repo, "/") {
http.Error(w, "invalid repo path", http.StatusBadRequest)
return
}
handleSessions(w, r, root, repo)
})
mux.HandleFunc("/r/{repo}/{sessionID}", func(w http.ResponseWriter, r *http.Request) {
repo := r.PathValue("repo")
sid := r.PathValue("sessionID")
if strings.Contains(repo, "..") || strings.Contains(sid, "..") {
http.Error(w, "invalid path", http.StatusBadRequest)
return
}
handleSession(w, r, root, repo, sid)
})
srv := &http.Server{
Addr: addr,
Handler: mux,
}
fmt.Printf("\nOpen browser: http://%s\n", addr)
return srv.ListenAndServe()
}
var cstZone = func() *time.Location {
loc, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
loc = time.FixedZone("CST", 8*60*60)
}
return loc
}()
func formatTime(t time.Time) string {
return t.In(cstZone).Format("2006-01-02 15:04")
}
func parseTemplate(name string) (*template.Template, error) {
funcMap := template.FuncMap{
"formatDuration": formatDuration,
"formatTime": formatTime,
"truncate": truncateText,
"add": func(a, b int) int { return a + b },
"cardCount": func(tasks map[TaskType][]*TaskCard) int {
n := 0
for _, cards := range tasks {
n += len(cards)
}
return n
},
"taskTypeClass": func(tt TaskType) string {
switch tt {
case PlanTask:
return "task-plan"
case MainTask:
return "task-main"
case MemoryCompressionTask:
return "task-memory"
default:
return "task-default"
}
},
"orderedTasks": func(tasks map[TaskType][]*TaskCard) []struct {
Type TaskType
Cards []*TaskCard
} {
order := []TaskType{PlanTask, MainTask, MemoryCompressionTask}
var result []struct {
Type TaskType
Cards []*TaskCard
}
for _, tt := range order {
if cards, ok := tasks[tt]; ok {
result = append(result, struct {
Type TaskType
Cards []*TaskCard
}{tt, cards})
}
}
for tt, cards := range tasks {
if tt != PlanTask && tt != MainTask && tt != MemoryCompressionTask {
result = append(result, struct {
Type TaskType
Cards []*TaskCard
}{tt, cards})
}
}
return result
},
}
content, err := assets.ReadFile("templates/" + name)
if err != nil {
return nil, err
}
return template.New(name).Funcs(funcMap).Parse(string(content))
}
func truncateText(n int, s string) string {
if len(s) <= n {
return s
}
return s[:n] + "…"
}
func renderTemplate(w http.ResponseWriter, name string, data any) {
tmpl, err := parseTemplate(name)
if err != nil {
http.Error(w, "template error: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.Execute(w, data); err != nil {
// Partially written — just log
fmt.Printf("[viewer] template execution error: %v\n", err)
}
}
func staticFS() fs.FS {
sub, err := fs.Sub(assets, "static")
if err != nil {
panic(err)
}
return sub
}
func formatDuration(seconds float64) string {
d := time.Duration(seconds * float64(time.Second))
if d < time.Minute {
return fmt.Sprintf("%.1fs", seconds)
}
minutes := int(d.Minutes())
sec := int(d.Seconds()) - minutes*60
return fmt.Sprintf("%dm%ds", minutes, sec)
}

View file

@ -0,0 +1,442 @@
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #f5f7fa;
color: #333;
line-height: 1.6;
padding-bottom: 2rem;
}
nav.breadcrumb {
background: #fff;
border-bottom: 1px solid #e1e4e8;
padding: 0.75rem 1.5rem;
font-size: 0.9rem;
}
nav.breadcrumb a {
color: #0366d6;
text-decoration: none;
}
nav.breadcrumb a:hover { text-decoration: underline; }
main { max-width: 1200px; margin: 1.5rem auto; padding: 0 1rem; }
h2 { margin-bottom: 1rem; color: #24292e; }
h3 { margin: 1.5rem 0 0.75rem; color: #24292e; }
.table {
width: 100%;
border-collapse: collapse;
background: #fff;
border-radius: 6px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
}
.table th {
background: #f6f8fa;
text-align: left;
padding: 0.6rem 1rem;
font-size: 0.85rem;
color: #586069;
border-bottom: 1px solid #e1e4e8;
}
.table td {
padding: 0.6rem 1rem;
border-bottom: 1px solid #eaecef;
font-size: 0.9rem;
}
.table tr:last-child td { border-bottom: none; }
.table tr:hover { background: #f6f8fa; }
.table a { color: #0366d6; text-decoration: none; }
.table a:hover { text-decoration: underline; }
.table code {
background: #f0f0f0;
padding: 0.15em 0.4em;
border-radius: 3px;
font-size: 0.85em;
}
.session-header {
background: #fff;
border-radius: 6px;
padding: 1.25rem;
margin-bottom: 1rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
}
.session-header h2 { margin-bottom: 0.5rem; word-break: break-all; }
.meta { display: flex; flex-wrap: wrap; gap: 1rem; font-size: 0.9rem; color: #586069; }
.meta code {
background: #f0f0f0;
padding: 0.15em 0.4em;
border-radius: 3px;
font-size: 0.85em;
}
/* Token usage summary */
.token-summary {
background: #fff;
border-radius: 6px;
padding: 1.25rem;
margin-bottom: 1rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
}
.token-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 1rem;
margin-bottom: 1rem;
}
.token-item { text-align: center; }
.token-value {
font-size: 1.8rem;
font-weight: 700;
color: #0366d6;
line-height: 1.2;
}
.token-label {
font-size: 0.75rem;
color: #586069;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.token-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
.token-table th {
background: #f6f8fa;
text-align: left;
padding: 0.4rem 0.75rem;
color: #586069;
border-bottom: 1px solid #e1e4e8;
}
.token-table td {
padding: 0.35rem 0.75rem;
border-bottom: 1px solid #eaecef;
}
.token-table tr:last-child td { border-bottom: none; }
.token-table tbody tr:hover { background: #f6f8fa; }
.file-list { list-style: none; padding-left: 1rem; }
.file-list li {
padding: 0.25rem 0;
font-size: 0.9rem;
color: #586069;
}
.file-list li::before { content: "\1F4C4 "; margin-right: 0.25rem; }
/* ── Conversations Section ── */
.conversations { margin-top: 0.5rem; }
/* File Accordion */
.file-accordion {
background: #fff;
border-radius: 8px;
margin-bottom: 0.75rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
border: 1px solid #e1e4e8;
overflow: hidden;
}
.file-accordion-header {
cursor: pointer;
padding: 0.85rem 1rem;
font-size: 0.95rem;
color: #24292e;
user-select: none;
display: flex;
align-items: center;
gap: 0.6rem;
transition: background 0.15s;
list-style: none;
}
.file-accordion-header::-webkit-details-marker { display: none; }
.file-accordion-header::marker { display: none; content: ""; }
.file-accordion-header:hover { background: #f0f6ff; }
/* Chevron icon */
.chevron {
display: inline-block;
width: 6px;
height: 6px;
border-right: 2px solid #586069;
border-bottom: 2px solid #586069;
transform: rotate(-45deg);
transition: transform 0.2s ease;
flex-shrink: 0;
margin-left: 2px;
}
.file-accordion[open] > .file-accordion-header .chevron {
transform: rotate(45deg);
}
.file-path {
font-weight: 600;
font-family: SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace;
font-size: 0.88rem;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-count-badge {
background: #e8ecf1;
color: #586069;
padding: 0.15em 0.6em;
border-radius: 10px;
font-size: 0.72rem;
font-weight: 500;
white-space: nowrap;
flex-shrink: 0;
}
.file-accordion[open] > .file-accordion-header {
border-bottom: 1px solid #e1e4e8;
}
.file-accordion-body {
padding: 0.75rem 1rem 1rem;
}
/* Task Group */
.task-group { margin-bottom: 1rem; }
.task-group:last-child { margin-bottom: 0; }
.task-type-label {
font-size: 0.72rem;
text-transform: uppercase;
color: #586069;
margin-bottom: 0.6rem;
letter-spacing: 0.06em;
display: flex;
align-items: center;
gap: 0.4rem;
font-weight: 600;
}
.task-type-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.task-main .task-type-dot { background: #0366d6; }
.task-plan .task-type-dot { background: #8250df; }
.task-memory .task-type-dot { background: #8b949e; }
.task-default .task-type-dot { background: #57606a; }
/* Card */
.card {
border: 1px solid #e1e4e8;
border-radius: 8px;
margin-bottom: 0.75rem;
overflow: hidden;
transition: box-shadow 0.15s;
}
.card:hover {
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
}
.task-main .card { border-left: 3px solid #0366d6; }
.task-plan .card { border-left: 3px solid #8250df; }
.task-memory .card { border-left: 3px solid #8b949e; }
.card-header {
background: #f6f8fa;
padding: 0.55rem 0.85rem;
font-size: 0.85rem;
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
border-bottom: 1px solid #eaecef;
}
.request-label {
font-weight: 600;
color: #24292e;
}
/* Badges */
.badge {
padding: 0.12em 0.55em;
border-radius: 10px;
font-size: 0.72em;
font-weight: 500;
white-space: nowrap;
}
.badge-model { background: #ddf4ff; color: #0550ae; }
.badge-tokens { background: #dafbe1; color: #116329; }
.badge-duration { background: #fff8c5; color: #6a5700; }
.badge-error { background: #ffebe9; color: #cf222e; }
/* Response Content */
.card-body {
padding: 0;
}
.response-content {
background: #fefefe;
border-top: none;
}
.response-text {
padding: 0.85rem 1rem;
font-size: 0.85rem;
line-height: 1.65;
color: #24292e;
max-height: 400px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-word;
}
/* Markdown-rendered elements */
.response-text .code-block {
background: #f6f8fa;
border: 1px solid #e1e4e8;
border-radius: 6px;
padding: 0.65rem 0.85rem;
margin: 0.5rem 0;
font-size: 0.82rem;
overflow-x: auto;
white-space: pre;
}
.response-text .code-block code {
background: none;
padding: 0;
font-size: inherit;
}
.response-text .inline-code {
background: #eff1f3;
padding: 0.15em 0.35em;
border-radius: 4px;
font-size: 0.88em;
font-family: SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace;
}
.response-text .md-h1 { font-size: 1.15em; font-weight: 700; margin: 0.6em 0 0.3em; color: #24292e; }
.response-text .md-h2 { font-size: 1.05em; font-weight: 600; margin: 0.5em 0 0.25em; color: #24292e; }
.response-text .md-h3 { font-size: 0.95em; font-weight: 600; margin: 0.4em 0 0.2em; color: #24292e; }
.response-text .md-li { padding-left: 1em; text-indent: -0.8em; margin: 0.15em 0; }
/* Tool Calls Section */
.tool-calls-section {
background: #f8f9fb;
border-top: 1px solid #e1e4e8;
padding: 0.65rem 0.85rem;
}
.tool-calls-label {
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
color: #586069;
letter-spacing: 0.04em;
margin-bottom: 0.5rem;
}
.tool-call-item {
background: #fff;
border: 1px solid #e1e4e8;
border-radius: 6px;
margin-bottom: 0.5rem;
overflow: hidden;
transition: border-color 0.15s;
}
.tool-call-item:last-child { margin-bottom: 0; }
.tool-call-error {
border-left: 3px solid #cf222e;
}
.tool-call-header {
padding: 0.45rem 0.7rem;
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.83rem;
flex-wrap: wrap;
}
.tool-icon {
color: #8b949e;
font-size: 0.9em;
}
.tool-name {
color: #8250df;
font-family: SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace;
font-size: 0.85em;
}
/* Tool Detail Toggle */
.tool-detail {
border-top: 1px solid #eaecef;
}
.tool-detail summary {
list-style: none;
}
.tool-detail summary::-webkit-details-marker { display: none; }
.tool-detail summary::marker { display: none; content: ""; }
.tool-detail-toggle {
cursor: pointer;
padding: 0.35rem 0.7rem;
font-size: 0.78rem;
color: #0366d6;
display: flex;
align-items: center;
gap: 0.35rem;
user-select: none;
transition: background 0.12s;
}
.tool-detail-toggle:hover { background: #f0f6ff; }
/* Small chevron for tool details */
.chevron-sm {
display: inline-block;
width: 5px;
height: 5px;
border-right: 1.5px solid #0366d6;
border-bottom: 1.5px solid #0366d6;
transform: rotate(-45deg);
transition: transform 0.2s ease;
flex-shrink: 0;
}
.tool-detail[open] > .tool-detail-toggle .chevron-sm {
transform: rotate(45deg);
}
.tool-detail-body {
padding: 0 0.7rem 0.6rem;
}
.tool-section {
margin-top: 0.4rem;
}
.tool-section-label {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
color: #8b949e;
letter-spacing: 0.04em;
margin-bottom: 0.2rem;
}
.tool-section pre {
background: #f6f8fa;
border: 1px solid #e1e4e8;
border-radius: 4px;
padding: 0.5rem 0.65rem;
font-size: 0.78rem;
max-height: 200px;
overflow-y: auto;
margin: 0;
white-space: pre-wrap;
word-break: break-word;
}
p { color: #586069; padding: 1rem 0; }

421
internal/viewer/store.go Normal file
View file

@ -0,0 +1,421 @@
// Package viewer provides a read-only WebUI for browsing session records
// produced by open-code-review runs. It scans JSONL files under
// $HOME/.open-code-review/sessions/, parses them, and exposes structured data.
package viewer
import (
"bufio"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
// SessionsRoot returns the root directory where session JSONL files are stored.
func SessionsRoot() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("resolve home dir: %w", err)
}
return filepath.Join(home, ".open-code-review", "sessions"), nil
}
// RepoInfo represents a discovered repository from the sessions directory.
type RepoInfo struct {
EncodedPath string // encoded directory name on disk
SessionCount int
LastModified time.Time
}
// DiscoverRepos walks the sessions root and returns one entry per subdirectory.
func DiscoverRepos(root string) ([]RepoInfo, error) {
entries, err := os.ReadDir(root)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("read sessions dir: %w", err)
}
var repos []RepoInfo
for _, e := range entries {
if !e.IsDir() {
continue
}
repoDir := filepath.Join(root, e.Name())
info := RepoInfo{EncodedPath: e.Name()}
subEntries, err := os.ReadDir(repoDir)
if err != nil {
continue
}
for _, se := range subEntries {
if strings.HasSuffix(se.Name(), ".jsonl") {
info.SessionCount++
if fi, err := se.Info(); err == nil {
if fi.ModTime().After(info.LastModified) {
info.LastModified = fi.ModTime()
}
}
}
}
if info.SessionCount > 0 {
repos = append(repos, info)
}
}
sort.Slice(repos, func(i, j int) bool {
return repos[i].LastModified.After(repos[j].LastModified)
})
return repos, nil
}
// SessionSummary is built from session_start and session_end records.
type SessionSummary struct {
SessionID string
Timestamp time.Time
CWD string
GitBranch string
Model string
FilesReviewed []string
DurationSec float64
FileCount int
}
// ListSessions returns lightweight summaries for all sessions in a repo subdir.
func ListSessions(root, encodedRepo string) ([]SessionSummary, error) {
repoDir := filepath.Join(root, encodedRepo)
entries, err := os.ReadDir(repoDir)
if err != nil {
return nil, fmt.Errorf("read repo dir: %w", err)
}
var summaries []SessionSummary
for _, e := range entries {
if !strings.HasSuffix(e.Name(), ".jsonl") {
continue
}
sessionID := strings.TrimSuffix(e.Name(), ".jsonl")
s, err := peekSession(filepath.Join(repoDir, e.Name()))
if err != nil {
continue // skip unreadable files
}
s.SessionID = sessionID
summaries = append(summaries, s)
}
sort.Slice(summaries, func(i, j int) bool {
return summaries[i].Timestamp.After(summaries[j].Timestamp)
})
return summaries, nil
}
// peekSession reads only the first and last record of a JSONL file.
func peekSession(path string) (SessionSummary, error) {
f, err := os.Open(path)
if err != nil {
return SessionSummary{}, err
}
defer f.Close()
var summary SessionSummary
scanner := bufio.NewScanner(f)
buf := make([]byte, 0, 1024*1024)
scanner.Buffer(buf, 10*1024*1024)
var lastLine []byte
for scanner.Scan() {
line := scanner.Bytes()
lastLine = append([]byte(nil), line...)
if summary.Timestamp.IsZero() {
var rec map[string]any
if err := json.Unmarshal(line, &rec); err != nil {
continue
}
if ts, ok := rec["timestamp"].(string); ok {
summary.Timestamp, _ = time.Parse(time.RFC3339, ts)
}
if cwd, ok := rec["cwd"].(string); ok {
summary.CWD = cwd
}
if branch, ok := rec["gitBranch"].(string); ok {
summary.GitBranch = branch
}
if model, ok := rec["model"].(string); ok {
summary.Model = model
}
}
}
if len(lastLine) > 0 {
var rec map[string]any
if err := json.Unmarshal(lastLine, &rec); err == nil {
if typ, _ := rec["type"].(string); typ == "session_end" {
if dur, ok := rec["duration_seconds"].(float64); ok {
summary.DurationSec = dur
}
if files, ok := rec["files_reviewed"].([]any); ok {
summary.FilesReviewed = make([]string, 0, len(files))
for _, fv := range files {
if s, ok := fv.(string); ok {
summary.FilesReviewed = append(summary.FilesReviewed, s)
}
}
}
}
}
}
summary.FileCount = len(summary.FilesReviewed)
return summary, scanner.Err()
}
// ViewSession holds fully parsed records for one session.
type ViewSession struct {
Summary SessionSummary
TokenUsage TokenUsageSummary
Files []*FileGroup // ordered by file path
}
// TokenUsageSummary aggregates estimated token counts across the session.
type TokenUsageSummary struct {
TotalPromptTokens int
TotalCompletionTokens int
RequestCount int
FileTokenBreakdown []FileTokenUsage // populated after parsing
}
// FileTokenUsage tracks token totals for a single file within a session.
type FileTokenUsage struct {
FilePath string
PromptTokens int
CompletionTokens int
}
// FileGroup aggregates records for a single file.
type FileGroup struct {
FilePath string
Tasks map[TaskType][]*TaskCard
}
// TaskType mirrors session.TaskType.
type TaskType string
const (
PlanTask TaskType = "plan_task"
MainTask TaskType = "main_task"
MemoryCompressionTask TaskType = "memory_compression_task"
)
// TaskCard links an LLM request with its response and tool calls.
type TaskCard struct {
RequestMessages any // preserved for display
RequestNo int
ResponseContent string
ToolCalls []ToolCallInfo
DurationMs int64
Error string
Model string
PromptTokens int
CompletionTokens int
}
// ToolCallInfo summarizes a single tool call.
type ToolCallInfo struct {
Name string
Arguments string
Result string
Ok bool
DurationMs int64
}
// LoadSession fully parses a JSONL file into a ViewSession.
func LoadSession(root, encodedRepo, sessionID string) (*ViewSession, error) {
path := filepath.Join(root, encodedRepo, sessionID+".jsonl")
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("open session file: %w", err)
}
defer f.Close()
vs := &ViewSession{Files: make([]*FileGroup, 0)}
fileIndex := make(map[string]*FileGroup)
scanner := bufio.NewScanner(f)
buf := make([]byte, 0, 1024*1024)
scanner.Buffer(buf, 10*1024*1024)
for scanner.Scan() {
var rec map[string]any
if err := json.Unmarshal(scanner.Bytes(), &rec); err != nil {
continue // skip malformed lines
}
typ, _ := rec["type"].(string)
switch typ {
case "session_start":
if ts, ok := rec["timestamp"].(string); ok {
vs.Summary.Timestamp, _ = time.Parse(time.RFC3339, ts)
}
if cwd, ok := rec["cwd"].(string); ok {
vs.Summary.CWD = cwd
}
if branch, ok := rec["gitBranch"].(string); ok {
vs.Summary.GitBranch = branch
}
if model, ok := rec["model"].(string); ok {
vs.Summary.Model = model
}
case "llm_request":
fp, _ := rec["filePath"].(string)
tt, _ := rec["taskType"].(string)
reqNo := 0
if n, ok := rec["request_no"].(float64); ok {
reqNo = int(n)
}
msgs := rec["messages"]
tc := &TaskCard{RequestMessages: msgs, RequestNo: reqNo}
fg := fileIndex[fp]
if fg == nil {
fg = &FileGroup{FilePath: fp, Tasks: make(map[TaskType][]*TaskCard)}
fileIndex[fp] = fg
vs.Files = append(vs.Files, fg)
}
fg.Tasks[TaskType(tt)] = append(fg.Tasks[TaskType(tt)], tc)
case "llm_response":
fp, _ := rec["filePath"].(string)
content, _ := rec["content"].(string)
durationMs := int64(0)
if d, ok := rec["duration_ms"].(float64); ok {
durationMs = int64(d)
}
model, _ := rec["model"].(string)
errStr, _ := rec["error"].(string)
promptTok := 0
completionTok := 0
if usage, ok := rec["usage"].(map[string]any); ok {
if v, ok := usage["prompt_tokens"].(float64); ok {
promptTok = int(v)
}
if v, ok := usage["completion_tokens"].(float64); ok {
completionTok = int(v)
}
}
tt, _ := rec["taskType"].(string)
fg := fileIndex[fp]
if fg != nil {
cards := fg.Tasks[TaskType(tt)]
if len(cards) > 0 && cards[len(cards)-1].ResponseContent == "" {
card := cards[len(cards)-1]
card.ResponseContent = content
card.DurationMs = durationMs
card.Model = model
card.Error = errStr
card.PromptTokens = promptTok
card.CompletionTokens = completionTok
}
}
// Also attach tool_calls to the same card
if tcs, ok := rec["tool_calls"].([]any); ok && fg != nil {
tt, _ := rec["taskType"].(string)
cards := fg.Tasks[TaskType(tt)]
if len(cards) > 0 {
card := cards[len(cards)-1]
for _, tc := range tcs {
if tm, ok := tc.(map[string]any); ok {
name, _ := tm["name"].(string)
args, _ := tm["arguments"].(string)
info := ToolCallInfo{Name: name, Arguments: args}
if name == "task_done" {
info.Ok = true
}
card.ToolCalls = append(card.ToolCalls, info)
}
}
}
}
case "tool_call":
result, _ := rec["result"].(string)
okVal := true
if b, hasOk := rec["ok"].(bool); hasOk {
okVal = b
}
fp, _ := rec["filePath"].(string)
tt, _ := rec["taskType"].(string)
durationMs := int64(0)
if d, ok2 := rec["duration_ms"].(float64); ok2 {
durationMs = int64(d)
}
fg := fileIndex[fp]
if fg != nil {
cards := fg.Tasks[TaskType(tt)]
if len(cards) > 0 {
card := cards[len(cards)-1]
for ti := range card.ToolCalls {
if card.ToolCalls[ti].Result == "" && !card.ToolCalls[ti].Ok {
card.ToolCalls[ti].Result = result
card.ToolCalls[ti].Ok = okVal
card.ToolCalls[ti].DurationMs = durationMs
break
}
}
}
}
case "session_end":
if dur, ok := rec["duration_seconds"].(float64); ok {
vs.Summary.DurationSec = dur
}
if files, ok := rec["files_reviewed"].([]any); ok {
vs.Summary.FilesReviewed = make([]string, 0, len(files))
for _, fv := range files {
if s, ok2 := fv.(string); ok2 {
vs.Summary.FilesReviewed = append(vs.Summary.FilesReviewed, s)
}
}
}
vs.Summary.FileCount = len(vs.Summary.FilesReviewed)
}
}
// Aggregate token usage across all task cards
fileBreakdown := make([]FileTokenUsage, 0, len(vs.Files))
for _, fg := range vs.Files {
ft := FileTokenUsage{FilePath: fg.FilePath}
for _, cards := range fg.Tasks {
for _, c := range cards {
vs.TokenUsage.TotalPromptTokens += c.PromptTokens
vs.TokenUsage.TotalCompletionTokens += c.CompletionTokens
if c.ResponseContent != "" || c.PromptTokens > 0 {
vs.TokenUsage.RequestCount++
}
ft.PromptTokens += c.PromptTokens
ft.CompletionTokens += c.CompletionTokens
}
}
fileBreakdown = append(fileBreakdown, ft)
}
sort.Slice(fileBreakdown, func(i, j int) bool {
return fileBreakdown[i].PromptTokens+fileBreakdown[i].CompletionTokens > fileBreakdown[j].PromptTokens+fileBreakdown[j].CompletionTokens
})
vs.TokenUsage.FileTokenBreakdown = fileBreakdown
sort.Slice(vs.Files, func(i, j int) bool {
return vs.Files[i].FilePath < vs.Files[j].FilePath
})
vs.Summary.SessionID = sessionID
return vs, scanner.Err()
}

View file

@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Repositories - Open Code Review Viewer</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<nav class="breadcrumb"><a href="/">Open Code Review Viewer</a></nav>
<main>
<h2>Repositories</h2>
{{if .Repos}}
<table class="table">
<thead><tr><th>Repository</th><th>Sessions</th><th>Last Modified</th></tr></thead>
<tbody>
{{range .Repos}}
<tr>
<td><a href="/r/{{.EncodedPath}}">{{.EncodedPath}}</a></td>
<td>{{.SessionCount}}</td>
<td>{{formatTime .LastModified}}</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p>No session data found. Run a code review first.</p>
{{end}}
</main>
</body>
</html>

View file

@ -0,0 +1,172 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Session Detail - Open Code Review Viewer</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<nav class="breadcrumb"><a href="/">Open Code Review Viewer</a> &rsaquo; <a href="/r/{{.EncodedRepo}}">{{.RepoName}}</a> &rsaquo; Session {{.Session.Summary.SessionID | truncate 12}}</nav>
<main>
<div class="session-header">
<h2>Session: {{.Session.Summary.SessionID}}</h2>
<div class="meta">
<span><strong>CWD:</strong> {{.Session.Summary.CWD}}</span>
<span><strong>Branch:</strong> {{.Session.Summary.GitBranch}}</span>
<span><strong>Model:</strong> <code>{{.Session.Summary.Model}}</code></span>
<span><strong>Duration:</strong> {{formatDuration .Session.Summary.DurationSec}}</span>
<span><strong>Files:</strong> {{.Session.Summary.FileCount}}</span>
</div>
</div>
<div class="token-summary">
<h3>Token Usage</h3>
<div class="token-stats">
<div class="token-item">
<div class="token-value">{{.Session.TokenUsage.TotalPromptTokens}}</div>
<div class="token-label">Prompt Tokens</div>
</div>
<div class="token-item">
<div class="token-value">{{.Session.TokenUsage.TotalCompletionTokens}}</div>
<div class="token-label">Completion Tokens</div>
</div>
<div class="token-item">
<div class="token-value">{{add .Session.TokenUsage.TotalPromptTokens .Session.TokenUsage.TotalCompletionTokens}}</div>
<div class="token-label">Total Tokens</div>
</div>
<div class="token-item">
<div class="token-value">{{.Session.TokenUsage.RequestCount}}</div>
<div class="token-label">LLM Requests</div>
</div>
</div>
{{with .Session.TokenUsage.FileTokenBreakdown}}
<table class="token-table">
<thead><tr><th>File</th><th>Prompt</th><th>Completion</th><th>Total</th></tr></thead>
<tbody>
{{range .}}
<tr>
<td title="{{.FilePath}}">{{.FilePath | truncate 60}}</td>
<td>{{.PromptTokens}}</td>
<td>{{.CompletionTokens}}</td>
<td><strong>{{add .PromptTokens .CompletionTokens}}</strong></td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
</div>
{{if .Session.Summary.FilesReviewed}}
<h3>Files Reviewed</h3>
<ul class="file-list">
{{range .Session.Summary.FilesReviewed}}
<li>{{.}}</li>
{{end}}
</ul>
{{end}}
<h3>Conversations ({{len .Session.Files}} files)</h3>
<div class="conversations">
{{range .Session.Files}}
<details class="file-accordion">
<summary class="file-accordion-header">
<span class="chevron"></span>
<span class="file-path">{{.FilePath}}</span>
<span class="file-count-badge">{{cardCount .Tasks}} requests</span>
</summary>
<div class="file-accordion-body">
{{range $tg := orderedTasks .Tasks}}
<div class="task-group {{taskTypeClass $tg.Type}}">
<h4 class="task-type-label">
<span class="task-type-dot"></span>
{{$tg.Type}}
</h4>
{{range $tg.Cards}}
<div class="card">
<div class="card-header">
<span class="request-label">Request #{{.RequestNo}}</span>
{{if .Model}}<span class="badge badge-model">{{.Model}}</span>{{end}}
{{if or .PromptTokens .CompletionTokens}}<span class="badge badge-tokens">P:{{.PromptTokens}} C:{{.CompletionTokens}}</span>{{end}}
{{if .DurationMs}}<span class="badge badge-duration">{{.DurationMs}}ms</span>{{end}}
{{if .Error}}<span class="badge badge-error">{{.Error}}</span>{{end}}
</div>
{{with .ResponseContent}}
<div class="card-body response-content">
<div class="response-text">{{.}}</div>
</div>
{{end}}
{{if .ToolCalls}}
<div class="tool-calls-section">
<div class="tool-calls-label">Tool Calls ({{len .ToolCalls}})</div>
{{range .ToolCalls}}
<div class="tool-call-item {{if not .Ok}}tool-call-error{{end}}">
<div class="tool-call-header">
<span class="tool-icon">&#9881;</span>
<strong class="tool-name">{{.Name}}</strong>
{{if .DurationMs}}<span class="badge badge-duration">{{.DurationMs}}ms</span>{{end}}
{{if not .Ok}}<span class="badge badge-error">Failed</span>{{end}}
</div>
<details class="tool-detail">
<summary class="tool-detail-toggle">
<span class="chevron-sm"></span>
<span>Show Details</span>
</summary>
<div class="tool-detail-body">
<div class="tool-section">
<div class="tool-section-label">Arguments</div>
<pre>{{.Arguments}}</pre>
</div>
{{if .Result}}
<div class="tool-section">
<div class="tool-section-label">Result</div>
<pre>{{.Result}}</pre>
</div>
{{end}}
</div>
</details>
</div>
{{end}}
</div>
{{end}}
</div>
{{end}}
</div>
{{end}}
</div>
</details>
{{end}}
</div>
<script>
document.querySelectorAll('.response-text').forEach(function(el) {
var text = el.textContent;
var esc = function(s) {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
};
var codeBlocks = [];
var html = esc(text);
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, function(_, lang, code) {
codeBlocks.push(code.replace(/^\n|\n$/g, ''));
return '%%CODEBLOCK_' + (codeBlocks.length - 1) + '%%';
});
html = html
.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/^### (.+)$/gm, '<div class="md-h3">$1</div>')
.replace(/^## (.+)$/gm, '<div class="md-h2">$1</div>')
.replace(/^# (.+)$/gm, '<div class="md-h1">$1</div>')
.replace(/^[-*] (.+)$/gm, '<div class="md-li">&bull; $1</div>')
.replace(/\n{2,}/g, '<br><br>')
.replace(/\n/g, '<br>');
codeBlocks.forEach(function(code, i) {
html = html.replace('%%CODEBLOCK_' + i + '%%',
'<pre class="code-block"><code>' + code + '</code></pre>');
});
el.innerHTML = html;
});
</script>
</main>
</body>
</html>

View file

@ -0,0 +1,34 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sessions - Open Code Review Viewer</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<nav class="breadcrumb"><a href="/">Open Code Review Viewer</a> &rsaquo; <a href="/r/{{.EncodedRepo}}">{{.RepoName}}</a></nav>
<main>
<h2>Sessions: {{.RepoName}}</h2>
{{if .Sessions}}
<table class="table">
<thead><tr><th>Session ID</th><th>Branch</th><th>Model</th><th>Files</th><th>Duration</th><th>Started At</th></tr></thead>
<tbody>
{{range .Sessions}}
<tr>
<td><a href="/r/{{$.EncodedRepo}}/{{.SessionID}}">{{.SessionID | printf "%.8s"}}…</a></td>
<td>{{.GitBranch}}</td>
<td><code>{{.Model}}</code></td>
<td>{{.FileCount}}</td>
<td>{{formatDuration .DurationSec}}</td>
<td>{{formatTime .Timestamp}}</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p>No sessions found for this repository.</p>
{{end}}
</main>
</body>
</html>

28
package.json Normal file
View file

@ -0,0 +1,28 @@
{
"name": "@alibaba/open-code-review",
"version": "0.2.2",
"description": "OpenCodeReview CLI — AI-powered code review tool",
"bin": {
"ocr": "bin/ocr.js"
},
"scripts": {
"postinstall": "node scripts/install.js",
"prepublishOnly": "cp NPM-README.md README.md",
"postpublish": "git checkout README.md"
},
"repository": {
"type": "git",
"url": "https://github.com/alibaba/open-code-review.git"
},
"publishConfig": {
"access": "public"
},
"ocrConfig": {
"urlPattern": "https://github.com/alibaba/open-code-review/releases/download/v{version}/opencodereview-{os}-{arch}",
"checksumPattern": "https://github.com/alibaba/open-code-review/releases/download/v{version}/sha256sum.txt"
},
"engines": {
"node": ">=14"
},
"license": "Apache-2.0"
}

2
pages/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
node_modules/
dist/

16
pages/index.html Normal file
View file

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>open-code-review — Agent Native Code Review</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><defs><linearGradient id='g' x1='0' y1='0' x2='1' y2='1'><stop offset='0%25' stop-color='%234ade80'/><stop offset='100%25' stop-color='%2316a34a'/></linearGradient></defs><rect width='32' height='32' rx='6' fill='url(%23g)'/><path d='M16 10c-4.4 0-8 3.6-8 6s3.6 6 8 6 8-3.6 8-6-3.6-6-8-6zm0 9.5a3.5 3.5 0 1 1 0-7 3.5 3.5 0 0 1 0 7zm0-5.5a2 2 0 1 0 0 4 2 2 0 0 0 0-4z' fill='%230f172a'/></svg>" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css" />
</head>
<body>
<div id="root"></div>
</body>
</html>

38
pages/package.json Normal file
View file

@ -0,0 +1,38 @@
{
"name": "open-code-review-landing",
"version": "1.0.0",
"description": "Landing page for Open Code Review",
"scripts": {
"dev": "webpack serve",
"build": "NODE_ENV=production webpack --mode production",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.8.0"
},
"overrides": {
"fast-uri": "^3.1.2"
},
"devDependencies": {
"@babel/core": "^7.23.5",
"@babel/preset-env": "^7.29.5",
"@babel/preset-react": "^7.23.5",
"@babel/preset-typescript": "^7.23.3",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"autoprefixer": "^10.4.16",
"babel-loader": "^9.1.3",
"css-loader": "^6.8.1",
"html-webpack-plugin": "^5.5.3",
"postcss": "^8.5.15",
"postcss-loader": "^7.3.3",
"style-loader": "^3.3.3",
"tailwindcss": "^3.3.5",
"typescript": "^5.3.2",
"webpack": "^5.89.0",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^5.2.4"
}
}

6
pages/postcss.config.js Normal file
View file

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};

15
pages/src/App.tsx Normal file
View file

@ -0,0 +1,15 @@
import React from 'react';
import { Routes, Route } from 'react-router-dom';
import LandingPage from './components/LandingPage';
import DocsPage from './pages/DocsPage';
const App: React.FC = () => {
return (
<Routes>
<Route path="/" element={<LandingPage />} />
<Route path="/docs" element={<DocsPage />} />
</Routes>
);
};
export default App;

View file

@ -0,0 +1,260 @@
import React, { useState } from 'react';
import { useTranslation } from '../i18n';
interface LeaderboardEntry {
rank: number;
medal?: string;
model: string;
provider: string;
source: string;
sourceType: 'ocr' | 'claude';
version: string;
f1: string;
precision: string;
precisionFraction: string;
recall: string;
recallFraction: string;
tags: string[];
}
const leaderboardData: LeaderboardEntry[] = [
{
rank: 1,
medal: 'gold',
model: 'Claude-4.6-Opus',
provider: 'Anthropic',
source: 'open-code-review',
sourceType: 'ocr',
version: 'AVG (10)',
f1: '26.1%',
precision: '41.9%',
precisionFraction: '286/682',
recall: '19.0%',
recallFraction: '286/1505',
tags: ['Closed', 'Reproduced']
},
{
rank: 2,
medal: 'silver',
model: 'Qwen-3.6-Plus',
provider: 'Alibaba',
source: 'open-code-review',
sourceType: 'ocr',
version: 'AVG (3)',
f1: '21.9%',
precision: '38.6%',
precisionFraction: '231/597',
recall: '15.3%',
recallFraction: '231/1505',
tags: ['Reproduced', 'Closed']
},
{
rank: 3,
medal: 'bronze',
model: 'GLM-4.7',
provider: 'Zhipu AI',
source: 'open-code-review',
sourceType: 'ocr',
version: 'AVG (2)',
f1: '20.1%',
precision: '34.2%',
precisionFraction: '214/624',
recall: '14.2%',
recallFraction: '214/1505',
tags: ['Open Source', 'Reproduced']
},
{
rank: 4,
medal: undefined,
model: 'Qwen3.5-27b-dense',
provider: 'Alibaba',
source: 'open-code-review',
sourceType: 'ocr',
version: 'AVG (3)',
f1: '19.5%',
precision: '28.1%',
precisionFraction: '225/799',
recall: '15.0%',
recallFraction: '225/1505',
tags: ['Open Source']
},
{
rank: 5,
medal: undefined,
model: 'Claude-4.5-Sonnet',
provider: 'Anthropic',
source: 'Claude Code + Skills',
sourceType: 'claude',
version: 'v0',
f1: '15.5%',
precision: '30.0%',
precisionFraction: '157/523',
recall: '10.4%',
recallFraction: '157/1505',
tags: ['Closed', 'Reproduced']
},
{
rank: 6,
medal: undefined,
model: 'Qwen3-Coder-480B-A35B-Instruct',
provider: 'Alibaba',
source: 'Claude Code + Skills',
sourceType: 'claude',
version: 'v0',
f1: '6.6%',
precision: '14.8%',
precisionFraction: '64/432',
recall: '4.3%',
recallFraction: '64/1505',
tags: ['Closed', 'Reproduced']
},
{
rank: 7,
medal: undefined,
model: 'GLM-4.7',
provider: 'Zhipu AI',
source: 'Claude Code + Skills',
sourceType: 'claude',
version: 'v0',
f1: '6.6%',
precision: '11.2%',
precisionFraction: '70/623',
recall: '4.7%',
recallFraction: '70/1505',
tags: ['Closed', 'Reproduced']
},
{
rank: 8,
medal: undefined,
model: 'Deepseek-V3.2',
provider: 'DeepSeek',
source: 'Claude Code + Skills',
sourceType: 'claude',
version: 'v0',
f1: '6.4%',
precision: '10.4%',
precisionFraction: '69/661',
recall: '4.6%',
recallFraction: '69/1505',
tags: ['Closed', 'Reproduced']
}
];
const medalIcons: Record<string, string> = {
gold: '🥇',
silver: '🥈',
bronze: '🥉'
};
const BenchmarkSection: React.FC = () => {
const [hoveredRow, setHoveredRow] = useState<number | null>(null);
const { t } = useTranslation();
return (
<section id="benchmark" className="py-24 relative noise-overlay">
{/* Ambient glow */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 w-[800px] h-[600px] rounded-full bg-brand-500/[0.02] blur-[140px] pointer-events-none"></div>
<div className="relative z-10">
<div className="section-divider mb-24"></div>
<div className="max-w-7xl mx-auto px-6">
<div className="text-center mb-12">
<p className="text-slate-500 text-sm font-mono uppercase tracking-widest mb-3">{t('benchmark.sectionLabel')}</p>
<h2 className="text-4xl font-bold text-white mb-4">
{t('benchmark.title')}
</h2>
<p className="text-slate-400 max-w-2xl mx-auto">
{t('benchmark.subtitle')}
</p>
</div>
{/* Legend */}
<div className="mb-6 flex items-center gap-4 flex-wrap">
<div className="flex items-center gap-2 text-xs">
<span className="w-3 h-3 rounded-sm bg-brand-500/20 border border-brand-500/40 inline-block"></span>
<span className="text-slate-400">{t('benchmark.legendOcr')}</span>
</div>
<div className="flex items-center gap-2 text-xs">
<span className="w-3 h-3 rounded-sm bg-slate-700 border border-slate-600 inline-block"></span>
<span className="text-slate-400">{t('benchmark.legendClaude')}</span>
</div>
</div>
{/* Table */}
<div className="rounded-2xl overflow-hidden glass-strong gradient-border shadow-2xl shadow-black/30">
{/* Table header */}
<div className="grid grid-cols-12 gap-2 px-6 py-3 bg-dark-700/60 text-xs font-medium text-slate-500 uppercase tracking-wider">
<div className="col-span-1">{t('benchmark.colRank')}</div>
<div className="col-span-3">{t('benchmark.colModel')}</div>
<div className="col-span-2">{t('benchmark.colSource')}</div>
<div className="col-span-1">{t('benchmark.colVersion')}</div>
<div className="col-span-2 text-right">SEM.F1</div>
<div className="col-span-2 text-right">SEM.PRECISION</div>
<div className="col-span-1 text-right">SEM.RECALL</div>
</div>
{leaderboardData.map((entry) => (
<div
key={entry.rank}
className={`leaderboard-row grid grid-cols-12 gap-2 px-6 py-4 items-center cursor-default ${
entry.sourceType === 'ocr' ? 'bg-brand-500/3' : ''
} ${hoveredRow === entry.rank ? 'bg-brand-500/6' : ''}`}
onMouseEnter={() => setHoveredRow(entry.rank)}
onMouseLeave={() => setHoveredRow(null)}
>
<div className="col-span-1 flex items-center gap-2">
{entry.medal ? (
<span className="text-lg">{medalIcons[entry.medal]}</span>
) : (
<span className="text-slate-500 font-mono text-sm w-6 text-center">{entry.rank}</span>
)}
</div>
<div className="col-span-3">
<div className="text-white text-sm font-medium">{entry.model}</div>
<div className="text-slate-500 text-xs">{entry.provider}</div>
</div>
<div className="col-span-2">
<span
className={`px-2 py-0.5 rounded-md text-xs font-medium ${
entry.sourceType === 'ocr'
? 'bg-brand-500/15 text-brand-400 border border-brand-500/25'
: 'bg-slate-700/50 text-slate-400 border border-slate-600/40'
}`}
>
{entry.source}
</span>
</div>
<div className="col-span-1 text-slate-500 text-xs font-mono">{entry.version}</div>
<div className="col-span-2 text-right">
<span className={`text-sm font-bold ${entry.rank <= 3 ? 'text-brand-400' : 'text-white'}`}>
{entry.f1}
</span>
</div>
<div className="col-span-2 text-right">
<div className="text-slate-300 text-sm">{entry.precision}</div>
<div className="text-slate-600 text-xs font-mono">{entry.precisionFraction}</div>
</div>
<div className="col-span-1 text-right">
<div className="text-slate-300 text-sm">{entry.recall}</div>
<div className="text-slate-600 text-xs font-mono">{entry.recallFraction}</div>
</div>
</div>
))}
</div>
<div className="mt-6 text-center text-slate-600 text-xs">
{t('benchmark.footer')}
</div>
</div>
</div>
</section>
);
};
export default BenchmarkSection;

View file

@ -0,0 +1,90 @@
import React from 'react';
import { useTranslation } from '../i18n';
const FeaturesSection: React.FC = () => {
const { t } = useTranslation();
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'),
},
];
return (
<section id="features" className="py-24 relative noise-overlay">
{/* Ambient glow */}
<div className="absolute top-1/3 right-1/4 w-[500px] h-[500px] rounded-full bg-purple-500/[0.02] blur-[100px] pointer-events-none"></div>
<div className="absolute bottom-1/3 left-1/4 w-[400px] h-[400px] rounded-full bg-cyan-500/[0.02] blur-[80px] pointer-events-none"></div>
<div className="relative z-10">
<div className="max-w-7xl mx-auto px-6">
<div className="text-center mb-16">
{/* Section pill badge */}
<div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full border border-slate-700/50 bg-dark-700/30 backdrop-blur-sm mb-6">
<span className="text-xs text-slate-400 font-medium tracking-wide">{t('features.sectionBadge')}</span>
</div>
<h2 className="text-4xl font-bold text-white mb-4">
{t('features.title')}
</h2>
<p className="text-slate-400 max-w-2xl mx-auto">
{t('features.subtitle')}
</p>
</div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{features.map((feature) => (
<div key={feature.icon} className="feature-card rounded-2xl p-6 group glass">
<div className={`w-12 h-12 rounded-xl ${feature.bgColor} flex items-center justify-center mb-4 group-hover:scale-110 transition-transform duration-300`}>
<i className={`fa-solid ${feature.icon} ${feature.color} text-lg`}></i>
</div>
<h3 className="text-white font-semibold text-lg mb-2">{feature.title}</h3>
<p className="text-slate-400 text-sm leading-relaxed">{feature.description}</p>
</div>
))}
</div>
</div>
</div>
</section>
);
};
export default FeaturesSection;

View file

@ -0,0 +1,180 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from '../i18n';
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' }
];
const TerminalLine: React.FC<{ line: typeof terminalLines[0]; visible: boolean }> = ({ line, visible }) => {
const colorMap: Record<string, string> = {
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 (
<div
className={`font-mono text-sm transition-all duration-300 ${
visible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-2'
} ${colorMap[line.type]}`}
>
{line.text}
</div>
);
};
const HeroSection: React.FC = () => {
const [visibleLines, setVisibleLines] = useState<number[]>([]);
const { t } = useTranslation();
useEffect(() => {
const timers: ReturnType<typeof setTimeout>[] = [];
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') }
];
return (
<section className="relative min-h-screen flex items-center justify-center overflow-hidden grid-bg noise-overlay spotlight">
{/* Layered glow orbs */}
<div className="absolute top-1/4 left-1/2 -translate-x-1/2 w-[800px] h-[800px] rounded-full bg-brand-500/[0.04] blur-[120px] pointer-events-none"></div>
<div className="absolute bottom-1/4 right-1/4 w-[500px] h-[500px] rounded-full bg-cyan-500/[0.03] blur-[100px] pointer-events-none"></div>
<div className="absolute top-1/3 left-1/4 w-[300px] h-[300px] rounded-full bg-purple-500/[0.02] blur-[80px] pointer-events-none"></div>
{/* Decorative horizontal lines */}
<div className="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-brand-500/20 to-transparent"></div>
<div className="relative z-10 max-w-7xl mx-auto px-6 pt-24 pb-16 grid lg:grid-cols-2 gap-16 items-center">
{/* Left content */}
<div className="space-y-8">
<div className="space-y-4">
<h1 className="text-5xl lg:text-5xl font-bold leading-tight tracking-tight">
<span className="text-white">{t('hero.title')}</span>
<br />
<span className="bg-gradient-to-r from-brand-400 via-green-300 to-cyan-400 bg-clip-text text-transparent text-glow">{t('hero.titleHighlight')}</span>
</h1>
<p className="text-slate-400 text-lg leading-relaxed max-w-xl">
{t('hero.description')}
</p>
</div>
{/* Feature pills */}
<div className="flex flex-wrap gap-6 text-sm">
{pills.map((item) => (
<div key={item.label} className="flex items-center gap-2 text-slate-300 group">
<i className={`fa-solid ${item.icon} ${item.color} group-hover:scale-110 transition-transform`}></i>
<span className="group-hover:text-white transition-colors">{item.label}</span>
</div>
))}
</div>
{/* CTA buttons */}
<div className="flex flex-wrap gap-4">
<button
onClick={() => document.getElementById('quickstart')?.scrollIntoView({ behavior: 'smooth' })}
className="btn-primary text-dark-900 font-semibold px-6 py-3 rounded-xl flex items-center gap-2 shadow-lg"
>
<i className="fa-solid fa-rocket"></i>
{t('hero.cta1')}
</button>
<button
onClick={() => document.getElementById('benchmark')?.scrollIntoView({ behavior: 'smooth' })}
className="btn-secondary text-brand-400 font-medium px-6 py-3 rounded-xl flex items-center gap-2"
>
<i className="fa-solid fa-chart-bar"></i>
{t('hero.cta2')}
</button>
</div>
{/* Social proof */}
<div className="flex items-center gap-6 pt-2">
<div className="flex items-center gap-2 text-slate-500 text-sm">
<i className="fa-solid fa-users text-slate-600"></i>
<span>{t('hero.users')}</span>
</div>
<div className="w-px h-4 bg-dark-500"></div>
<div className="flex items-center gap-2 text-slate-500 text-sm">
<i className="fa-brands fa-github text-slate-600"></i>
<span>{t('hero.openSource')}</span>
</div>
</div>
</div>
{/* Right terminal */}
<div className="relative">
{/* Outer glow frame */}
<div className="absolute -inset-1 bg-gradient-to-r from-brand-500/10 via-cyan-500/10 to-purple-500/10 rounded-3xl blur-xl opacity-50 pointer-events-none"></div>
<div className="code-block rounded-2xl p-6 space-y-3 relative overflow-hidden glass-strong">
{/* Scan line effect */}
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-brand-500/[0.02] to-transparent h-1/4 animate-scan-line pointer-events-none"></div>
<div className="flex items-center gap-2 mb-4">
<div className="w-3 h-3 rounded-full bg-red-500/70"></div>
<div className="w-3 h-3 rounded-full bg-yellow-500/70"></div>
<div className="w-3 h-3 rounded-full bg-brand-500/70"></div>
<span className="ml-2 text-slate-500 text-xs font-mono">{t('hero.terminal')}</span>
</div>
{terminalLines.map((line, index) => (
<TerminalLine
key={index}
line={line}
visible={visibleLines.includes(index)}
/>
))}
{visibleLines.length < terminalLines.length && (
<div className="font-mono text-sm text-brand-400 terminal-cursor"></div>
)}
</div>
{/* F1 badge */}
<div className="absolute -top-4 -right-4 floating-badge">
<div className="rank-badge px-3 py-2 rounded-xl text-xs font-mono text-brand-400 backdrop-blur-md">
<div className="text-lg font-bold">F1: 26.1%</div>
<div className="text-slate-500">{t('hero.badgeLabel')}</div>
</div>
</div>
{/* Decorative accent line */}
<div className="absolute -bottom-1 left-8 right-8 h-px bg-gradient-to-r from-brand-500/40 via-cyan-500/20 to-transparent"></div>
</div>
</div>
{/* Scroll indicator */}
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 flex flex-col items-center gap-2 animate-bounce">
<span className="text-slate-600 text-[10px] uppercase tracking-widest">Scroll</span>
<i className="fa-solid fa-chevron-down text-slate-600 text-sm"></i>
</div>
</section>
);
};
export default HeroSection;

View file

@ -0,0 +1,36 @@
import React, { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import Navbar from './Navbar';
import HeroSection from './HeroSection';
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]);
return (
<div className="min-h-screen bg-dark-900 noise-overlay">
<Navbar />
<HeroSection />
<WhySection />
<FeaturesSection />
<BenchmarkSection />
<QuickStartSection />
</div>
);
};
export default LandingPage;

View file

@ -0,0 +1,149 @@
import React, { useState, useEffect } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { useTranslation } from '../i18n';
const Navbar: React.FC = () => {
const [isScrolled, setIsScrolled] = useState(false);
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
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);
};
useEffect(() => {
const handleScroll = () => setIsScrolled(window.scrollY > 20);
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
const scrollToSection = (sectionId: string) => {
const element = document.getElementById(sectionId);
if (element) {
element.scrollIntoView({ behavior: 'smooth' });
}
setIsMobileMenuOpen(false);
};
const toggleLanguage = () => {
setLanguage(language === 'en' ? 'zh' : 'en');
};
return (
<nav
className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${
isScrolled || isDocsPage
? 'bg-dark-900/80 backdrop-blur-xl border-b border-dark-600/30 shadow-lg shadow-black/20'
: 'bg-transparent'
}`}
>
{/* Top edge glow */}
<div className="absolute bottom-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-brand-500/10 to-transparent opacity-0 group-hover:opacity-100"></div>
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
<Link to="/" className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-brand-400 to-green-600 flex items-center justify-center glow-green-sm">
<i className="fa-solid fa-eye text-dark-900 text-sm"></i>
</div>
<span className="font-bold text-lg tracking-tight">
<span className="text-white">open-code-review</span>
</span>
</Link>
<div className="hidden md:flex items-center gap-8">
{navItems.map((item) => (
<button
key={item.id}
onClick={() => isDocsPage ? navigateToSection(item.id) : scrollToSection(item.id)}
className="nav-link text-slate-400 hover:text-white text-sm font-medium transition-colors"
>
{item.label}
</button>
))}
<Link
to="/docs"
className={`nav-link text-sm font-medium transition-colors flex items-center gap-1 ${
isDocsPage ? 'text-brand-400' : 'text-slate-400 hover:text-white'
}`}
>
<i className="fa-solid fa-book text-xs"></i>
{t('navbar.docs')}
</Link>
</div>
<div className="hidden md:flex items-center gap-3">
<button
onClick={toggleLanguage}
className="text-slate-400 hover:text-white text-sm font-medium px-3 py-1.5 rounded-lg border border-dark-600/30 hover:border-slate-500/50 transition-all"
>
{language === 'en' ? '中文' : 'EN'}
</button>
<a
href="https://github.com/alibaba/open-code-review"
target="_blank"
rel="noopener noreferrer"
className="btn-secondary text-brand-400 text-sm font-medium px-4 py-2 rounded-lg flex items-center gap-2"
>
<i className="fa-brands fa-github"></i>
GitHub
</a>
<button
onClick={() => isDocsPage ? navigateToSection('quickstart') : scrollToSection('quickstart')}
className="btn-primary text-dark-900 text-sm font-semibold px-4 py-2 rounded-lg"
>
{t('navbar.getStarted')}
</button>
</div>
<button
className="md:hidden text-slate-400 hover:text-white"
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
>
<i className={`fa-solid ${isMobileMenuOpen ? 'fa-xmark' : 'fa-bars'} text-lg`}></i>
</button>
</div>
{isMobileMenuOpen && (
<div className="md:hidden bg-dark-900/95 backdrop-blur-xl border-b border-dark-600/30 px-6 py-4 flex flex-col gap-4">
{navItems.map((item) => (
<button
key={item.id}
onClick={() => isDocsPage ? navigateToSection(item.id) : scrollToSection(item.id)}
className="text-slate-400 hover:text-white text-sm font-medium text-left transition-colors"
>
{item.label}
</button>
))}
<Link
to="/docs"
onClick={() => setIsMobileMenuOpen(false)}
className={`text-sm font-medium text-left transition-colors flex items-center gap-1 ${
isDocsPage ? 'text-brand-400' : 'text-slate-400 hover:text-white'
}`}
>
<i className="fa-solid fa-book text-xs"></i>
{t('navbar.docs')}
</Link>
<button
onClick={toggleLanguage}
className="text-slate-400 hover:text-white text-sm font-medium text-left transition-colors"
>
{language === 'en' ? '切换到中文' : 'Switch to English'}
</button>
</div>
)}
</nav>
);
};
export default Navbar;

View file

@ -0,0 +1,173 @@
import React, { useState } from 'react';
import { useTranslation } from '../i18n';
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 }) => (
<div>
{block.label && (
<p className="text-slate-500 text-xs font-mono mb-2 ml-1">{block.label}</p>
)}
<div className="relative group/code">
<div className="code-block rounded-xl p-4 overflow-x-auto group-hover/code:border-brand-500/30 transition-colors duration-300">
<pre className="font-mono text-xs text-brand-400 whitespace-pre">{block.code}</pre>
</div>
<button
onClick={() => onCopy(block.code, index)}
className="absolute top-2 right-3 text-slate-600 hover:text-brand-400 transition-colors text-xs flex items-center gap-1 opacity-60 group-hover/code:opacity-100"
>
<i className={`fa-solid ${copiedIndex === index ? 'fa-check text-brand-400' : 'fa-copy'}`}></i>
{copiedIndex === index ? '' : copyLabel}
</button>
</div>
</div>
);
const QuickStartSection: React.FC = () => {
const [copiedIndex, setCopiedIndex] = useState<string | null>(null);
const { t } = useTranslation();
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 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 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.step2Label2'), code: `ocr config set language Chinese` },
{ 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);
});
} 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);
}
};
return (
<section id="quickstart" className="py-24 relative noise-overlay">
{/* Ambient glow */}
<div className="absolute bottom-0 left-1/4 w-[600px] h-[400px] rounded-full bg-brand-500/[0.02] blur-[100px] pointer-events-none"></div>
<div className="relative z-10">
<div className="section-divider mb-24"></div>
<div className="max-w-4xl mx-auto px-6">
<div className="text-center mb-16">
<p className="text-slate-500 text-sm font-mono uppercase tracking-widest mb-3">{t('quickstart.sectionLabel')}</p>
<h2 className="text-4xl font-bold text-white mb-4">
{t('quickstart.title')}
</h2>
<p className="text-slate-400 max-w-xl mx-auto">
{t('quickstart.subtitle')}
</p>
</div>
<div className="space-y-8">
{steps.map((step, stepIndex) => (
<div key={step.number} className="feature-card rounded-2xl p-6 relative glass">
{/* Connector line between steps */}
{stepIndex < steps.length - 1 && (
<div className="absolute left-8 -bottom-8 w-px h-8 bg-gradient-to-b from-brand-500/20 to-transparent hidden lg:block"></div>
)}
{/* Step header */}
<div className="flex items-start gap-4 mb-6">
<div className="flex-shrink-0 w-10 h-10 rounded-xl bg-brand-500/10 border border-brand-500/20 flex items-center justify-center">
<span className="font-mono text-brand-400 text-sm font-bold">{step.number}</span>
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<i className={`${step.icon} text-slate-500 text-sm`}></i>
<h3 className="text-white font-semibold text-lg">{step.title}</h3>
</div>
<p className="text-slate-400 text-sm">{step.description}</p>
</div>
</div>
{/* Code blocks */}
<div className="space-y-3">
{step.codeBlocks.map((block, blockIdx) => (
<CodeBlock
key={blockIdx}
block={block}
index={`${stepIndex}-${blockIdx}`}
copiedIndex={copiedIndex}
onCopy={handleCopy}
copyLabel={t('quickstart.copy')}
/>
))}
</div>
</div>
))}
</div>
{/* Zero-config callout */}
<div className="mt-8 stat-card rounded-2xl p-6 border-brand-500/15 glass">
<div className="absolute inset-0 bg-gradient-to-r from-brand-500/[0.03] via-transparent to-transparent rounded-2xl pointer-events-none"></div>
<h3 className="text-white font-semibold mb-3 flex items-center gap-2 relative z-10">
<i className="fa-solid fa-magic text-brand-400"></i>
{t('quickstart.zeroCfgTitle')}
</h3>
<p className="text-slate-400 text-sm mb-3 relative z-10">
{t('quickstart.zeroCfgDesc')}
</p>
<div className="code-block rounded-xl p-4 overflow-x-auto relative z-10">
<pre className="font-mono text-xs text-brand-400 whitespace-pre">{`export ANTHROPIC_BASE_URL=https://api.anthropic.com
export ANTHROPIC_AUTH_TOKEN=sk-ant-xxxxx
export ANTHROPIC_MODEL=claude-opus-4-6
${t('quickstart.commentEnvAuto')} `}</pre>
</div>
</div>
</div>
</div>
</section>
);
};
export default QuickStartSection;

View file

@ -0,0 +1,65 @@
import React from 'react';
import { useTranslation } from '../i18n';
const WhySection: React.FC = () => {
const { t } = useTranslation();
const useCases = [
{
icon: 'fa-user-astronaut',
title: t('why.case1Title'),
description: t('why.case1Desc'),
color: 'text-brand-400',
bgColor: 'bg-brand-500/8'
},
{
icon: 'fa-building',
title: t('why.case2Title'),
description: t('why.case2Desc'),
color: 'text-cyan-400',
bgColor: 'bg-cyan-500/8'
},
{
icon: 'fa-brain',
title: t('why.case3Title'),
description: t('why.case3Desc'),
color: 'text-purple-400',
bgColor: 'bg-purple-500/8'
}
];
return (
<section id="why" className="py-24 relative noise-overlay">
{/* Ambient glow */}
<div className="absolute top-1/4 left-1/4 w-[400px] h-[400px] rounded-full bg-red-500/[0.015] blur-[100px] pointer-events-none"></div>
<div className="absolute bottom-1/4 right-1/4 w-[400px] h-[400px] rounded-full bg-brand-500/[0.02] blur-[100px] pointer-events-none"></div>
<div className="relative z-10">
<div className="section-divider mb-24"></div>
<div className="max-w-7xl mx-auto px-6">
<div className="text-center mb-16">
<p className="text-slate-500 text-sm font-mono uppercase tracking-widest mb-3">{t('why.sectionLabel')}</p>
<h2 className="text-4xl font-bold text-white mb-4">{t('why.title')}</h2>
</div>
{/* Use cases */}
<div>
<div className="grid md:grid-cols-3 gap-6">
{useCases.map((useCase) => (
<div key={useCase.title} className={`feature-card rounded-2xl p-6 ${useCase.bgColor} glass`}>
<div className={`text-3xl mb-4 ${useCase.color}`}>
<i className={`fa-solid ${useCase.icon}`}></i>
</div>
<h4 className="text-white font-semibold text-lg mb-2">{useCase.title}</h4>
<p className="text-slate-400 text-sm leading-relaxed">{useCase.description}</p>
</div>
))}
</div>
</div>
</div>
</div>
</section>
);
};
export default WhySection;

View file

@ -0,0 +1,49 @@
import React, { createContext, useContext, useState, useCallback } from 'react';
import { Language, TranslationKeys } from './types';
import { en } from './en';
import { zh } from './zh';
const translations: Record<Language, TranslationKeys> = { en, zh };
interface LanguageContextValue {
language: Language;
setLanguage: (lang: Language) => void;
t: (key: string) => string;
}
const LanguageContext = createContext<LanguageContextValue | null>(null);
const STORAGE_KEY = 'ocr-lang';
function getInitialLanguage(): Language {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === 'en' || stored === 'zh') return stored;
} catch {}
return 'en';
}
export const LanguageProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [language, setLanguageState] = useState<Language>(getInitialLanguage);
const setLanguage = useCallback((lang: Language) => {
setLanguageState(lang);
try { localStorage.setItem(STORAGE_KEY, lang); } catch {}
}, []);
const t = useCallback((key: string): string => {
return translations[language][key] ?? key;
}, [language]);
return (
<LanguageContext.Provider value={{ language, setLanguage, t }}>
{children}
</LanguageContext.Provider>
);
};
export function useTranslation() {
const ctx = useContext(LanguageContext);
if (!ctx) throw new Error('useTranslation must be used within LanguageProvider');
return ctx;
}

167
pages/src/i18n/en.ts Normal file
View file

@ -0,0 +1,167 @@
import { TranslationKeys } from './types';
export const en: TranslationKeys = {
// Navbar
'navbar.features': 'Features',
'navbar.benchmark': 'Benchmark',
'navbar.quickstart': 'Quick Start',
'navbar.docs': 'Docs',
'navbar.getStarted': 'Get Started',
// Hero
'hero.title': 'AI Code Review',
'hero.titleHighlight': 'Validated on Millions of Real-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/5',
'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',
// 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.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.',
'features.feat1Title': 'Hybrid Architecture: Deterministic + Agent',
'features.feat1Desc': 'Decouples deterministic engineering (task splitting, file filtering, line-number positioning, rule routing, async scheduling) from LLM Agent reasoning (risk detection, context exploration, issue classification) — engineering modules handle certainty, Agents handle semantics, optimizing quality, token cost, and speed.',
'features.feat2Title': 'Precise Comment Positioning & Reflection',
'features.feat2Desc': 'An independent line-level comment positioning module with a 3-tier progressive LLM strategy pinpoints each comment to exact line numbers. A separate reflection module intercepts hallucinations and knowledge drift early.',
'features.feat3Title': 'Multi-Model Protocol Support',
'features.feat3Desc': 'Supports both Anthropic Messages API and OpenAI Chat Completions API. Connect custom model endpoints.',
'features.feat4Title': 'Dynamic Concurrent Processing',
'features.feat4Desc': 'Dynamically splits sub-tasks for parallel review with configurable goroutine workers (default 8). Even large changesets complete quickly.',
'features.feat5Title': 'Smart Memory Compression',
'features.feat5Desc': 'Purpose-built memory compression for code review with 3-tier partitioning (frozen/compress/active) context management, breaking token limits for deep review.',
'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, C++, C, with specialized rules for NPE, thread safety, XSS, SQL injection, and more.',
// Benchmark Section
'benchmark.sectionLabel': 'Open Benchmark',
'benchmark.title': 'Cross-Validated by 80+ Senior Engineers',
'benchmark.subtitle': 'A real-world CodeReview benchmark cross-annotated by 80+ senior engineers. open-code-review leads across F1, Precision, and Recall over generic Agent approaches.',
'benchmark.legendOcr': 'open-code-review',
'benchmark.legendClaude': 'Claude Code + Skills (Generic)',
'benchmark.colRank': 'Rank',
'benchmark.colModel': 'Model',
'benchmark.colSource': 'Source',
'benchmark.colVersion': 'Version',
'benchmark.footer': 'Benchmark cross-annotated by 80+ senior engineers · Semantic F1 metric',
// QuickStart Section
'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',
'quickstart.step1Desc': 'One-command global install via npm.',
'quickstart.step1Label1': 'Install',
'quickstart.step1Label2': 'Verify Installation',
'quickstart.step2Title': 'Configure',
'quickstart.step2Desc': 'Configure your LLM info. Supports Anthropic and OpenAI-compatible protocols. Config file at ~/.open-code-review/config.json.',
'quickstart.step2Label1': 'Configure LLM',
'quickstart.step2Label2': 'Configure Language (default English)',
'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',
// Docs Page
'docs.toc': 'Table of Contents',
'docs.overview': 'Overview',
'docs.install': 'Install',
'docs.config': 'ocr config',
'docs.review': 'ocr review',
'docs.viewer': 'ocr viewer',
'docs.env': 'Claude Code Integration',
'docs.overviewTitle': 'Overview',
'docs.overviewDesc': '(short <code>ocr</code>) 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.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',
'docs.configTitle': 'Configuration & Verification',
'docs.configDesc': 'Manage CLI configuration settings, stored in <code>~/.open-code-review/config.json</code>.',
'docs.configCommand': 'Command',
'docs.configExample': 'Example',
'docs.configKeys': 'Supported Config Keys',
'docs.configKeyUrl': 'API URL',
'docs.configKeyToken': 'API Key',
'docs.configKeyModel': 'Model Name',
'docs.configKeyAnthropic': 'Use Anthropic SDK',
'docs.configKeyLanguage': 'Output Language',
'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': '<code>ocr review</code> is the core command for initiating AI code review.',
'docs.reviewModes': 'Review Modes',
'docs.reviewWorkspace': 'Workspace Mode (Default)',
'docs.reviewWorkspaceDesc': 'Reviews staged + unstaged + untracked changes in the current workspace.',
'docs.reviewBranch': 'Branch Diff Mode',
'docs.reviewBranchDesc': 'Reviews the diff between two refs (based on merge-base).',
'docs.reviewCommit': 'Single Commit Mode',
'docs.reviewCommitDesc': 'Reviews a specific commit (diff against its parent).',
'docs.reviewAdvanced': 'Advanced Usage',
'docs.reviewBackground': 'Review with Requirement Context',
'docs.reviewBackgroundDesc': 'Provide the requirement context (including important boundaries) to improve review quality.',
'docs.reviewJson': 'JSON Output',
'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.reviewFlags': 'Flag Reference',
'docs.reviewFlagCol1': 'Flag',
'docs.reviewFlagCol2': 'Description',
'docs.reviewFlagCol3': 'Default',
'docs.reviewFlag1Desc': 'Specify commit hash to review',
'docs.reviewFlag2Desc': 'Start ref (diff origin)',
'docs.reviewFlag3Desc': 'Target ref (diff endpoint)',
'docs.reviewFlag4Desc': 'Output format: text or json',
'docs.reviewFlag5Desc': 'Git repository root',
'docs.reviewFlag5Default': 'Current dir',
'docs.reviewFlag6Desc': 'Path to review rules JSON file',
'docs.reviewFlag6Default': 'Built-in',
'docs.reviewFlag7Desc': 'Max concurrent file reviews',
'docs.reviewFlag8Desc': 'Single task timeout (minutes)',
'docs.reviewFlag9Desc': 'Output audience: human (show process) or agent (summary only)',
'docs.reviewNote': 'Note: <code>--from/--to</code> and <code>--commit</code> cannot be used together. When specifying <code>--from</code>, <code>--to</code> 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, <code>open-code-review</code> will recognize them automatically — no extra configuration needed:',
'docs.envNote': 'You can also use <code>ocr config</code> 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',
};

2
pages/src/i18n/index.ts Normal file
View file

@ -0,0 +1,2 @@
export { LanguageProvider, useTranslation } from './context';
export type { Language, TranslationKeys } from './types';

3
pages/src/i18n/types.ts Normal file
View file

@ -0,0 +1,3 @@
export type Language = 'en' | 'zh';
export type TranslationKeys = Record<string, string>;

167
pages/src/i18n/zh.ts Normal file
View file

@ -0,0 +1,167 @@
import { TranslationKeys } from './types';
export const zh: TranslationKeys = {
// Navbar
'navbar.features': '核心功能',
'navbar.benchmark': '性能基准',
'navbar.quickstart': '快速开始',
'navbar.docs': '使用文档',
'navbar.getStarted': '立即开始',
// Hero
'hero.title': 'AI 代码评审',
'hero.titleHighlight': '数百万真实场景任务的验证',
'hero.description': 'open-code-review 将阿里巴巴经大规模生产验证的代码评审 Agent 带入你的工作流。接入任意 LLM数据完全私有获得开发者真正愿意采纳的评审意见。',
'hero.pill1': '采纳率 > 30%',
'hero.pill2': '数据本地闭环',
'hero.pill3': 'Token 消耗仅 1/5',
'hero.cta1': '快速开始',
'hero.cta2': '查看基准测试',
'hero.users': '2 万+ 活跃用户',
'hero.openSource': '完全开源',
'hero.terminal': 'ocr 终端',
'hero.badgeLabel': '基准榜第 #1',
// 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': '作为强化学习训练流水线中的代码质量验证器,为代码生成模型提供可靠奖励信号。',
// Features Section
'features.sectionBadge': '核心特性',
'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。可接入自定义模型端点。',
'features.feat4Title': '动态并发处理',
'features.feat4Desc': '动态拆分子任务并行评审,可配置 goroutine worker 数量(默认 8 个)。大变更集也能快速完成。',
'features.feat5Title': '智能记忆压缩',
'features.feat5Desc': '面向代码评审场景的智能记忆压缩三层分区frozen/compress/active上下文管理突破 token 限制,确保深度评审。',
'features.feat6Title': '内置评审规则',
'features.feat6Desc': '经过线上海量真实场景验证迭代而来的评审规则,覆盖 Java、TypeScript、Go、Python、Kotlin、C++、C 等 10+ 语言,专项规则涵盖 NPE、线程安全、XSS、SQL 注入等常见风险类型。',
// Benchmark Section
'benchmark.sectionLabel': '开放基准测试',
'benchmark.title': '80+ 位资深工程师交叉标注验证',
'benchmark.subtitle': '基于真实场景的 CodeReview 基准测试,由 80+ 位资深工程师交叉标注。open-code-review 在 F1、精确率、召回率上全面领先通用 Agent 方案。',
'benchmark.legendOcr': 'open-code-review',
'benchmark.legendClaude': 'Claude Code + Skills通用方案',
'benchmark.colRank': '排名',
'benchmark.colModel': '模型',
'benchmark.colSource': '来源',
'benchmark.colVersion': '版本',
'benchmark.footer': '基准测试由 80+ 位资深工程师交叉标注 · 语义 F1 指标',
// QuickStart Section
'quickstart.sectionLabel': '快速开始',
'quickstart.title': '几分钟内跑起来',
'quickstart.subtitle': '无需复杂配置,除所选 LLM 服务商外无需任何第三方账号。',
'quickstart.step1Title': '安装',
'quickstart.step1Desc': '通过 npm 一键全局安装。',
'quickstart.step1Label1': '安装',
'quickstart.step1Label2': '验证安装',
'quickstart.step2Title': '配置',
'quickstart.step2Desc': '配置您的 LLM 信息,支持 Anthropic 和 OpenAI 兼容协议。配置文件位于 ~/.open-code-review/config.json。',
'quickstart.step2Label1': '配置 LLM',
'quickstart.step2Label2': '配置语言(默认英文)',
'quickstart.step2Label3': '验证配置',
'quickstart.step3Title': '运行评审',
'quickstart.step3Desc': '开始你的第一次代码评审。',
'quickstart.zeroCfgTitle': '已在使用 Claude Code零额外配置',
'quickstart.zeroCfgDesc': '如果你已经配置了 Claude Code 的环境变量ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN / ANTHROPIC_MODELopen-code-review 会自动读取这些配置,无需任何额外设置即可直接使用。',
'quickstart.copy': '复制',
// 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': '(简称 <code>ocr</code>)是一个 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 的配置设置,存储在 <code>~/.open-code-review/config.json</code>。',
'docs.configCommand': '命令',
'docs.configExample': '示例',
'docs.configKeys': '支持的配置 Key',
'docs.configKeyUrl': 'API 地址',
'docs.configKeyToken': 'API 密钥',
'docs.configKeyModel': '模型名称',
'docs.configKeyAnthropic': '是否使用 Anthropic SDK',
'docs.configKeyLanguage': '输出语言',
'docs.configKeyTelemetry': '数据上报',
'docs.configVerify': '验证配置',
'docs.configVerifyDesc': '完成配置后运行此命令,工具会根据当前配置尝试连接指定的 LLM 服务,帮助你确认配置是否正确。',
'docs.reviewTitle': 'ocr review',
'docs.reviewDesc': '<code>ocr review</code> 是核心命令,用于发起 AI 代码评审。',
'docs.reviewModes': '评审模式',
'docs.reviewWorkspace': '工作区模式(默认)',
'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.reviewAgent': 'Agent 模式',
'docs.reviewAgentDesc': '仅输出最终摘要,不显示执行过程,适合作为自动化流程的一部分使用。',
'docs.reviewFlags': 'Flag 参考',
'docs.reviewFlagCol1': 'Flag',
'docs.reviewFlagCol2': '说明',
'docs.reviewFlagCol3': '默认值',
'docs.reviewFlag1Desc': '指定提交哈希进行评审',
'docs.reviewFlag2Desc': '起始引用diff 起点)',
'docs.reviewFlag3Desc': '目标引用diff 终点)',
'docs.reviewFlag4Desc': '输出格式text 或 json',
'docs.reviewFlag5Desc': 'Git 仓库根目录',
'docs.reviewFlag5Default': '当前目录',
'docs.reviewFlag6Desc': '评审规则的 JSON 文件路径',
'docs.reviewFlag6Default': '内置',
'docs.reviewFlag7Desc': '最大并发评审文件数',
'docs.reviewFlag8Desc': '单个并发任务超时时间(分钟)',
'docs.reviewFlag9Desc': '输出受众human显示过程或 agent仅摘要',
'docs.reviewNote': '注意:<code>--from/--to</code> 和 <code>--commit</code> 不可同时使用。指定 <code>--from</code> 时必须同时指定 <code>--to</code>。',
'docs.viewerTitle': 'ocr viewer',
'docs.viewerDesc': '启动 WebUI 会话查看器,用于以网页形式浏览评审结果。',
'docs.viewerNote': '运行后会在本地启动一个 HTTP 服务器,提供可视化的评审结果浏览界面。',
'docs.envTitle': '适配 Claude Code',
'docs.envDesc': '如果你已经是 Claude Code 用户并配置了以下环境变量,<code>open-code-review</code> 会自动识别,无需任何额外配置:',
'docs.envNote': '此外,工具也支持通过 <code>ocr config</code> 命令来覆盖或补充这些配置。',
'docs.copy': '复制',
// QuickStart code comments
'quickstart.commentReview': '# 评审当前变更',
'quickstart.commentBranch': '# 评审分支间的差异',
'quickstart.commentCommit': '# 评审指定提交',
'quickstart.commentEnvAuto': '# open-code-review 自动识别这些变量',
};

19
pages/src/index.tsx Normal file
View file

@ -0,0 +1,19 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { HashRouter } from 'react-router-dom';
import App from './App';
import { LanguageProvider } from './i18n';
import './styles/index.css';
const rootElement = document.getElementById('root') as HTMLElement;
const root = ReactDOM.createRoot(rootElement);
root.render(
<React.StrictMode>
<LanguageProvider>
<HashRouter>
<App />
</HashRouter>
</LanguageProvider>
</React.StrictMode>
);

View file

@ -0,0 +1,421 @@
import React, { useState, useEffect, useRef } from 'react';
import Navbar from '../components/Navbar';
import { useTranslation } from '../i18n';
interface Section {
id: string;
labelKey: string;
}
const sectionDefs: Section[] = [
{ id: 'overview', labelKey: 'docs.overview' },
{ id: 'install', labelKey: 'docs.install' },
{ id: 'config', labelKey: 'docs.config' },
{ id: 'review', labelKey: 'docs.review' },
{ id: 'viewer', labelKey: 'docs.viewer' },
{ id: 'env', labelKey: 'docs.env' },
];
const CodeBlock: React.FC<{ code: string; copied?: boolean; onCopy?: () => void; copyLabel?: string }> = ({ code, copied, onCopy, copyLabel }) => (
<div className="relative group/code">
<div className="code-block rounded-xl p-4 overflow-x-auto group-hover/code:border-brand-500/30 transition-colors duration-300">
<pre className="font-mono text-xs text-brand-400 whitespace-pre">{code}</pre>
</div>
{onCopy && (
<button
onClick={onCopy}
className="absolute top-2 right-3 text-slate-600 hover:text-brand-400 transition-colors text-xs flex items-center gap-1 opacity-60 group-hover/code:opacity-100"
>
<i className={`fa-solid ${copied ? 'fa-check text-brand-400' : 'fa-copy'}`}></i>
{copied ? '' : (copyLabel || '')}
</button>
)}
</div>
);
const DocSection: React.FC<{ id: string; title: string; children: React.ReactNode }> = ({ id, title, children }) => (
<section id={id} className="mb-16 scroll-mt-24">
<h2 className="text-2xl font-bold text-white mb-6 pb-2 border-b border-dark-600/30">{title}</h2>
{children}
</section>
);
const DocsPage: React.FC = () => {
const [activeSection, setActiveSection] = useState('overview');
const [mobileTocOpen, setMobileTocOpen] = useState(false);
const [copiedIndex, setCopiedIndex] = useState<string | null>(null);
const lockedRef = useRef<string | null>(null);
const { t } = useTranslation();
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);
});
} 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);
}
};
useEffect(() => {
const THRESHOLD = 160;
const handleScroll = () => {
if (lockedRef.current) return;
let bestIndex = 0;
let bestTop = -Infinity;
for (let i = 0; i < sectionDefs.length; i++) {
const el = document.getElementById(sectionDefs[i].id);
if (!el) continue;
const top = el.getBoundingClientRect().top;
if (top <= THRESHOLD && top > bestTop) {
bestTop = top;
bestIndex = i;
}
}
setActiveSection(sectionDefs[bestIndex].id);
};
window.addEventListener('scroll', handleScroll);
return () => {
window.removeEventListener('scroll', handleScroll);
clearTimeout(unlockTimerRef.current);
};
}, []);
const unlockTimerRef = useRef<ReturnType<typeof setTimeout>>();
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}`);
}
setActiveSection(id);
unlockTimerRef.current = setTimeout(() => {
lockedRef.current = null;
}, 800);
};
return (
<div className="min-h-screen bg-dark-900 relative noise-overlay pt-16">
<div className="absolute inset-0 pointer-events-none">
<div className="absolute top-0 left-1/4 w-[800px] h-[600px] rounded-full bg-brand-500/[0.02] blur-[120px]"></div>
</div>
<Navbar />
{/* Mobile TOC toggle */}
<div className="lg:hidden fixed top-16 right-4 z-50">
<button
className="text-slate-400 hover:text-white transition-colors text-sm flex items-center gap-2 bg-dark-900/80 backdrop-blur-xl border border-dark-600/30 rounded-lg px-3 py-1.5"
onClick={() => setMobileTocOpen(!mobileTocOpen)}
>
<i className="fa-solid fa-list-ul"></i>
{t('docs.toc')}
</button>
</div>
{/* Mobile TOC dropdown */}
{mobileTocOpen && (
<div className="lg:hidden fixed inset-0 z-[60] bg-black/60" onClick={() => setMobileTocOpen(false)}>
<div
className="bg-dark-900 border-r border-dark-600/30 w-64 max-h-full overflow-y-auto pt-16 pb-8 px-4"
onClick={(e) => e.stopPropagation()}
>
<ul className="space-y-1">
{sections.map((s) => (
<li key={s.id}>
<button
onClick={() => { scrollToSection(s.id); setMobileTocOpen(false); }}
className={`block w-full text-left px-3 py-2 rounded-lg text-sm transition-colors ${
activeSection === s.id ? 'text-brand-400 bg-brand-500/10 font-medium' : 'text-slate-400 hover:text-white hover:bg-dark-800/50'
}`}
>
{s.label}
</button>
</li>
))}
</ul>
</div>
</div>
)}
<div className="max-w-7xl mx-auto px-6 py-12 flex gap-12 relative z-10">
{/* Sidebar TOC — desktop */}
<aside className="hidden lg:block w-56 flex-shrink-0 sticky top-24 self-start max-h-[calc(100vh-120px)] overflow-y-auto">
<p className="text-slate-500 text-xs font-mono uppercase tracking-widest mb-4">{t('docs.toc')}</p>
<ul className="space-y-1 border-l border-dark-600/20 pl-4">
{sections.map((s) => (
<li key={s.id}>
<button
onClick={() => scrollToSection(s.id)}
className={`w-full text-left block py-1.5 text-sm transition-all border-l-2 -ml-4 pl-4 ${
activeSection === s.id
? 'text-brand-400 border-brand-500 font-medium'
: 'text-slate-500 border-transparent hover:text-slate-300 hover:border-slate-700'
}`}
>
{s.label}
</button>
</li>
))}
</ul>
</aside>
{/* Main content */}
<main className="flex-1 min-w-0 max-w-3xl">
{/* Overview */}
<DocSection id="overview" title={t('docs.overviewTitle')}>
<p className="text-slate-300 leading-relaxed mb-4">
<code className="text-brand-400 bg-dark-800/50 px-1.5 py-0.5 rounded text-sm font-mono">open-code-review</code>{' '}
<span dangerouslySetInnerHTML={{ __html: t('docs.overviewDesc') }} />
</p>
<div className="glass rounded-xl p-5 mb-6">
<p className="text-slate-400 text-sm mb-3"><strong className="text-white">{t('docs.overviewFeatures')}</strong></p>
<ul className="space-y-2 text-sm text-slate-400">
{(['docs.overviewFeat1', 'docs.overviewFeat2', 'docs.overviewFeat3', 'docs.overviewFeat4', 'docs.overviewFeat5', 'docs.overviewFeat6'] as const).map((key) => (
<li key={key} className="flex items-start gap-2"><i className="fa-solid fa-check text-brand-500 mt-1 text-xs"></i>{t(key)}</li>
))}
</ul>
</div>
</DocSection>
{/* Install */}
<DocSection id="install" title={t('docs.installTitle')}>
<div className="space-y-4 mb-8">
<div className="feature-card rounded-xl p-4 glass">
<h4 className="text-white font-semibold mb-2 flex items-center gap-2">
<i className="fa-solid fa-download text-brand-400 text-sm"></i>
{t('docs.installLabel')}
</h4>
<CodeBlock
code="npm i -g open-code-review"
copied={copiedIndex === 'install'}
onCopy={() => handleCopy('npm i -g open-code-review', 'install')}
copyLabel={t('docs.copy')}
/>
</div>
<div className="feature-card rounded-xl p-4 glass">
<h4 className="text-white font-semibold mb-2 flex items-center gap-2">
<i className="fa-solid fa-circle-check text-brand-400 text-sm"></i>
{t('docs.installVerifyLabel')}
</h4>
<CodeBlock
code="ocr version"
copied={copiedIndex === 'install-verify'}
onCopy={() => handleCopy('ocr version', 'install-verify')}
copyLabel={t('docs.copy')}
/>
</div>
</div>
</DocSection>
{/* Config */}
<DocSection id="config" title={t('docs.configTitle')}>
<p className="text-slate-300 leading-relaxed mb-6" dangerouslySetInnerHTML={{ __html: t('docs.configDesc') }} />
<h3 className="text-lg font-semibold text-white mb-3">{t('docs.configCommand')}</h3>
<CodeBlock code="ocr config set &lt;key&gt; &lt;value&gt;" />
<h3 className="text-lg font-semibold text-white mb-3 mt-8">{t('docs.configExample')}</h3>
<div className="space-y-3 mb-8">
<CodeBlock
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 \\\n && ocr config set language Chinese`}
copied={copiedIndex === 'config-examples'}
onCopy={() => 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')}
/>
</div>
<h3 className="text-lg font-semibold text-white mb-3">{t('docs.configKeys')}</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{[
{ 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: 'language', descKey: 'docs.configKeyLanguage' },
{ key: 'telemetry.enabled', descKey: 'docs.configKeyTelemetry' },
].map(({ key, descKey }) => (
<div key={key} className="rounded-lg bg-dark-800/40 px-3 py-2 border border-dark-600/20">
<code className="text-brand-400 font-mono text-sm">{key}</code>
<span className="text-slate-500 text-sm ml-2">{t(descKey)}</span>
</div>
))}
</div>
<h3 className="text-lg font-semibold text-white mb-3 mt-8">{t('docs.configVerify')}</h3>
<CodeBlock
code={`# Test LLM connection\nocr llm test`}
/>
<p className="text-slate-400 text-sm mt-4">
{t('docs.configVerifyDesc')}
</p>
</DocSection>
{/* Review */}
<DocSection id="review" title={t('docs.reviewTitle')}>
<p className="text-slate-300 leading-relaxed mb-6" dangerouslySetInnerHTML={{ __html: t('docs.reviewDesc') }} />
<h3 className="text-lg font-semibold text-white mb-3">{t('docs.reviewModes')}</h3>
<div className="space-y-4 mb-8">
<div className="feature-card rounded-xl p-4 glass">
<h4 className="text-white font-semibold mb-2 flex items-center gap-2">
<i className="fa-solid fa-pen-to-square text-brand-400 text-sm"></i>
{t('docs.reviewWorkspace')}
</h4>
<p className="text-slate-400 text-sm mb-2">{t('docs.reviewWorkspaceDesc')}</p>
<CodeBlock code="ocr review" />
</div>
<div className="feature-card rounded-xl p-4 glass">
<h4 className="text-white font-semibold mb-2 flex items-center gap-2">
<i className="fa-solid fa-code-branch text-brand-400 text-sm"></i>
{t('docs.reviewBranch')}
</h4>
<p className="text-slate-400 text-sm mb-2">{t('docs.reviewBranchDesc')}</p>
<CodeBlock
code="ocr review --from master --to dev-ref"
copied={copiedIndex === 'review-branch'}
onCopy={() => handleCopy('ocr review --from master --to dev-ref', 'review-branch')}
copyLabel={t('docs.copy')}
/>
</div>
<div className="feature-card rounded-xl p-4 glass">
<h4 className="text-white font-semibold mb-2 flex items-center gap-2">
<i className="fa-solid fa-code-commit text-brand-400 text-sm"></i>
{t('docs.reviewCommit')}
</h4>
<p className="text-slate-400 text-sm mb-2">{t('docs.reviewCommitDesc')}</p>
<CodeBlock
code={`ocr review --commit abc123\nocr review -c abc123`}
copied={copiedIndex === 'review-commit'}
onCopy={() => handleCopy('ocr review -c abc123', 'review-commit')}
copyLabel={t('docs.copy')}
/>
</div>
</div>
<h3 className="text-lg font-semibold text-white mb-3">{t('docs.reviewAdvanced')}</h3>
<div className="space-y-4 mb-8">
<div className="feature-card rounded-xl p-4 glass">
<h4 className="text-white font-semibold mb-2 flex items-center gap-2">
<i className="fa-solid fa-file-code text-brand-400 text-sm"></i>
{t('docs.reviewBackground')}
</h4>
<p className="text-slate-400 text-sm mb-2">{t('docs.reviewBackgroundDesc')}</p>
<CodeBlock
code={`ocr review --background "requirement context"\nocr review -b "requirement context"`}
copied={copiedIndex === 'review-background'}
onCopy={() => handleCopy('ocr review --background "requirement context"', 'review-background')}
copyLabel={t('docs.copy')}
/>
</div>
<div className="feature-card rounded-xl p-4 glass">
<h4 className="text-white font-semibold mb-2 flex items-center gap-2">
<i className="fa-solid fa-file-code text-brand-400 text-sm"></i>
{t('docs.reviewJson')}
</h4>
<p className="text-slate-400 text-sm mb-2">{t('docs.reviewJsonDesc')}</p>
<CodeBlock
code={`ocr review --format json\nocr review -f json`}
copied={copiedIndex === 'review-json'}
onCopy={() => handleCopy('ocr review --format json', 'review-json')}
copyLabel={t('docs.copy')}
/>
</div>
<div className="feature-card rounded-xl p-4 glass">
<h4 className="text-white font-semibold mb-2 flex items-center gap-2">
<i className="fa-solid fa-robot text-brand-400 text-sm"></i>
{t('docs.reviewAgent')}
</h4>
<p className="text-slate-400 text-sm mb-2">{t('docs.reviewAgentDesc')}</p>
<CodeBlock
code="ocr review --audience agent"
copied={copiedIndex === 'review-agent'}
onCopy={() => handleCopy('ocr review --audience agent', 'review-agent')}
copyLabel={t('docs.copy')}
/>
</div>
</div>
<h3 className="text-lg font-semibold text-white mb-3">{t('docs.reviewFlags')}</h3>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-dark-600/30">
<th className="text-left py-2 px-3 text-slate-400 font-mono text-xs">{t('docs.reviewFlagCol1')}</th>
<th className="text-left py-2 px-3 text-slate-400 text-xs">{t('docs.reviewFlagCol2')}</th>
<th className="text-left py-2 px-3 text-slate-400 text-xs">{t('docs.reviewFlagCol3')}</th>
</tr>
</thead>
<tbody>
{[
['-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'],
].map(([flag, desc, def]) => (
<tr key={flag} className="border-b border-dark-800/30 hover:bg-dark-800/20 transition-colors">
<td className="py-2 px-3"><code className="text-brand-400 font-mono text-xs whitespace-nowrap">{flag}</code></td>
<td className="py-2 px-3 text-slate-300">{desc}</td>
<td className="py-2 px-3 text-slate-500 font-mono text-xs">{def || '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="text-slate-500 text-xs mt-3" dangerouslySetInnerHTML={{ __html: t('docs.reviewNote') }} />
</DocSection>
{/* Viewer */}
<DocSection id="viewer" title={t('docs.viewerTitle')}>
<p className="text-slate-300 leading-relaxed mb-6">
{t('docs.viewerDesc')}
</p>
<CodeBlock code="ocr viewer" />
<p className="text-slate-400 text-sm mt-4">
{t('docs.viewerNote')}
</p>
</DocSection>
{/* Environment variables */}
<DocSection id="env" title={t('docs.envTitle')}>
<p className="text-slate-300 leading-relaxed mb-4" dangerouslySetInnerHTML={{ __html: t('docs.envDesc') }} />
<CodeBlock
code={`export ANTHROPIC_BASE_URL=https://api.anthropic.com
export ANTHROPIC_AUTH_TOKEN=sk-ant-xxxxx
export ANTHROPIC_MODEL=claude-opus-4-6
${t('quickstart.commentEnvAuto')} `}
/>
<p className="text-slate-400 text-sm mt-4" dangerouslySetInnerHTML={{ __html: t('docs.envNote') }} />
</DocSection>
{/* Footer spacer */}
<div className="h-32"></div>
</main>
</div>
</div>
);
};
export default DocsPage;

405
pages/src/styles/index.css Normal file
View file

@ -0,0 +1,405 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
* {
-webkit-tap-highlight-color: transparent;
}
html {
scroll-behavior: smooth;
}
body {
background-color: #030712;
color: #f8fafc;
font-family: 'Inter', sans-serif;
overflow-x: hidden;
}
/* ========== Noise Texture Overlay ========== */
.noise-overlay::before {
content: '';
position: fixed;
inset: 0;
pointer-events: none;
z-index: 9999;
opacity: 0.025;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E");
background-repeat: repeat;
background-size: 256px 256px;
}
/* ========== Scrollbar ========== */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: #0a0f1e;
}
::-webkit-scrollbar-thumb {
background: #334155;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #22c55e;
}
/* ========== Grid Background ========== */
.grid-bg {
background-image:
linear-gradient(rgba(34, 197, 94, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(34, 197, 94, 0.03) 1px, transparent 1px);
background-size: 60px 60px;
}
/* ========== Radial Glow Orbs ========== */
.glow-orb {
position: absolute;
border-radius: 50%;
filter: blur(80px);
pointer-events: none;
opacity: 0.5;
}
/* ========== Glow Effects ========== */
.glow-green {
box-shadow:
0 0 30px rgba(34, 197, 94, 0.25),
0 0 60px rgba(34, 197, 94, 0.1);
}
.glow-green-sm {
box-shadow:
0 0 12px rgba(34, 197, 94, 0.3),
0 0 24px rgba(34, 197, 94, 0.1);
}
.glow-card {
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.3),
0 4px 16px rgba(0, 0, 0, 0.2);
}
.text-glow {
text-shadow:
0 0 20px rgba(34, 197, 94, 0.4),
0 0 40px rgba(34, 197, 94, 0.2);
}
.border-glow {
border-color: rgba(34, 197, 94, 0.4);
box-shadow:
0 0 0 1px rgba(34, 197, 94, 0.1) inset,
0 0 16px rgba(34, 197, 94, 0.06);
}
/* ========== Glassmorphism ========== */
.glass {
background: rgba(15, 23, 42, 0.5);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.06);
}
.glass-strong {
background: rgba(15, 23, 42, 0.7);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.08);
}
/* ========== Terminal Cursor Blink ========== */
.terminal-cursor::after {
content: '|';
animation: blink 1s step-end infinite;
color: #22c55e;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
/* ========== Stat Card (Enhanced) ========== */
.stat-card {
background: linear-gradient(145deg, rgba(15, 23, 42, 0.9) 0%, rgba(10, 15, 30, 0.95) 100%);
border: 1px solid rgba(34, 197, 94, 0.1);
backdrop-filter: blur(8px);
transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: hidden;
}
.stat-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(34, 197, 94, 0.3), transparent);
opacity: 0;
transition: opacity 0.35s ease;
}
.stat-card:hover {
border-color: rgba(34, 197, 94, 0.3);
transform: translateY(-4px);
box-shadow:
0 12px 40px rgba(34, 197, 94, 0.12),
0 4px 16px rgba(0, 0, 0, 0.3);
}
.stat-card:hover::before {
opacity: 1;
}
/* ========== Feature Card (Enhanced) ========== */
.feature-card {
background: linear-gradient(160deg, rgba(15, 23, 42, 0.7) 0%, rgba(10, 15, 30, 0.85) 100%);
border: 1px solid rgba(51, 65, 85, 0.4);
backdrop-filter: blur(6px);
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: hidden;
}
.feature-card::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
background: radial-gradient(circle at var(--mouse-x, 50%) var(--mouse-y, 50%), rgba(34, 197, 94, 0.08) 0%, transparent 60%);
opacity: 0;
transition: opacity 0.4s ease;
pointer-events: none;
}
.feature-card:hover::before {
opacity: 1;
}
.feature-card:hover {
border-color: rgba(34, 197, 94, 0.25);
box-shadow:
0 8px 32px rgba(34, 197, 94, 0.08),
0 4px 16px rgba(0, 0, 0, 0.3);
transform: translateY(-4px);
}
/* ========== Nav Link ========== */
.nav-link {
position: relative;
transition: color 0.2s ease;
}
.nav-link::after {
content: '';
position: absolute;
bottom: -2px;
left: 0;
width: 0;
height: 1px;
background: linear-gradient(90deg, #22c55e, #4ade80);
transition: width 0.3s ease;
}
.nav-link:hover::after {
width: 100%;
}
/* ========== Primary Button (Enhanced) ========== */
.btn-primary {
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
.btn-primary::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent);
transition: left 0.5s ease;
}
.btn-primary:hover::before {
left: 100%;
}
.btn-primary:hover {
box-shadow:
0 0 24px rgba(34, 197, 94, 0.5),
0 0 48px rgba(34, 197, 94, 0.2);
transform: translateY(-2px);
}
/* ========== Secondary Button ========== */
.btn-secondary {
border: 1px solid rgba(34, 197, 94, 0.4);
background: rgba(34, 197, 94, 0.05);
backdrop-filter: blur(4px);
transition: all 0.3s ease;
}
.btn-secondary:hover {
background: rgba(34, 197, 94, 0.12);
border-color: rgba(34, 197, 94, 0.7);
box-shadow: 0 0 16px rgba(34, 197, 94, 0.15);
}
/* ========== Rank Badge ========== */
.rank-badge {
background: linear-gradient(145deg, rgba(34, 197, 94, 0.15) 0%, rgba(22, 163, 74, 0.08) 100%);
border: 1px solid rgba(34, 197, 94, 0.25);
backdrop-filter: blur(8px);
}
/* ========== Leaderboard Row ========== */
.leaderboard-row {
border-bottom: 1px solid rgba(51, 65, 85, 0.3);
transition: background 0.2s ease;
}
.leaderboard-row:hover {
background: rgba(34, 197, 94, 0.04);
}
/* ========== Tags ========== */
.tag-open {
background: rgba(34, 197, 94, 0.15);
color: #4ade80;
border: 1px solid rgba(34, 197, 94, 0.3);
}
.tag-closed {
background: rgba(239, 68, 68, 0.12);
color: #f87171;
border: 1px solid rgba(239, 68, 68, 0.25);
}
.tag-reproduced {
background: rgba(99, 102, 241, 0.12);
color: #a5b4fc;
border: 1px solid rgba(99, 102, 241, 0.25);
}
/* ========== Progress Bar ========== */
.progress-bar {
background: linear-gradient(90deg, #22c55e, #4ade80);
border-radius: 2px;
height: 3px;
transition: width 1s ease;
}
/* ========== Section Divider (Enhanced) ========== */
.section-divider {
background: linear-gradient(90deg, transparent, rgba(34, 197, 94, 0.2), transparent);
height: 1px;
}
.section-divider-soft {
background: linear-gradient(90deg, transparent, rgba(51, 65, 85, 0.3), transparent);
height: 1px;
}
/* ========== Code Block ========== */
.code-block {
background: rgba(10, 15, 30, 0.9);
border: 1px solid rgba(51, 65, 85, 0.5);
border-left: 3px solid #22c55e;
}
/* ========== Floating Badge ========== */
.floating-badge {
animation: float 3s ease-in-out infinite;
}
@keyframes float {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-8px); }
}
/* ========== Animated Gradient Border ========== */
.gradient-border {
position: relative;
}
.gradient-border::after {
content: '';
position: absolute;
inset: -1px;
border-radius: inherit;
padding: 1px;
background: linear-gradient(135deg, #22c55e, #06b6d4, #8b5cf6, #22c55e);
background-size: 300% 300%;
animation: gradientShift 6s ease infinite;
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
opacity: 0;
transition: opacity 0.4s ease;
}
.gradient-border:hover::after {
opacity: 1;
}
@keyframes gradientShift {
0%, 100% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
}
/* ========== Spotlight Effect ========== */
.spotlight {
position: relative;
overflow: hidden;
}
.spotlight::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: radial-gradient(circle, rgba(34, 197, 94, 0.03) 0%, transparent 70%);
pointer-events: none;
animation: spotlightRotate 10s linear infinite;
}
@keyframes spotlightRotate {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* ========== Shimmer Animation ========== */
@keyframes shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
.shimmer {
position: relative;
overflow: hidden;
}
.shimmer::after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 50%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(34, 197, 94, 0.03), transparent);
animation: shimmer 3s ease-in-out infinite;
}

54
pages/tailwind.config.js Normal file
View file

@ -0,0 +1,54 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./src/**/*.{ts,tsx}', './index.html'],
theme: {
extend: {
fontFamily: {
sans: ['Inter', 'sans-serif'],
mono: ['JetBrains Mono', 'monospace']
},
colors: {
brand: {
50: '#f0fdf4',
100: '#dcfce7',
400: '#4ade80',
500: '#22c55e',
600: '#16a34a'
},
dark: {
900: '#030712',
800: '#0a0f1e',
700: '#0f172a',
600: '#1e293b',
500: '#334155',
400: '#475569'
}
},
animation: {
'fade-in': 'fadeIn 0.6s ease-out forwards',
'slide-up': 'slideUp 0.6s ease-out forwards',
'glow-pulse': 'glowPulse 3s ease-in-out infinite',
'scan-line': 'scanLine 4s linear infinite'
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' }
},
slideUp: {
'0%': { opacity: '0', transform: 'translateY(24px)' },
'100%': { opacity: '1', transform: 'translateY(0)' }
},
glowPulse: {
'0%, 100%': { boxShadow: '0 0 20px rgba(34,197,94,0.2)' },
'50%': { boxShadow: '0 0 40px rgba(34,197,94,0.5)' }
},
scanLine: {
'0%': { transform: 'translateY(-100%)' },
'100%': { transform: 'translateY(100vh)' }
}
}
}
},
plugins: []
};

16
pages/tsconfig.json Normal file
View file

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"lib": ["DOM", "DOM.Iterable", "ES2020"],
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "node",
"resolveJsonModule": true,
"noEmit": true
},
"include": ["src"]
}

50
pages/webpack.config.js Normal file
View file

@ -0,0 +1,50 @@
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
mode: process.env.NODE_ENV === 'production' ? 'production' : 'development',
entry: './src/index.tsx',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'bundle.js',
publicPath: process.env.NODE_ENV === 'production' ? '/open-code-review/' : '/'
},
module: {
rules: [
{
test: /\.(ts|tsx)$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: [
['@babel/preset-react', { development: process.env.NODE_ENV !== 'production' }],
'@babel/preset-env',
'@babel/preset-typescript'
]
}
}
},
{
test: /\.css$/,
use: ['style-loader', 'css-loader', 'postcss-loader']
}
]
},
resolve: {
extensions: ['.ts', '.tsx', '.js', '.jsx']
},
devServer: {
port: 3030,
historyApiFallback: {
index: '/index.html',
rewrites: [{ from: /^\/_p\/\d+\//, to: '/index.html' }]
}
},
plugins: [
new HtmlWebpackPlugin({
template: './index.html',
inject: 'body'
})
]
};

224
scripts/install.js Normal file
View file

@ -0,0 +1,224 @@
#!/usr/bin/env node
"use strict";
const fs = require("fs");
const path = require("path");
const https = require("https");
const crypto = require("crypto");
const BINARY_NAME = "opencodereview";
const packageRoot = path.join(__dirname, "..");
const binDir = path.join(packageRoot, "bin");
const binaryDest = path.join(binDir, BINARY_NAME);
function info(msg) {
console.log(`[INFO] ${msg}`);
}
function warn(msg) {
console.warn(`[WARN] ${msg}`);
}
function error(msg) {
console.error(`[ERROR] ${msg}`);
}
function detectPlatform() {
const os = process.platform;
let arch = process.arch;
switch (arch) {
case "x64":
arch = "amd64";
break;
case "arm64":
arch = "arm64";
break;
default:
throw new Error(
`Unsupported architecture: ${arch}. Supported: amd64 (x64), arm64`
);
}
if (os !== "linux" && os !== "darwin") {
throw new Error(
`Unsupported operating system: ${os}. Supported: linux, darwin`
);
}
return { os, arch };
}
function loadPackageJson() {
const pkg = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf8"));
if (!pkg.version) {
throw new Error("Missing version field in package.json");
}
if (!pkg.ocrConfig || !pkg.ocrConfig.urlPattern) {
throw new Error("Missing ocrConfig.urlPattern in package.json");
}
return pkg;
}
function resolveVersion(pkg) {
const envVersion = process.env.OCR_VERSION;
if (envVersion) {
const v = envVersion.startsWith("v") ? envVersion.slice(1) : envVersion;
info(`Using pinned version from OCR_VERSION: ${v}`);
return v;
}
info(`Using version from package.json: ${pkg.version}`);
return pkg.version;
}
function buildUrl(pattern, vars) {
return pattern
.replace(/\{version\}/g, vars.version)
.replace(/\{os\}/g, vars.os)
.replace(/\{arch\}/g, vars.arch);
}
function download(url, maxRedirects = 10) {
if (!url.startsWith("https")) {
return Promise.reject(new Error(`Refusing non-HTTPS download: ${url}`));
}
if (maxRedirects <= 0) {
return Promise.reject(new Error(`Too many redirects fetching ${url}`));
}
return new Promise((resolve, reject) => {
https.get(url, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
res.resume();
download(res.headers.location, maxRedirects - 1).then(resolve).catch(reject);
return;
}
if (res.statusCode !== 200) {
res.resume();
reject(new Error(`HTTP ${res.statusCode} fetching ${url}`));
return;
}
resolve(res);
}).on("error", reject);
});
}
async function downloadText(url) {
const res = await download(url);
return new Promise((resolve, reject) => {
let data = "";
res.on("data", (chunk) => (data += chunk));
res.on("end", () => resolve(data));
res.on("error", reject);
});
}
async function downloadBinary(url, destPath) {
const res = await download(url);
return new Promise((resolve, reject) => {
const fileStream = fs.createWriteStream(destPath);
res.on("error", (err) => {
fileStream.destroy();
fs.unlink(destPath, () => {});
reject(err);
});
res.pipe(fileStream);
fileStream.on("finish", () => fileStream.close(() => resolve()));
fileStream.on("error", (err) => {
fs.unlink(destPath, () => {});
reject(err);
});
});
}
function computeChecksum(filePath) {
return new Promise((resolve, reject) => {
const hash = crypto.createHash("sha256");
const stream = fs.createReadStream(filePath);
stream.on("data", (chunk) => hash.update(chunk));
stream.on("end", () => resolve(hash.digest("hex")));
stream.on("error", reject);
});
}
async function main() {
info("OpenCodeReview Installer");
info("=========================");
const { os, arch } = detectPlatform();
info(`Detected platform: ${os}/${arch}`);
const pkg = loadPackageJson();
const version = resolveVersion(pkg);
const config = pkg.ocrConfig;
if (!fs.existsSync(binDir)) {
fs.mkdirSync(binDir, { recursive: true });
}
const jsWrapper = path.join(binDir, "ocr.js");
if (fs.existsSync(jsWrapper)) {
try {
fs.chmodSync(jsWrapper, 0o755);
} catch (e) {
warn(`Could not make ocr.js executable: ${e.message}`);
}
}
const vars = { version, os, arch };
const downloadUrl = buildUrl(config.urlPattern, vars);
info(`Downloading ${downloadUrl} ...`);
await downloadBinary(downloadUrl, binaryDest);
fs.chmodSync(binaryDest, 0o755);
if (config.checksumPattern) {
try {
const checksumUrl = buildUrl(config.checksumPattern, vars);
info("Verifying checksum...");
const shaContent = await downloadText(checksumUrl);
const actualSha = await computeChecksum(binaryDest);
let verified = false;
for (const line of shaContent.split("\n")) {
const trimmed = line.trim();
if (trimmed.includes(`-${os}-${arch}`)) {
const expectedSha = trimmed.split(/\s+/)[0].toLowerCase();
if (expectedSha) {
if (actualSha !== expectedSha) {
throw new Error(
`Checksum mismatch! Expected: ${expectedSha}, Got: ${actualSha}`
);
}
info("Checksum verified.");
verified = true;
break;
}
}
}
if (!verified) {
warn("No matching checksum entry found; skipping verification.");
}
} catch (e) {
if (e.message.includes("mismatch")) {
throw e;
}
warn(`Could not verify checksum: ${e.message}`);
}
}
info(`Installed: ${binaryDest}`);
info("");
info("OpenCodeReview is ready!");
info("");
info("Quick start:");
info(" ocr version Show version info");
info(" ocr config set Configure your LLM provider");
info(" ocr review Start a code review");
}
main().catch((err) => {
error(err.message);
process.exit(1);
});