-
@@ -92,6 +91,10 @@ agent의 강점은 동적 판단과 동적 context 검색이 중요한 지점에
## 사용 방법
+### 사전 요구 사항
+
+- **Git >= 2.41** — Open Code Review는 diff 생성, 코드 검색, 저장소 작업에 Git을 사용합니다.
+
### CLI
#### 설치
@@ -104,6 +107,18 @@ npm install -g @alibaba-group/open-code-review
설치 후 `ocr` 명령을 전역에서 사용할 수 있습니다.
+**업데이트**
+
+NPM으로 설치했다면 최신 버전으로 수동 업데이트할 수 있습니다:
+
+```bash
+npm install -g @alibaba-group/open-code-review@latest
+```
+
+NPM 설치의 `ocr`은 기본적으로 백그라운드에서 새 버전을 확인하고 자동으로 업데이트합니다. 자동 업데이트를 끄려면 `OCR_NO_UPDATE=1`을 설정하세요.
+
+설치 스크립트나 수동 다운로드한 binary로 설치했다면 같은 설치/다운로드 명령을 다시 실행해 로컬 binary를 최신 release로 교체할 수 있습니다. 특정 release tag로 고정해야 한다면 `OCR_VERSION`을 사용하세요.
+
**GitHub Release 사용**
명령 한 번으로 사용 중인 OS/아키텍처에 맞는 최신 binary를 설치합니다 (macOS / Linux):
@@ -265,6 +280,10 @@ ocr review --from main --to feature-branch
# 단일 commit
ocr review --commit abc123
+# 중단된 range 또는 단일 commit review 재개
+ocr session list
+ocr review --from main --to feature-branch --resume
+
# 전체 파일 스캔 — diff 대신 파일 전체를 리뷰 (git 이력 불필요)
ocr scan # 전체 repository 스캔
ocr scan --path internal/agent # 디렉터리 또는 특정 파일 스캔
@@ -400,10 +419,35 @@ ocr review \
`--format json` flag는 CI script에서 파싱하기 좋은 machine-readable 결과를 출력합니다.
+각 finding에는 두 개의 구조화된 field가 포함되어, CI 통합에서 comment 텍스트를 다시 파싱하지 않고도 정렬·그룹화·필터링하거나 build를 gate할 수 있습니다:
+
+| Field | 허용 값 | 설명 |
+|-------|--------|------|
+| `category` | `bug`, `security`, `performance`, `maintainability`, `test`, `style`, `documentation`, `other` | 이슈가 속한 카테고리. |
+| `severity` | `critical`, `high`, `medium`, `low` | 이슈의 중요도. |
+
+JSON 출력에서 두 field는 `content`, `start_line` 등과 같은 수준의 sibling으로 나타납니다. 터미널에서는 comment 앞에 인라인 `[category · severity]` badge로 표시되며 severity에 따라 색상이 지정됩니다.
+
통합 예시는 [`examples/`](./examples/) 디렉터리를 참고하세요.
- [`github_actions/`](./examples/github_actions/): GitHub Actions 통합 예시
- [`gitlab_ci/`](./examples/gitlab_ci/): GitLab CI 통합 예시
+- [`gitflic_ci/`](./examples/gitflic_ci/): GitFlic CI 통합 예시
+
+#### GitHub Action
+
+GitHub의 경우, 이 리포지터리는 루트에 바로 사용할 수 있는 composite Action([`action.yml`](./action.yml))을 제공합니다. 직접 `ocr review` 스크립트를 작성하는 대신 이를 참조하기만 하면 전체 파이프라인 — checkout, OCR 설치, review 실행, inline/summary comment 게시, artifact 업로드, 재시도 및 멱등성 — 을 모두 처리합니다:
+
+```yaml
+- uses: alibaba/open-code-review@main
+ with:
+ llm_url: ${{ secrets.OCR_LLM_URL }}
+ llm_auth_token: ${{ secrets.OCR_LLM_AUTH_TOKEN }}
+ llm_model: ${{ vars.OCR_LLM_MODEL }}
+ llm_use_anthropic: ${{ vars.OCR_LLM_USE_ANTHROPIC }}
+```
+
+재현성을 위해 version tag나 commit SHA에 고정하세요. 전체 workflow 데모와 inputs/outputs, comment 게시 모드(sticky summary, incremental non-destructive posting)의 전체 목록은 [`examples/github_actions/`](./examples/github_actions/) 디렉터리를 참고하세요.
## Commands
@@ -418,6 +462,8 @@ ocr review \
| `ocr config unset custom_providers.` | - | custom provider 삭제 |
| `ocr llm test` | - | LLM 연결 테스트 |
| `ocr llm providers` | - | built-in LLM provider 목록 표시 |
+| `ocr session list` | `ocr sessions list`, `ocr session ls` | 저장된 review session 목록 표시 |
+| `ocr session show ` | `ocr sessions show ` | 단일 session과 파일별 checkpoint 확인 |
| `ocr viewer` | `ocr v` | `localhost:5483`에서 WebUI session viewer 실행 |
| `ocr version` | - | version 정보 표시 |
@@ -431,17 +477,54 @@ ocr review \
| `--commit` | `-c` | - | 리뷰할 단일 commit |
| `--exclude` | - | - | 건너뛸 파일의 쉼표 구분 gitignore 스타일 패턴; rule.json의 excludes와 병합 |
| `--preview` | `-p` | `false` | LLM 실행 없이 리뷰 대상 파일 미리보기 |
+| `--resume` | - | - | 이전의 호환되는 range 또는 단일 commit review session에서 재개 |
| `--format` | `-f` | `text` | Output format: `text` 또는 `json` |
| `--concurrency` | - | `8` | 최대 동시 파일 리뷰 수 |
| `--timeout` | - | `10` | 동시 task timeout(분) |
| `--audience` | - | `human` | `human`(progress 표시) 또는 `agent`(summary only) |
| `--background` | `-b` | - | 리뷰를 위한 선택적 요구사항/비즈니스 컨텍스트. `--commit` 사용 시 미지정이면 commit message에서 자동 추출 |
+| `--background-file` | `-B` | - | Markdown 파일에서 읽어오는 선택적 요구사항/비즈니스 컨텍스트. `--background`와 함께 사용하면 inline 값이 먼저 배치됩니다 |
| `--model` | - | - | 이번 리뷰에서 LLM model 선택 또는 override |
| `--rule` | - | - | custom JSON review rules 경로 |
| `--max-tools` | - | built-in | 파일별 최대 tool call round. template default보다 클 때만 적용 |
| `--max-git-procs` | - | built-in | 최대 동시 git subprocess 수 |
| `--tools` | - | - | custom JSON tools config 경로 |
+#### Resumable Reviews and Sessions
+
+모든 `ocr review` 실행은 `~/.opencodereview/sessions/` 아래에 local session log를 저장합니다.
+정상 완료된 text output은 review 결과에 집중하며 session ID를 출력하지 않습니다.
+저장된 session은 `ocr session list/show`로 찾을 수 있고, `--format json`을 사용하면
+machine-readable output에 `session_id`가 포함됩니다. range 또는 단일 commit review가 중단된 경우,
+저장된 session을 나열한 뒤 동일한 review target과 일치하는 session에서 재개합니다.
+
+```bash
+ocr session list
+ocr session show
+ocr review --from main --to feature-branch --resume
+ocr review --commit abc123 --resume
+```
+
+Resume은 의도적으로 엄격합니다. branch range와 단일 commit review만 지원하고 workspace review는 지원하지 않습니다.
+현재 `--from/--to` 또는 `--commit`은 저장된 session과 일치해야 합니다. `--preview`와 `--resume`은 함께 사용할 수 없습니다.
+
+`--format json`을 사용하면 재개된 run에는 다음 field가 포함됩니다.
+
+- `session_id`: 현재 run의 session ID
+- `resume.resumed_from`: source session ID
+- `resume.reused_files`: 저장된 checkpoint에서 재사용한 파일 수
+- `resume.rerun_files`: 현재 run에서 다시 review한 파일 수
+
+### `ocr session` Flags
+
+| Command | Flag | Default | Description |
+|---------|------|---------|-------------|
+| `ocr session list` | `--repo` | current dir | session을 나열할 repository |
+| `ocr session list` | `--json` | `false` | session summary를 JSON으로 출력 |
+| `ocr session list` | `--limit` | `20` | 나열할 session 수 제한. `0`은 unlimited |
+| `ocr session show ` | `--repo` | current dir | 확인할 session의 repository |
+| `ocr session show ` | `--json` | `false` | session metadata와 파일별 item을 JSON으로 출력 |
+
### `ocr scan` Flags
`ocr scan`은 diff가 아닌 전체 파일을 리뷰합니다 — 익숙하지 않은 코드베이스 감사, 마이그레이션 전 스캔, 의미 있는 diff가 없는 디렉터리 등에 유용합니다. 비-git 디렉터리에서도 작동합니다 (`.gitignore`를 따르는 파일 시스템 탐색으로 폴백).
@@ -487,6 +570,12 @@ ocr review --from main --to my-feature --concurrency 4
# 특정 commit을 verbose JSON output으로 리뷰
ocr review --commit abc123 --format json --audience agent
+# 중단된 range 또는 단일 commit review 재개
+ocr session list
+ocr session show
+ocr review --from main --to my-feature --resume
+ocr review --commit abc123 --resume
+
# 이번 리뷰에서 model 선택 또는 override
ocr review --model claude-opus-4-6
ocr review --commit abc123 --model claude-sonnet-4-6
@@ -494,6 +583,12 @@ ocr review --commit abc123 --model claude-sonnet-4-6
# 요구사항 컨텍스트를 제공하여 더 정확한 리뷰 수행
ocr review --background "로그인 API에 rate limiting 추가"
+# Markdown 파일에서 요구사항 컨텍스트 제공
+ocr review --background-file ./docs/my_business_context.md
+
+# inline 컨텍스트와 로컬 컨텍스트 파일을 함께 사용(둘 다 적용됨)
+ocr review --background "인증에 집중" --background-file ./docs/my_business_context.md
+
# custom review rules 사용
ocr review --rule /path/to/my-rules.json
@@ -705,13 +800,22 @@ ocr config set telemetry.otlp_endpoint localhost:4317
exported data에 LLM prompt와 response를 포함하려면 `telemetry.content_logging`을 설정합니다.
+**프로토콜 선택:** 환경 변수 `OTEL_EXPORTER_OTLP_PROTOCOL`로 export 프로토콜을 선택할 수 있습니다:
+
+| 값 | 전송 방식 | 설명 |
+|---|---|---|
+| `grpc` (기본값) | gRPC | 기본 포트 4317 |
+| `http/protobuf` | HTTP | 기본 포트 4318 |
+
+**Endpoint 형식:** `telemetry.otlp_endpoint`는 `host:port` 또는 `http://host:port` 형식의 base URL을 지정합니다. 경로를 포함할 필요가 없습니다. SDK가 [OTLP 사양](https://opentelemetry.io/docs/specs/otlp/#otlphttp-request)에 따라 signal 경로(예: `/v1/traces`)를 자동으로 추가합니다.
+
## Contributing
-개발 환경 설정, coding guideline, pull request 제출 방법은 [CONTRIBUTING.ko-KR.md](CONTRIBUTING.ko-KR.md)를 참고하세요.
+이 프로젝트는 기여해 주신 모든 분들 덕분에 존재합니다. 개발 환경 설정, coding guideline, pull request 제출 방법은 [CONTRIBUTING.ko-KR.md](CONTRIBUTING.ko-KR.md)를 참고하세요.
-## Star History
-
-[](https://star-history.com/#alibaba/open-code-review&Date)
+
+
+
## License
diff --git a/README.md b/README.md
index 8910699..3ed3d78 100644
--- a/README.md
+++ b/README.md
@@ -13,7 +13,6 @@
-
@@ -92,6 +91,10 @@ The agent's strengths are concentrated where they matter most — dynamic decisi
## How to Use
+### Prerequisites
+
+- **Git >= 2.41** — Open Code Review relies on Git for diff generation, code search, and repository operations.
+
### CLI
#### Install
@@ -104,6 +107,18 @@ npm install -g @alibaba-group/open-code-review
After installation, the `ocr` command is available globally.
+**Update**
+
+If you installed via NPM, update manually to the latest version:
+
+```bash
+npm install -g @alibaba-group/open-code-review@latest
+```
+
+NPM installations also check for newer versions in the background by default and upgrade automatically. To disable auto-updates, set `OCR_NO_UPDATE=1`.
+
+If you installed with the install script or a manually downloaded binary, rerun the same install/download command to replace the local binary with the latest release. Use `OCR_VERSION` when you need to pin a specific release tag.
+
**From GitHub Release**
Install the latest binary for your OS/architecture with one command (macOS / Linux):
@@ -265,6 +280,10 @@ ocr review --from main --to feature-branch
# Single commit
ocr review --commit abc123
+# Resume an interrupted range or commit review
+ocr session list
+ocr review --from main --to feature-branch --resume
+
# Full-file scan — review whole files instead of a diff (no git history needed)
ocr scan # scan the entire repository
ocr scan --path internal/agent # scan a directory or specific files
@@ -402,10 +421,35 @@ The `--from` flag accepts a branch ref (e.g., `origin/main`) or commit SHA as th
The `--format json` flag outputs machine-readable results suitable for parsing in CI scripts.
+Each finding carries two structured fields so CI integrations can sort, group, filter, or gate builds without re-parsing comment text:
+
+| Field | Allowed values | Notes |
+|-------|----------------|-------|
+| `category` | `bug`, `security`, `performance`, `maintainability`, `test`, `style`, `documentation`, `other` | The category the issue belongs to. |
+| `severity` | `critical`, `high`, `medium`, `low` | The importance of the issue. |
+
+In JSON output the two fields appear as siblings alongside `content`, `start_line`, etc. In the terminal, they render as an inline `[category · severity]` badge before the comment, colored by severity.
+
See the [`examples/`](./examples/) directory for integration examples:
- [`github_actions/`](./examples/github_actions/) — GitHub Actions integration example
- [`gitlab_ci/`](./examples/gitlab_ci/) — GitLab CI integration example
+- [`gitflic_ci/`](./examples/gitflic_ci/) — GitFlic CI integration example
+
+#### GitHub Action
+
+For GitHub, this repository also ships a ready-to-use composite Action at the repo root ([`action.yml`](./action.yml)). Instead of scripting `ocr review` yourself, reference it directly and it handles the full pipeline — checkout, OCR install, running the review, posting inline and summary comments, uploading artifacts, and retry/idempotency:
+
+```yaml
+- uses: alibaba/open-code-review@main
+ with:
+ llm_url: ${{ secrets.OCR_LLM_URL }}
+ llm_auth_token: ${{ secrets.OCR_LLM_AUTH_TOKEN }}
+ llm_model: ${{ vars.OCR_LLM_MODEL }}
+ llm_use_anthropic: ${{ vars.OCR_LLM_USE_ANTHROPIC }}
+```
+
+Pin to a version tag or commit SHA for reproducibility. See the [`examples/github_actions/`](./examples/github_actions/) directory for a complete workflow demo and the full list of inputs, outputs, and comment-posting modes (sticky summary, incremental non-destructive posting).
## Commands
@@ -420,6 +464,8 @@ See the [`examples/`](./examples/) directory for integration examples:
| `ocr config unset custom_providers.` | — | Delete a custom provider |
| `ocr llm test` | — | Test LLM connectivity |
| `ocr llm providers` | — | List built-in LLM providers |
+| `ocr session list` | `ocr sessions list`, `ocr session ls` | List saved review sessions |
+| `ocr session show ` | `ocr sessions show ` | Inspect one session and its per-file checkpoints |
| `ocr viewer` | `ocr v` | Launch WebUI session viewer on `localhost:5483` |
| `ocr version` | — | Show version info |
@@ -433,16 +479,55 @@ See the [`examples/`](./examples/) directory for integration examples:
| `--commit` | `-c` | — | Single commit to review |
| `--exclude` | — | — | Comma-separated gitignore-style patterns to skip; merged with rule.json excludes |
| `--preview` | `-p` | `false` | Preview which files will be reviewed without running the LLM |
+| `--resume` | — | — | Resume from a previous compatible range or commit review session |
| `--format` | `-f` | `text` | Output format: `text` or `json` |
| `--concurrency` | — | `8` | Max concurrent file reviews |
| `--timeout` | — | `10` | Concurrent task timeout in minutes |
| `--audience` | — | `human` | `human` (show progress) or `agent` (summary only) |
| `--background` | `-b` | — | Optional requirement/business context for the review; auto-filled from commit message when using `--commit` |
+| `--background-file` | `-B` | — | Optional requirement/business context from a Markdown file; Combined with `--background` the inline value is given first |
| `--model` | — | — | Select or override the LLM model for this review |
| `--rule` | — | — | Path to custom JSON review rules |
| `--max-tools` | — | built-in | Max tool call rounds per file; only takes effect when greater than template default |
-| `--max-git-procs` | — | built-in | Max concurrent git subprocesses |
-| `--tools` | — | — | Path to custom JSON tools config |
+| `--max-git-procs` | — | `16` | Max concurrent git subprocesses |
+| `--tools` | — | built-in | Path to custom JSON tools config |
+
+#### Resumable Reviews and Sessions
+
+Every `ocr review` run persists a local session log under
+`~/.opencodereview/sessions/`. Successful text output stays focused on review
+results and does not print the session ID; use `ocr session list/show` to find
+saved sessions, or `--format json` to include `session_id` in machine-readable
+output. If a range or commit review is interrupted, list the saved sessions and
+resume from the one that matches the same review target:
+
+```bash
+ocr session list
+ocr session show
+ocr review --from main --to feature-branch --resume
+ocr review --commit abc123 --resume
+```
+
+Resume is intentionally strict: it only supports branch-range and single-commit
+reviews, not workspace reviews, and the current `--from/--to` or `--commit`
+must match the saved session. `--preview` cannot be combined with `--resume`.
+
+When `--format json` is used, resumed runs include:
+
+- `session_id` — the current run's session ID
+- `resume.resumed_from` — the source session ID
+- `resume.reused_files` — files reused from saved checkpoints
+- `resume.rerun_files` — files reviewed again in the current run
+
+### `ocr session` Flags
+
+| Command | Flag | Default | Description |
+|---------|------|---------|-------------|
+| `ocr session list` | `--repo` | current dir | Repository whose sessions should be listed |
+| `ocr session list` | `--json` | `false` | Emit session summaries as JSON |
+| `ocr session list` | `--limit` | `20` | Cap listed sessions; use `0` for unlimited |
+| `ocr session show ` | `--repo` | current dir | Repository whose session should be inspected |
+| `ocr session show ` | `--json` | `false` | Emit session metadata and per-file items as JSON |
### `ocr scan` Flags
@@ -492,6 +577,12 @@ ocr review --from main --to my-feature --concurrency 4
# Review a specific commit with verbose JSON output
ocr review --commit abc123 --format json --audience agent
+# Resume an interrupted range or commit review
+ocr session list
+ocr session show
+ocr review --from main --to my-feature --resume
+ocr review --commit abc123 --resume
+
# Select or override model for this review
ocr review --model claude-opus-4-6
ocr review --commit abc123 --model claude-sonnet-4-6
@@ -499,6 +590,12 @@ ocr review --commit abc123 --model claude-sonnet-4-6
# Provide requirement context for more targeted review
ocr review --background "Adding rate limiting to the login API"
+# Provide requirement context from a Markdown file
+ocr review --background-file ./docs/my_business_context.md
+
+# Combine inline context with a local context file (both are used)
+ocr review --background "Focus on auth" --background-file ./docs/my_business_context.md
+
# Use custom review rules
ocr review --rule /path/to/my-rules.json
@@ -753,13 +850,23 @@ ocr config set telemetry.otlp_endpoint localhost:4317
Set `telemetry.content_logging` to include LLM prompts and responses in exported data.
+**Protocol selection:** Set the environment variable `OTEL_EXPORTER_OTLP_PROTOCOL` to choose the export protocol:
+
+| Value | Transport | Notes |
+|---|---|---|
+| `grpc` (default) | gRPC | Default port 4317 |
+| `http/protobuf` | HTTP | Default port 4318 |
+
+**Endpoint format:** `telemetry.otlp_endpoint` expects a base URL in `host:port` or `http://host:port` format, without a path component. The SDK appends the signal path (e.g. `/v1/traces`) automatically per the [OTLP specification](https://opentelemetry.io/docs/specs/otlp/#otlphttp-request).
+
+
## Contributing
-See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, coding guidelines, and how to submit pull requests.
+This project exists thanks to all the people who contribute. See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, coding guidelines, and how to submit pull requests.
-## Star History
-
-[](https://star-history.com/#alibaba/open-code-review&Date)
+
+
+
## License
diff --git a/README.ru-RU.md b/README.ru-RU.md
index 7034883..c1310fa 100644
--- a/README.ru-RU.md
+++ b/README.ru-RU.md
@@ -13,7 +13,6 @@
-
@@ -92,6 +91,10 @@ Open Code Review — это CLI-инструмент для код-ревью н
## Как использовать
+### Предварительные требования
+
+- **Git >= 2.41** — Open Code Review использует Git для генерации diff, поиска по коду и операций с репозиторием.
+
### CLI
#### Установка
@@ -104,6 +107,18 @@ npm install -g @alibaba-group/open-code-review
После установки команда `ocr` доступна глобально.
+**Обновление**
+
+Если установка выполнена через NPM, обновите вручную до последней версии:
+
+```bash
+npm install -g @alibaba-group/open-code-review@latest
+```
+
+Установка через NPM также по умолчанию проверяет новые версии в фоне и обновляется автоматически. Чтобы отключить автообновления, задайте `OCR_NO_UPDATE=1`.
+
+Если вы устанавливали через install script или вручную скачанный бинарный файл, повторно запустите ту же команду установки/скачивания, чтобы заменить локальный бинарный файл последним релизом. Используйте `OCR_VERSION`, если нужно зафиксировать конкретный тег релиза.
+
**Из GitHub Release**
Установите свежий бинарный файл для вашей ОС/архитектуры одной командой (macOS / Linux):
@@ -265,6 +280,10 @@ ocr review --from main --to feature-branch
# Один коммит
ocr review --commit abc123
+# Возобновить прерванное ревью диапазона или одного коммита
+ocr session list
+ocr review --from main --to feature-branch --resume
+
# Полнофайловое сканирование — ревью целых файлов вместо диффа (история git не нужна)
ocr scan # сканировать весь репозиторий
ocr scan --path internal/agent # сканировать каталог или конкретные файлы
@@ -402,10 +421,35 @@ ocr review \
Флаг `--format json` выводит машиночитаемый результат, удобный для разбора в CI-скриптах.
+Каждое замечание содержит два структурированных поля, чтобы CI-интеграции могли сортировать, группировать, фильтровать замечания или блокировать сборку без повторного разбора текста комментария:
+
+| Поле | Допустимые значения | Примечание |
+|------|---------------------|------------|
+| `category` | `bug`, `security`, `performance`, `maintainability`, `test`, `style`, `documentation`, `other` | Категория, к которой относится замечание. |
+| `severity` | `critical`, `high`, `medium`, `low` | Важность замечания. |
+
+В JSON-выводе эти два поля располагаются рядом с `content`, `start_line` и др. В терминале они отображаются перед комментарием как встроенный бейдж `[category · severity]`, цвет которого определяется важностью.
+
Примеры интеграции — в каталоге [`examples/`](./examples/):
- [`github_actions/`](./examples/github_actions/) — пример интеграции с GitHub Actions
- [`gitlab_ci/`](./examples/gitlab_ci/) — пример интеграции с GitLab CI
+- [`gitflic_ci/`](./examples/gitflic_ci/) — пример интеграции с GitFlic CI
+
+#### GitHub Action
+
+Для GitHub в корне репозитория также поставляется готовая к использованию composite Action ([`action.yml`](./action.yml)). Вместо того чтобы вручную скриптовать `ocr review`, просто подключите её — она берёт на себя весь конвейер: checkout, установку OCR, запуск ревью, публикацию инлайн- и сводных комментариев, загрузку артефактов, а также повтор и идемпотентность:
+
+```yaml
+- uses: alibaba/open-code-review@main
+ with:
+ llm_url: ${{ secrets.OCR_LLM_URL }}
+ llm_auth_token: ${{ secrets.OCR_LLM_AUTH_TOKEN }}
+ llm_model: ${{ vars.OCR_LLM_MODEL }}
+ llm_use_anthropic: ${{ vars.OCR_LLM_USE_ANTHROPIC }}
+```
+
+Для воспроизводимости зафиксируйте тег версии или SHA коммита. Полный демо-воркфлоу, а также полный список входов, выходов и режимов публикации комментариев (закреплённая сводка, инкрементальная неразрушающая публикация) см. в каталоге [`examples/github_actions/`](./examples/github_actions/).
## Команды
@@ -420,6 +464,8 @@ ocr review \
| `ocr config unset custom_providers.` | — | Удалить пользовательского провайдера |
| `ocr llm test` | — | Проверить подключение к LLM |
| `ocr llm providers` | — | Показать список встроенных LLM-провайдеров |
+| `ocr session list` | `ocr sessions list`, `ocr session ls` | Показать сохранённые сессии ревью |
+| `ocr session show ` | `ocr sessions show ` | Показать одну сессию и её checkpoint'ы по файлам |
| `ocr viewer` | `ocr v` | Запустить WebUI-просмотрщик сессий на `localhost:5483` |
| `ocr version` | — | Показать информацию о версии |
@@ -433,17 +479,56 @@ ocr review \
| `--commit` | `-c` | — | Один коммит для ревью |
| `--exclude` | — | — | Паттерны в стиле gitignore через запятую для пропуска файлов; объединяются с excludes из rule.json |
| `--preview` | `-p` | `false` | Показать, какие файлы попадут в ревью, без запуска LLM |
+| `--resume` | — | — | Возобновить предыдущую совместимую сессию ревью диапазона или одного коммита |
| `--format` | `-f` | `text` | Формат вывода: `text` или `json` |
| `--concurrency` | — | `8` | Максимум одновременных ревью файлов |
| `--timeout` | — | `10` | Таймаут конкурентной задачи в минутах |
| `--audience` | — | `human` | `human` (показывать прогресс) или `agent` (только сводка) |
| `--background` | `-b` | — | Необязательный контекст требований/бизнес-логики для ревью; при `--commit` автоматически заполняется из сообщения коммита |
+| `--background-file` | `-B` | — | Необязательный контекст требований/бизнес-логики из Markdown-файла; при совместном использовании с `--background` встроенное значение идёт первым |
| `--model` | — | — | Выбрать или переопределить LLM-модель для этого ревью |
| `--rule` | — | — | Путь к пользовательским JSON-правилам ревью |
| `--max-tools` | — | встроенное | Максимум раундов вызова инструментов на файл; действует, только если больше значения шаблона по умолчанию |
| `--max-git-procs` | — | встроенное | Максимум одновременных git-подпроцессов |
| `--tools` | — | — | Путь к пользовательскому JSON-конфигу инструментов |
+#### Возобновляемые ревью и сессии
+
+Каждый запуск `ocr review` сохраняет локальный журнал сессии в
+`~/.opencodereview/sessions/`. Успешный текстовый вывод остаётся сфокусированным
+на результате ревью и не печатает session ID. Сохранённые сессии можно найти через
+`ocr session list/show`, а `--format json` добавляет `session_id` в машиночитаемый
+вывод. Если ревью диапазона или одного коммита было прервано, выберите сохранённую
+сессию с тем же целевым ревью и возобновите её:
+
+```bash
+ocr session list
+ocr session show
+ocr review --from main --to feature-branch --resume
+ocr review --commit abc123 --resume
+```
+
+Возобновление намеренно строгое: поддерживаются только ревью диапазона веток и одного
+коммита, но не ревью рабочей копии. Текущие `--from/--to` или `--commit` должны
+совпадать с сохранённой сессией. `--preview` нельзя использовать вместе с `--resume`.
+
+При `--format json` возобновлённый запуск включает:
+
+- `session_id` — session ID текущего запуска
+- `resume.resumed_from` — исходный session ID
+- `resume.reused_files` — файлы, повторно использованные из сохранённых checkpoint'ов
+- `resume.rerun_files` — файлы, заново проверенные в текущем запуске
+
+### Флаги `ocr session`
+
+| Команда | Флаг | По умолчанию | Описание |
+|---------|------|--------------|----------|
+| `ocr session list` | `--repo` | текущий каталог | Репозиторий, для которого нужно показать сессии |
+| `ocr session list` | `--json` | `false` | Вывести сводки сессий в JSON |
+| `ocr session list` | `--limit` | `20` | Ограничить количество сессий; `0` означает без ограничения |
+| `ocr session show ` | `--repo` | текущий каталог | Репозиторий, сессию которого нужно посмотреть |
+| `ocr session show ` | `--json` | `false` | Вывести метаданные сессии и элементы по файлам в JSON |
+
### Флаги `ocr scan`
`ocr scan` проверяет целые файлы, а не дифф — удобно для аудита незнакомой кодовой базы, предмиграционного сканирования или любого каталога без значимого диффа. Работает и в каталогах без git (используется обход файловой системы с учётом `.gitignore`).
@@ -489,6 +574,12 @@ ocr review --from main --to my-feature --concurrency 4
# Ревью конкретного коммита с подробным JSON-выводом
ocr review --commit abc123 --format json --audience agent
+# Возобновить прерванное ревью диапазона или одного коммита
+ocr session list
+ocr session show
+ocr review --from main --to my-feature --resume
+ocr review --commit abc123 --resume
+
# Выбрать или переопределить модель для этого ревью
ocr review --model claude-opus-4-6
ocr review --commit abc123 --model claude-sonnet-4-6
@@ -496,6 +587,12 @@ ocr review --commit abc123 --model claude-sonnet-4-6
# Передать контекст требований для более прицельного ревью
ocr review --background "Добавляем rate limiting в API логина"
+# Передать контекст требований из Markdown-файла
+ocr review --background-file ./docs/my_business_context.md
+
+# Совместить встроенный контекст с локальным файлом контекста (используются оба)
+ocr review --background "Фокус на аутентификации" --background-file ./docs/my_business_context.md
+
# Использовать собственные правила ревью
ocr review --rule /path/to/my-rules.json
@@ -750,13 +847,22 @@ ocr config set telemetry.otlp_endpoint localhost:4317
Установите `telemetry.content_logging`, чтобы включать промпты и ответы LLM в экспортируемые данные.
+**Выбор протокола:** Переменная окружения `OTEL_EXPORTER_OTLP_PROTOCOL` определяет протокол экспорта:
+
+| Значение | Транспорт | Описание |
+|---|---|---|
+| `grpc` (по умолчанию) | gRPC | Порт по умолчанию 4317 |
+| `http/protobuf` | HTTP | Порт по умолчанию 4318 |
+
+**Формат endpoint:** `telemetry.otlp_endpoint` принимает базовый URL в формате `host:port` или `http://host:port` без компонента пути. SDK автоматически добавляет путь сигнала (например, `/v1/traces`) в соответствии со [спецификацией OTLP](https://opentelemetry.io/docs/specs/otlp/#otlphttp-request).
+
## Участие в разработке
-В [CONTRIBUTING.ru-RU.md](CONTRIBUTING.ru-RU.md) описаны настройка окружения разработки, рекомендации по коду и порядок отправки pull request'ов.
+Этот проект существует благодаря всем, кто вносит свой вклад. В [CONTRIBUTING.ru-RU.md](CONTRIBUTING.ru-RU.md) описаны настройка окружения разработки, рекомендации по коду и порядок отправки pull request'ов.
-## История звёзд
-
-[](https://star-history.com/#alibaba/open-code-review&Date)
+
+
+
## Лицензия
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 83524ea..13ae792 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -13,7 +13,6 @@
-
@@ -92,6 +91,10 @@ Open Code Review 的核心设计理念是将确定性工程与 Agent 结合,
## 如何使用
+### 前置条件
+
+- **Git >= 2.41** — Open Code Review 依赖 Git 进行 diff 生成、代码搜索和仓库操作。
+
### CLI
#### 安装
@@ -104,6 +107,18 @@ npm install -g @alibaba-group/open-code-review
安装后,`ocr` 命令即可全局使用。
+**更新**
+
+如果通过 NPM 安装,可手动更新到最新版本:
+
+```bash
+npm install -g @alibaba-group/open-code-review@latest
+```
+
+通过 NPM 安装的 `ocr` 还会默认在后台检查新版本并自动升级;如需关闭自动更新,可设置 `OCR_NO_UPDATE=1`。
+
+如果通过安装脚本或手动下载二进制文件安装,重新运行对应的安装/下载命令即可替换为最新 release。需要固定版本时,可继续通过 `OCR_VERSION` 指定 release tag。
+
**从 GitHub Release 下载**
使用一条命令为你的操作系统/架构安装最新二进制文件(macOS / Linux):
@@ -265,6 +280,10 @@ ocr review --from main --to feature-branch
# 单个提交
ocr review --commit abc123
+# 恢复中断的区间或单 commit 评审
+ocr session list
+ocr review --from main --to feature-branch --resume
+
# 全量文件扫描 —— 审查整个文件而非 diff(无需 git 历史)
ocr scan # 扫描整个仓库
ocr scan --path internal/agent # 扫描指定目录或文件
@@ -400,10 +419,35 @@ ocr review \
`--format json` 参数输出适合 CI 脚本解析的机器可读结果。
+每条评审结果都带有两个结构化字段,便于 CI 集成在无需解析评论文本的情况下排序、分组、过滤或卡点构建:
+
+| 字段 | 允许的取值 | 说明 |
+|------|-----------|------|
+| `category` | `bug`、`security`、`performance`、`maintainability`、`test`、`style`、`documentation`、`other` | 问题所属的类别。 |
+| `severity` | `critical`、`high`、`medium`、`low` | 问题的严重程度。 |
+
+在 JSON 输出中,这两个字段与 `content`、`start_line` 等平级;在终端中,它们会以内联的 `[category · severity]` 徽章形式显示在评论前,并按严重程度着色。
+
集成示例请参见 [`examples/`](./examples/) 目录:
- [`github_actions/`](./examples/github_actions/) — GitHub Actions 集成示例
- [`gitlab_ci/`](./examples/gitlab_ci/) — GitLab CI 集成示例
+- [`gitflic_ci/`](./examples/gitflic_ci/) — GitFlic CI 集成示例
+
+#### GitHub Action
+
+对于 GitHub,本仓库还在仓库根目录提供了一个开箱即用的 composite Action([`action.yml`](./action.yml))。你无需自己编写 `ocr review` 脚本,直接引用它即可完成完整流程——checkout、安装 OCR、执行审查、发布行内评论与汇总评论、上传 artifacts,以及重试与幂等处理:
+
+```yaml
+- uses: alibaba/open-code-review@main
+ with:
+ llm_url: ${{ secrets.OCR_LLM_URL }}
+ llm_auth_token: ${{ secrets.OCR_LLM_AUTH_TOKEN }}
+ llm_model: ${{ vars.OCR_LLM_MODEL }}
+ llm_use_anthropic: ${{ vars.OCR_LLM_USE_ANTHROPIC }}
+```
+
+为保障可复现性,请固定到某个版本标签或 commit SHA。完整的 workflow 示例以及 inputs、outputs 与评论发布模式(置顶汇总、增量非破坏式发布)的完整列表,请参见 [`examples/github_actions/`](./examples/github_actions/) 目录。
## 命令
@@ -418,6 +462,8 @@ ocr review \
| `ocr config unset custom_providers.` | — | 删除自定义供应商 |
| `ocr llm test` | — | 测试 LLM 连通性 |
| `ocr llm providers` | — | 列出内置 LLM 供应商 |
+| `ocr session list` | `ocr sessions list`, `ocr session ls` | 列出已保存的评审会话 |
+| `ocr session show ` | `ocr sessions show ` | 查看单个会话及其逐文件检查点 |
| `ocr viewer` | `ocr v` | 启动 WebUI 会话查看器,地址 `localhost:5483` |
| `ocr version` | — | 显示版本信息 |
@@ -431,17 +477,53 @@ ocr review \
| `--commit` | `-c` | — | 审查单个提交 |
| `--exclude` | — | — | 以逗号分隔的 gitignore 风格模式,用于跳过匹配文件;与 rule.json 中的 excludes 合并 |
| `--preview` | `-p` | `false` | 预览将被审查的文件列表,不调用 LLM |
+| `--resume` | — | — | 从之前兼容的区间或单 commit 评审会话恢复 |
| `--format` | `-f` | `text` | 输出格式:`text` 或 `json` |
| `--concurrency` | — | `8` | 最大并发文件审查数 |
| `--timeout` | — | `10` | 并发任务超时时间(分钟) |
| `--audience` | — | `human` | `human`(显示进度)或 `agent`(仅输出摘要) |
| `--background` | `-b` | — | 可选的需求/业务背景信息;使用 `--commit` 时如未指定则自动从 commit message 中提取 |
+| `--background-file` | `-B` | — | 来自 Markdown 文件的可选需求/业务背景信息;与 `--background` 同时使用时,内联内容排在前面 |
| `--model` | — | — | 为本次审查选择或覆盖 LLM 模型 |
| `--rule` | — | — | 自定义 JSON 审查规则路径 |
| `--max-tools` | — | 内置默认 | 每个文件的最大工具调用轮次;仅在大于模板默认值时生效 |
| `--max-git-procs` | — | 内置默认 | 最大并发 git 子进程数 |
| `--tools` | — | — | 自定义 JSON 工具配置路径 |
+#### 可恢复评审与会话
+
+每次 `ocr review` 都会在 `~/.opencodereview/sessions/` 下保存本地会话日志。
+正常完成的文本输出只展示评审结果,不打印 session ID;可使用
+`ocr session list/show` 查找已保存会话,或用 `--format json` 在机器可读输出中获取
+`session_id`。如果区间或单 commit 评审被中断,可列出保存的会话,并从匹配相同评审目标的会话恢复:
+
+```bash
+ocr session list
+ocr session show
+ocr review --from main --to feature-branch --resume
+ocr review --commit abc123 --resume
+```
+
+恢复逻辑是严格的:仅支持分支区间和单 commit 评审,不支持工作区评审;当前
+`--from/--to` 或 `--commit` 必须与保存的会话一致。`--preview` 不能与 `--resume` 同时使用。
+
+使用 `--format json` 时,恢复运行会包含:
+
+- `session_id` — 当前运行的 session ID
+- `resume.resumed_from` — 来源 session ID
+- `resume.reused_files` — 从已保存检查点复用的文件数
+- `resume.rerun_files` — 本次重新评审的文件数
+
+### `ocr session` 参数
+
+| 命令 | 参数 | 默认值 | 描述 |
+|------|------|--------|------|
+| `ocr session list` | `--repo` | 当前目录 | 要列出会话的仓库 |
+| `ocr session list` | `--json` | `false` | 以 JSON 输出会话摘要 |
+| `ocr session list` | `--limit` | `20` | 限制列出的会话数量;`0` 表示不限 |
+| `ocr session show ` | `--repo` | 当前目录 | 要查看会话的仓库 |
+| `ocr session show ` | `--json` | `false` | 以 JSON 输出会话元数据和逐文件条目 |
+
### `ocr scan` 参数
`ocr scan` 审查整个文件而非 diff —— 适用于审计不熟悉的代码库、迁移前扫描,或任何没有有意义 diff 的目录。它也可以在非 git 目录中工作(会回退到遵循 `.gitignore` 的文件系统遍历)。
@@ -487,6 +569,12 @@ ocr review --from main --to my-feature --concurrency 4
# 审查特定提交并以 JSON 格式输出详细信息
ocr review --commit abc123 --format json --audience agent
+# 恢复中断的区间或单 commit 评审
+ocr session list
+ocr session show
+ocr review --from main --to my-feature --resume
+ocr review --commit abc123 --resume
+
# 为本次审查选择或覆盖模型
ocr review --model claude-opus-4-6
ocr review --commit abc123 --model claude-sonnet-4-6
@@ -494,6 +582,12 @@ ocr review --commit abc123 --model claude-sonnet-4-6
# 提供需求背景以获得更有针对性的审查
ocr review --background "为登录 API 添加限流"
+# 从 Markdown 文件提供需求背景
+ocr review --background-file ./docs/my_business_context.md
+
+# 将内联背景与本地背景文件结合使用(两者都会生效)
+ocr review --background "关注鉴权" --background-file ./docs/my_business_context.md
+
# 使用自定义审查规则
ocr review --rule /path/to/my-rules.json
@@ -738,13 +832,22 @@ ocr config set telemetry.otlp_endpoint localhost:4317
设置 `telemetry.content_logging` 可在导出数据中包含 LLM 提示词和响应。
+**协议选择:** 通过环境变量 `OTEL_EXPORTER_OTLP_PROTOCOL` 选择导出协议:
+
+| 值 | 传输方式 | 说明 |
+|---|---|---|
+| `grpc`(默认) | gRPC | 默认端口 4317 |
+| `http/protobuf` | HTTP | 默认端口 4318 |
+
+**Endpoint 格式:** `telemetry.otlp_endpoint` 的值为 `host:port` 或 `http://host:port`,无需包含路径。SDK 会根据 [OTLP 规范](https://opentelemetry.io/docs/specs/otlp/#otlphttp-request)自动追加信号路径(如 `/v1/traces`)。
+
## 贡献
-参见 [CONTRIBUTING.zh-CN.md](CONTRIBUTING.zh-CN.md) 了解开发环境搭建、编码规范以及如何提交 Pull Request。
+感谢所有为本项目做出贡献的人。参见 [CONTRIBUTING.zh-CN.md](CONTRIBUTING.zh-CN.md) 了解开发环境搭建、编码规范以及如何提交 Pull Request。
-## Star History
-
-[](https://star-history.com/#alibaba/open-code-review&Date)
+
+
+
## 许可证
diff --git a/ROADMAP.md b/ROADMAP.md
index 4fa8ada..07d0b38 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -19,6 +19,9 @@ OpenCodeReview currently provides:
- CI/CD integration (GitHub Actions, GitLab CI, etc.).
- Multi-provider LLM support (OpenAI-compatible, Anthropic, Google Gemini,
Amazon Bedrock, Azure OpenAI, etc.).
+- MCP server — expose OpenCodeReview over the
+ [Model Context Protocol](https://modelcontextprotocol.io/) so review
+ capabilities can be invoked from any MCP-compatible client.
- Review rules engine with per-file pattern matching.
- Multi-language documentation (English, Chinese, Japanese, Korean, Russian).
@@ -30,13 +33,15 @@ OpenCodeReview currently provides:
PyCharm, and other JetBrains IDEs with the same capabilities as the
existing VSCode extension.
-### MCP Integration
+### Delegate Mode
-- **Standard MCP server** — Expose OpenCodeReview as a
- [Model Context Protocol](https://modelcontextprotocol.io/) server,
- allowing users to integrate external context tools (documentation
- retrieval, issue trackers, internal knowledge bases) into the review
- process through the standard MCP interface.
+- **Subscription-friendly review** — An opt-in mode where `ocr` no longer
+ depends on a separately-configured LLM endpoint. Instead of calling an
+ LLM itself, `ocr` resolves the review scope, applies excludes, loads
+ review rules, injects background context, and collects the diffs, then
+ hands that off as a structured review task for the host coding agent
+ (e.g. Claude Code) to execute using its own agent loop and included
+ subscription usage — removing the need for a standalone API key.
### Ultra Mode
diff --git a/action.yml b/action.yml
new file mode 100644
index 0000000..203b2e2
--- /dev/null
+++ b/action.yml
@@ -0,0 +1,305 @@
+name: OpenCodeReview PR Review
+description: >-
+ AI-powered GitHub PR review with inline comments, sticky summary, and
+ incremental non-destructive posting.
+author: alibaba
+branding:
+ icon: eye
+ color: green
+
+inputs:
+ llm_url:
+ description: LLM API endpoint URL (mapped to env OCR_LLM_URL).
+ required: true
+ llm_auth_token:
+ description: LLM auth token (mapped to env OCR_LLM_TOKEN).
+ required: true
+ llm_model:
+ description: Model name (mapped to env OCR_LLM_MODEL).
+ required: true
+ llm_use_anthropic:
+ description: "'true' for Anthropic Claude, 'false' for OpenAI-compatible APIs
+ (mapped to env OCR_USE_ANTHROPIC). Required to force an explicit choice."
+ required: true
+ llm_auth_header:
+ description: Custom auth header name (mapped to env OCR_LLM_AUTH_HEADER).
+ required: false
+ llm_extra_headers:
+ description: Extra headers "K=V,K=V" (mapped to env OCR_LLM_EXTRA_HEADERS).
+ required: false
+ llm_extra_body:
+ description: >-
+ extra_body JSON for LLM requests. No env var exists for this, so it is
+ written via `ocr config set llm.extra_body`.
+ required: false
+ default: '{"thinking": {"type": "disabled"}}'
+ language:
+ description: >-
+ Review output language, written via `ocr config set language`
+ (e.g. English, 中文). No env var exists for this.
+ required: false
+ default: 'English'
+ llm_timeout:
+ description: LLM request timeout in seconds (mapped to env OCR_LLM_TIMEOUT).
+ required: false
+ github_token:
+ description: GitHub token used to post review comments.
+ required: false
+ default: ${{ github.token }}
+ ocr_version:
+ description: npm version spec for @alibaba-group/open-code-review.
+ required: false
+ default: latest
+ review_concurrency:
+ description: Value passed to `ocr review --concurrency`.
+ required: false
+ background:
+ description: Value passed to `ocr review --background`.
+ required: false
+ rule:
+ description: Path to a custom rules JSON file passed to `ocr review --rule`.
+ required: false
+ upload_artifacts:
+ description: >-
+ Upload raw JSON result and stderr as workflow artifacts. Must be the
+ literal string 'true' or 'false' (quoted); the step gates on a string
+ comparison, so an unquoted YAML boolean will not match.
+ required: false
+ default: 'true'
+ sticky_summary:
+ description: >-
+ Summary dimension. true = update an existing summary comment in place
+ (sticky) instead of posting a new one each run.
+ required: false
+ default: 'true'
+ incremental:
+ description: >-
+ Incremental dimension. true = only append inline comments whose (path,
+ line range) does not overlap an existing bot review comment. History is
+ never deleted (non-destructive).
+ required: false
+ default: 'false'
+ incremental_overlap_threshold:
+ description: >-
+ IoU (intersection-over-union) threshold used by incremental mode to decide
+ whether a new multi-line comment overlaps an existing one. Two single-line
+ comments match when on the same line; single- vs multi-line never match.
+ Value in (0, 1]; ignored unless incremental is true.
+ required: false
+ default: '0.6'
+ base_ref:
+ description: >-
+ Override the base ref. Provide this (and head_sha) when invoking from a
+ non-PR event such as issue_comment.
+ required: false
+ head_sha:
+ description: Override the head commit SHA (use with base_ref for comment triggers).
+ required: false
+ node_version:
+ description: Node.js version for actions/setup-node.
+ required: false
+ default: '24'
+
+outputs:
+ comments_total:
+ description: Total number of review comments generated by OCR.
+ value: ${{ steps.post.outputs.comments_total }}
+ comments_inline:
+ description: Number of inline comments successfully posted.
+ value: ${{ steps.post.outputs.comments_inline }}
+ comments_skipped:
+ description: Number of inline comments skipped by incremental mode (overlap with history).
+ value: ${{ steps.post.outputs.comments_skipped }}
+ comments_failed:
+ description: Number of inline comments that failed to post.
+ value: ${{ steps.post.outputs.comments_failed }}
+ summary_comment_url:
+ description: URL of the posted/updated summary comment, if any.
+ value: ${{ steps.post.outputs.summary_comment_url }}
+
+runs:
+ using: composite
+ steps:
+ - name: Check git and Node.js
+ id: check_deps
+ shell: bash
+ run: |
+ if command -v git >/dev/null 2>&1; then
+ echo "git_installed=true" >> "$GITHUB_OUTPUT"
+ echo "git is already installed: $(git --version)"
+ else
+ echo "git_installed=false" >> "$GITHUB_OUTPUT"
+ echo "git is not installed"
+ fi
+ if command -v node >/dev/null 2>&1; then
+ echo "node_installed=true" >> "$GITHUB_OUTPUT"
+ echo "node is already installed: $(node --version)"
+ else
+ echo "node_installed=false" >> "$GITHUB_OUTPUT"
+ echo "node is not installed"
+ fi
+
+ - name: Install git
+ if: steps.check_deps.outputs.git_installed != 'true'
+ shell: bash
+ run: |
+ if command -v apt-get >/dev/null 2>&1; then
+ sudo apt-get update
+ sudo apt-get install -y git
+ elif command -v brew >/dev/null 2>&1; then
+ brew install git
+ elif command -v yum >/dev/null 2>&1; then
+ sudo yum install -y git
+ elif command -v apk >/dev/null 2>&1; then
+ sudo apk add --no-cache git
+ else
+ echo "::error::Unable to install git: no supported package manager found"
+ exit 1
+ fi
+ git --version
+
+ - name: Setup Node.js
+ if: steps.check_deps.outputs.node_installed != 'true'
+ uses: actions/setup-node@v4
+ with:
+ node-version: ${{ inputs.node_version }}
+
+ - name: Resolve PR refs
+ shell: bash
+ env:
+ INPUT_BASE_REF: ${{ inputs.base_ref }}
+ INPUT_HEAD_SHA: ${{ inputs.head_sha }}
+ EVENT_BASE_REF: ${{ github.event.pull_request.base.ref }}
+ EVENT_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
+ run: |
+ BASE_REF="${INPUT_BASE_REF:-$EVENT_BASE_REF}"
+ HEAD_SHA="${INPUT_HEAD_SHA:-$EVENT_HEAD_SHA}"
+ echo "BASE_REF=$BASE_REF" >> "$GITHUB_ENV"
+ echo "HEAD_SHA=$HEAD_SHA" >> "$GITHUB_ENV"
+ echo "PR base ref: $BASE_REF"
+ echo "PR head sha: $HEAD_SHA"
+
+ - name: Checkout base
+ uses: actions/checkout@v4
+ with:
+ # Checkout the trusted base, not the PR head. OCR reviews the
+ # base-to-head diff from git objects; the head commit's blobs are
+ # fetched separately so they are resolvable without materializing
+ # untrusted PR files into the working tree.
+ fetch-depth: 0
+
+ - name: Fetch PR head (fork-safe)
+ if: env.HEAD_SHA != ''
+ shell: bash
+ env:
+ PR_NUM: ${{ github.event.pull_request.number || github.event.issue.number }}
+ run: |
+ if [ -n "$PR_NUM" ]; then
+ git fetch origin "pull/${PR_NUM}/head"
+ fi
+
+ - name: Compute merge-base
+ shell: bash
+ run: |
+ git fetch origin "${BASE_REF}" 2>/dev/null || true
+ MERGE_BASE=$(git merge-base "origin/${BASE_REF}" "${HEAD_SHA}" 2>/dev/null || echo "${HEAD_SHA}")
+ echo "MERGE_BASE=$MERGE_BASE" >> "$GITHUB_ENV"
+ echo "Reviewing ${HEAD_SHA} from merge-base ${MERGE_BASE} (base origin/${BASE_REF})"
+
+ - name: Install OpenCodeReview
+ shell: bash
+ env:
+ OCR_VERSION: ${{ inputs.ocr_version }}
+ run: |
+ npm install -g "@alibaba-group/open-code-review@${OCR_VERSION}"
+ echo "OpenCodeReview installed:"
+ ocr version || true
+
+ - name: Configure OCR
+ env:
+ OCR_EXTRA_BODY: ${{ inputs.llm_extra_body }}
+ OCR_LANGUAGE: ${{ inputs.language }}
+ shell: bash
+ run: |
+ ocr config set llm.extra_body "$OCR_EXTRA_BODY"
+ ocr config set language "$OCR_LANGUAGE"
+
+ - name: Run OpenCodeReview
+ env:
+ OCR_LLM_URL: ${{ inputs.llm_url }}
+ OCR_LLM_TOKEN: ${{ inputs.llm_auth_token }}
+ OCR_LLM_MODEL: ${{ inputs.llm_model }}
+ OCR_USE_ANTHROPIC: ${{ inputs.llm_use_anthropic }}
+ OCR_LLM_AUTH_HEADER: ${{ inputs.llm_auth_header }}
+ OCR_LLM_EXTRA_HEADERS: ${{ inputs.llm_extra_headers }}
+ OCR_LLM_TIMEOUT: ${{ inputs.llm_timeout }}
+ OCR_REVIEW_CONCURRENCY: ${{ inputs.review_concurrency }}
+ OCR_BACKGROUND: ${{ inputs.background }}
+ OCR_RULE: ${{ inputs.rule }}
+ shell: bash
+ run: |
+ ARGS=(--from "${MERGE_BASE}" --to "${HEAD_SHA}" --format json)
+ [ -n "$OCR_REVIEW_CONCURRENCY" ] && ARGS+=(--concurrency "$OCR_REVIEW_CONCURRENCY")
+ [ -n "$OCR_BACKGROUND" ] && ARGS+=(--background "$OCR_BACKGROUND")
+ [ -n "$OCR_RULE" ] && ARGS+=(--rule "$OCR_RULE")
+ set +e
+ ocr review "${ARGS[@]}" > /tmp/ocr-result.json 2>/tmp/ocr-stderr.log
+ OCR_EXIT_CODE=$?
+ set -e
+ echo "OCR_EXIT_CODE=$OCR_EXIT_CODE" >> "$GITHUB_ENV"
+ echo "=== OCR result ==="
+ cat /tmp/ocr-result.json
+ echo "=== OCR stderr ==="
+ cat /tmp/ocr-stderr.log
+
+ - name: Upload review artifacts
+ if: ${{ always() && inputs.upload_artifacts == 'true' }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: ocr-review-result-${{ github.run_id }}-${{ github.run_attempt }}
+ path: |
+ /tmp/ocr-result.json
+ /tmp/ocr-stderr.log
+ if-no-files-found: warn
+
+ - name: Fail job on OCR error
+ if: env.OCR_EXIT_CODE != '0'
+ shell: bash
+ run: |
+ echo "ocr review exited with code ${OCR_EXIT_CODE}; see uploaded artifacts for details."
+ exit "${OCR_EXIT_CODE}"
+
+ - name: Post review comments
+ if: env.OCR_EXIT_CODE == '0'
+ id: post
+ uses: actions/github-script@v7
+ env:
+ OCR_INCREMENTAL_OVERLAP_THRESHOLD: ${{ inputs.incremental_overlap_threshold }}
+ with:
+ github-token: ${{ inputs.github_token }}
+ script: |
+ // Locate the helper shipped alongside action.yml at runtime.
+ // GITHUB_ACTION_PATH: correct for published (remote) actions and for
+ // local actions when not running in a container.
+ // GITHUB_WORKSPACE: correct for local `uses: ./` actions — under
+ // self-hosted + container setups, GITHUB_ACTION_PATH points to the
+ // host path (invisible inside the container), whereas GITHUB_WORKSPACE
+ // is correctly mapped to /__w.
+ const fs = require('fs');
+ const path = require('path');
+ const REL = 'scripts/github-actions/post-review-comments.js';
+ const roots = [process.env.GITHUB_ACTION_PATH, process.env.GITHUB_WORKSPACE].filter(Boolean);
+ const helper = roots.map(r => path.resolve(r, REL)).find(p => fs.existsSync(p));
+ if (!helper) throw new Error(`Could not locate ${REL}; searched roots: ${roots.join(', ')}`);
+ const { runPostReviewComments } = require(helper);
+ await runPostReviewComments({
+ github,
+ context,
+ core,
+ fs,
+ resultPath: '/tmp/ocr-result.json',
+ stderrPath: '/tmp/ocr-stderr.log',
+ stickySummary: ${{ inputs.sticky_summary == 'true' }},
+ incremental: ${{ inputs.incremental == 'true' }},
+ incrementalOverlapThreshold: parseFloat(process.env.OCR_INCREMENTAL_OVERLAP_THRESHOLD),
+ });
diff --git a/cmd/opencodereview/background_file.go b/cmd/opencodereview/background_file.go
new file mode 100644
index 0000000..2c14a5c
--- /dev/null
+++ b/cmd/opencodereview/background_file.go
@@ -0,0 +1,135 @@
+package main
+
+import (
+ "fmt"
+ "os"
+ "regexp"
+ "strings"
+ "unicode"
+)
+
+const (
+ backgroundSoftLimit = 2000
+ backgroundHardLimit = 8000
+ backgroundOpenTag = ""
+ backgroundCloseTag = ""
+ maxBackgroundFileBytes = 1 << 20 // 1 MB
+)
+
+var multiNewline = regexp.MustCompile(`\n{3,}`)
+
+// mergeBackground combines the inline --background value (or an auto-populated
+// commit message) with the content read from --background-file, separated by a
+// blank line. The inline value is sanitised the same way as the file content so
+// both portions are cleaned consistently. The file content is already wrapped
+// and sanitised by loadBackgroundFile.
+func mergeBackground(inline, fromFile string) string {
+ inline = sanitizeMarkdown(inline)
+ switch {
+ case inline == "":
+ return fromFile
+ case fromFile == "":
+ return inline
+ default:
+ return inline + "\n\n" + fromFile
+ }
+}
+
+func loadBackgroundFile(path string) (string, error) {
+ info, err := os.Stat(path)
+ if err != nil {
+ return "", fmt.Errorf("read background file %q: %w", path, err)
+ }
+ if info.IsDir() {
+ return "", fmt.Errorf("background file %q is a directory, not a file", path)
+ }
+ if info.Size() > maxBackgroundFileBytes {
+ return "", fmt.Errorf(
+ "background file %q is %d bytes, exceeding the maximum of %d bytes; please provide a smaller file",
+ path, info.Size(), maxBackgroundFileBytes,
+ )
+ }
+
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ return "", fmt.Errorf("read background file %q: %w", path, err)
+ }
+
+ cleaned := sanitizeMarkdown(string(raw))
+ if cleaned == "" {
+ return "", fmt.Errorf("background file %q is empty after sanitisation", path)
+ }
+
+ if strings.Contains(cleaned, backgroundOpenTag) || strings.Contains(cleaned, backgroundCloseTag) {
+ return "", fmt.Errorf(
+ "background file %q must not contain the reserved delimiters %q or %q",
+ path, backgroundOpenTag, backgroundCloseTag,
+ )
+ }
+
+ // Enforce the limits on the cleaned content only: the wrapper delimiters add
+ // overhead the user cannot control, so counting them would make the reported
+ // character count misleading.
+ if n := len([]rune(cleaned)); n > backgroundHardLimit {
+ return "", fmt.Errorf(
+ "background content is %d characters, exceeding the hard limit of %d (aborting)",
+ n, backgroundHardLimit,
+ )
+ } else if n > backgroundSoftLimit {
+ fmt.Fprintf(os.Stderr,
+ "[ocr] --background-file content is %d characters, exceeding the recommended %d (continuing but review quality might be impacted)\n",
+ n, backgroundSoftLimit,
+ )
+ }
+
+ return backgroundOpenTag + "\n" + cleaned + "\n" + backgroundCloseTag, nil
+}
+
+func sanitizeMarkdown(s string) string {
+ var b strings.Builder
+ b.Grow(len(s))
+
+ for _, r := range s {
+ switch r {
+ case '\n', '\t':
+ b.WriteRune(r)
+ continue
+ case '\r':
+ continue
+ }
+ if isForbiddenChar(r) {
+ continue
+ }
+ b.WriteRune(r)
+ }
+
+ collapsed := multiNewline.ReplaceAllString(b.String(), "\n\n")
+ return strings.TrimSpace(collapsed)
+}
+
+func isForbiddenChar(r rune) bool {
+ switch {
+ case r <= 0x1F: // C0 control characters (includes NUL)
+ return true
+ case r >= 0x7F && r <= 0x9F: // DEL and C1 control characters
+ return true
+ }
+
+ // The runes below all belong to Unicode category Cf and are therefore
+ // already caught by the unicode.Is(unicode.Cf, r) check at the end. They are
+ // listed explicitly only as documentation of the most common invisible
+ // characters we strip; the switch is redundant, not a correctness necessity.
+ switch r {
+ case '\u200B', // zero-width space
+ '\u200C', // zero-width non-joiner
+ '\u200D', // zero-width joiner
+ '\u200E', // left-to-right mark
+ '\u200F', // right-to-left mark
+ '\u2060', // word joiner
+ '\u00AD', // soft hyphen
+ '\uFEFF': // BOM / zero-width no-break space
+ return true
+ }
+
+ return unicode.Is(unicode.Cf, r)
+}
diff --git a/cmd/opencodereview/background_file_test.go b/cmd/opencodereview/background_file_test.go
new file mode 100644
index 0000000..cb18f21
--- /dev/null
+++ b/cmd/opencodereview/background_file_test.go
@@ -0,0 +1,312 @@
+package main
+
+import (
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// writeTempFile writes content to a temporary file and returns its path.
+func writeTempFile(t *testing.T, content string) string {
+ t.Helper()
+ path := filepath.Join(t.TempDir(), "background.md")
+ if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
+ t.Fatalf("write temp file: %v", err)
+ }
+ return path
+}
+
+func TestLoadBackgroundFileNotFound(t *testing.T) {
+ _, err := loadBackgroundFile(filepath.Join(t.TempDir(), "does-not-exist.md"))
+ if err == nil {
+ t.Fatal("expected an error for a missing file, got nil")
+ }
+}
+
+func TestLoadBackgroundFileEmpty(t *testing.T) {
+ cases := map[string]string{
+ "zero bytes": "",
+ "whitespace only": " \n\t \n ",
+ "invisible only": "\u200B\u200E\u00AD\uFEFF",
+ }
+ for name, content := range cases {
+ t.Run(name, func(t *testing.T) {
+ _, err := loadBackgroundFile(writeTempFile(t, content))
+ if err == nil {
+ t.Fatal("expected an error for empty-after-sanitisation content, got nil")
+ }
+ if !strings.Contains(err.Error(), "empty") {
+ t.Errorf("error = %q, want it to mention 'empty'", err)
+ }
+ })
+ }
+}
+
+func TestLoadBackgroundFileControlCharRemoval(t *testing.T) {
+ // Mix in NUL, bell, DEL, a C1 control char, zero-width space, BOM and an
+ // LTR mark around legitimate text.
+ content := "Hello\x00\x07world\x7f\u0085!\u200B\uFEFF\u200E"
+ got, err := loadBackgroundFile(writeTempFile(t, content))
+ if err != nil {
+ t.Fatalf("loadBackgroundFile: %v", err)
+ }
+ for _, bad := range []string{"\x00", "\x07", "\x7f", "\u0085", "\u200B", "\uFEFF", "\u200E"} {
+ if strings.Contains(got, bad) {
+ t.Errorf("result still contains control/invisible char %q: %q", bad, got)
+ }
+ }
+ if !strings.Contains(got, "Helloworld!") {
+ t.Errorf("expected cleaned text to contain %q, got %q", "Helloworld!", got)
+ }
+}
+
+func TestSanitizeMarkdownPreservesNewlinesAndTabs(t *testing.T) {
+ got := sanitizeMarkdown("line1\n\tindented\nline3")
+ want := "line1\n\tindented\nline3"
+ if got != want {
+ t.Errorf("sanitizeMarkdown = %q, want %q", got, want)
+ }
+}
+
+func TestSanitizeMarkdownCollapsesNewlines(t *testing.T) {
+ got := sanitizeMarkdown("a\n\n\n\n\nb")
+ want := "a\n\nb"
+ if got != want {
+ t.Errorf("sanitizeMarkdown = %q, want %q", got, want)
+ }
+}
+
+func TestSanitizeMarkdownNormalizesCRLF(t *testing.T) {
+ got := sanitizeMarkdown("a\r\nb\r\nc")
+ want := "a\nb\nc"
+ if got != want {
+ t.Errorf("sanitizeMarkdown = %q, want %q", got, want)
+ }
+}
+
+func TestSanitizeMarkdownTrims(t *testing.T) {
+ got := sanitizeMarkdown(" \n hello \n ")
+ if got != "hello" {
+ t.Errorf("sanitizeMarkdown = %q, want %q", got, "hello")
+ }
+}
+
+func TestLoadBackgroundFileDelimiters(t *testing.T) {
+ got, err := loadBackgroundFile(writeTempFile(t, "Some requirement context."))
+ if err != nil {
+ t.Fatalf("loadBackgroundFile: %v", err)
+ }
+ if !strings.HasPrefix(got, backgroundOpenTag+"\n") {
+ t.Errorf("result missing opening delimiter: %q", got)
+ }
+ if !strings.HasSuffix(got, "\n"+backgroundCloseTag) {
+ t.Errorf("result missing closing delimiter: %q", got)
+ }
+ want := backgroundOpenTag + "\nSome requirement context.\n" + backgroundCloseTag
+ if got != want {
+ t.Errorf("result = %q, want %q", got, want)
+ }
+}
+
+func TestLoadBackgroundFileRejectsReservedDelimiters(t *testing.T) {
+ for _, tag := range []string{backgroundOpenTag, backgroundCloseTag} {
+ t.Run(tag, func(t *testing.T) {
+ content := "Some context " + tag + " and more text."
+ _, err := loadBackgroundFile(writeTempFile(t, content))
+ if err == nil {
+ t.Fatalf("expected an error for content containing %q, got nil", tag)
+ }
+ if !strings.Contains(err.Error(), "reserved delimiters") {
+ t.Errorf("error = %q, want it to mention 'reserved delimiters'", err)
+ }
+ })
+ }
+}
+
+func TestMergeBackgroundSanitizesInline(t *testing.T) {
+ t.Run("inline only", func(t *testing.T) {
+ // Control char, zero-width space and surrounding whitespace must be removed.
+ got := mergeBackground(" \x00Inline\u200B context ", "")
+ if got != "Inline context" {
+ t.Errorf("mergeBackground = %q, want %q", got, "Inline context")
+ }
+ })
+
+ t.Run("inline combined with file", func(t *testing.T) {
+ wrapped := backgroundOpenTag + "\nfrom file\n" + backgroundCloseTag
+ got := mergeBackground("\x07dirty\uFEFF inline\n\n\n\nend", wrapped)
+ if strings.ContainsRune(got, '\x07') || strings.ContainsRune(got, '\uFEFF') {
+ t.Errorf("inline portion was not sanitised: %q", got)
+ }
+ // Excess blank lines in the inline portion are collapsed to one.
+ if strings.Contains(got, "\n\n\n") {
+ t.Errorf("inline newlines were not collapsed: %q", got)
+ }
+ // The file portion is preserved intact.
+ if !strings.Contains(got, wrapped) {
+ t.Errorf("file portion was altered: %q", got)
+ }
+ })
+}
+
+func TestMergeBackground(t *testing.T) {
+ wrapped := backgroundOpenTag + "\nfrom file\n" + backgroundCloseTag
+
+ t.Run("both present are combined", func(t *testing.T) {
+ got := mergeBackground("inline context", wrapped)
+ want := "inline context\n\n" + wrapped
+ if got != want {
+ t.Errorf("mergeBackground = %q, want %q", got, want)
+ }
+ // Both inputs must survive in the result.
+ if !strings.Contains(got, "inline context") || !strings.Contains(got, "from file") {
+ t.Errorf("merged background dropped one of the inputs: %q", got)
+ }
+ })
+
+ t.Run("inline only", func(t *testing.T) {
+ if got := mergeBackground("inline only", ""); got != "inline only" {
+ t.Errorf("mergeBackground = %q, want %q", got, "inline only")
+ }
+ })
+
+ t.Run("file only", func(t *testing.T) {
+ if got := mergeBackground("", wrapped); got != wrapped {
+ t.Errorf("mergeBackground = %q, want %q", got, wrapped)
+ }
+ })
+}
+
+func TestLoadBackgroundFileSoftLimit(t *testing.T) {
+ // Just above the soft limit but below the hard size limit: must succeed.
+ content := strings.Repeat("a", backgroundSoftLimit+100)
+ got, err := loadBackgroundFile(writeTempFile(t, content))
+ if err != nil {
+ t.Fatalf("loadBackgroundFile: %v", err)
+ }
+ if !strings.Contains(got, content) {
+ t.Error("expected content to be preserved past the soft limit")
+ }
+}
+
+func TestLoadBackgroundFileOversized(t *testing.T) {
+ // A file larger than maxBackgroundFileBytes must be rejected up front,
+ // before its content is read into memory.
+ content := strings.Repeat("a", maxBackgroundFileBytes+1)
+ _, err := loadBackgroundFile(writeTempFile(t, content))
+ if err == nil {
+ t.Fatal("expected an error for an oversized file, got nil")
+ }
+ if !strings.Contains(err.Error(), "maximum") {
+ t.Errorf("error = %q, want it to mention the byte 'maximum'", err)
+ }
+}
+
+func TestLoadBackgroundFileDirectory(t *testing.T) {
+ if _, err := loadBackgroundFile(t.TempDir()); err == nil {
+ t.Fatal("expected an error when the path is a directory, got nil")
+ }
+}
+
+func TestLoadBackgroundFileHardLimit(t *testing.T) {
+ content := strings.Repeat("a", backgroundHardLimit+1)
+ _, err := loadBackgroundFile(writeTempFile(t, content))
+ if err == nil {
+ t.Fatal("expected an error when exceeding the hard size limit, got nil")
+ }
+ if !strings.Contains(err.Error(), "hard limit") {
+ t.Errorf("error = %q, want it to mention 'hard limit'", err)
+ }
+}
+
+func TestLoadBackgroundFileHardLimitExcludesWrapper(t *testing.T) {
+ // The wrapper delimiters must NOT count toward the limit: cleaned content of
+ // exactly the hard limit is accepted even though the wrapped string is longer.
+ content := strings.Repeat("a", backgroundHardLimit)
+ if _, err := loadBackgroundFile(writeTempFile(t, content)); err != nil {
+ t.Fatalf("cleaned content at the hard limit must be accepted, got: %v", err)
+ }
+}
+
+func TestLoadBackgroundFileMultiByteRuneCount(t *testing.T) {
+ // Multi-byte runes must be counted as single characters, not bytes.
+ // A precomposed accented letter is one rune but two bytes; a string of exactly
+ // backgroundHardLimit runes (~2x the byte count) must still be accepted.
+ content := strings.Repeat("\u00E9", backgroundHardLimit)
+ got, err := loadBackgroundFile(writeTempFile(t, content))
+ if err != nil {
+ t.Fatalf("loadBackgroundFile rejected content within the rune limit: %v", err)
+ }
+ if !strings.Contains(got, content) {
+ t.Error("expected multi-byte content to be preserved")
+ }
+}
+
+// initRepoWithCommit creates a real git repository with a single commit whose
+// message is `message`, and returns the repo directory and the commit hash.
+func initRepoWithCommit(t *testing.T, message string) (string, string) {
+ t.Helper()
+ repo := t.TempDir()
+ run := func(args ...string) []byte {
+ cmd := exec.Command("git", args...)
+ cmd.Dir = repo
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ t.Fatalf("git %v failed: %v\n%s", args, err, out)
+ }
+ return out
+ }
+ run("init", "-q")
+ run("config", "user.email", "test@example.com")
+ run("config", "user.name", "Test")
+ run("config", "commit.gpgsign", "false")
+ if err := os.WriteFile(filepath.Join(repo, "file.txt"), []byte("hello\n"), 0o600); err != nil {
+ t.Fatalf("write file: %v", err)
+ }
+ run("add", ".")
+ run("commit", "-q", "-m", message)
+ hash := strings.TrimSpace(string(run("rev-parse", "HEAD")))
+ return repo, hash
+}
+
+// TestBackgroundFromCommitThenFile reproduces the resolution order used by
+// runReview when --background-file is supplied but --background is not: the
+// inline background is first auto-filled from the commit message, then the
+// background file is appended. Both must end up in the final background.
+func TestBackgroundFromCommitThenFile(t *testing.T) {
+ const commitMsg = "Implement rate limiting on login"
+ repo, hash := initRepoWithCommit(t, commitMsg)
+
+ // Mirror runReview: --background empty + --commit set -> use commit message.
+ background := ""
+ msg, err := getCommitMessage(repo, hash)
+ if err != nil {
+ t.Fatalf("getCommitMessage: %v", err)
+ }
+ if msg != commitMsg {
+ t.Fatalf("commit message = %q, want %q", msg, commitMsg)
+ }
+ if background == "" {
+ background = msg
+ }
+
+ // Then --background-file is loaded and merged in.
+ fileBg, err := loadBackgroundFile(writeTempFile(t, "Extra context from a file."))
+ if err != nil {
+ t.Fatalf("loadBackgroundFile: %v", err)
+ }
+ background = mergeBackground(background, fileBg)
+
+ // The commit message must come first, followed by the wrapped file content.
+ if !strings.HasPrefix(background, commitMsg+"\n\n") {
+ t.Errorf("expected commit message to lead the background, got %q", background)
+ }
+ if !strings.Contains(background, "Extra context from a file.") {
+ t.Errorf("expected file content to be appended, got %q", background)
+ }
+ if !strings.Contains(background, backgroundOpenTag) || !strings.Contains(background, backgroundCloseTag) {
+ t.Errorf("expected file content to keep its delimiters, got %q", background)
+ }
+}
diff --git a/cmd/opencodereview/config_cmd.go b/cmd/opencodereview/config_cmd.go
index 64b511b..a7c1341 100644
--- a/cmd/opencodereview/config_cmd.go
+++ b/cmd/opencodereview/config_cmd.go
@@ -480,23 +480,13 @@ func ensureModelInList(models []string, model string) []string {
if model == "" {
return models
}
- if modelListContains(models, model) {
+ if llm.ModelListContains(models, model) {
return models
}
out := append([]string(nil), models...)
return append(out, model)
}
-func modelListContains(models []string, target string) bool {
- target = strings.TrimSpace(target)
- for _, model := range models {
- if model == target {
- return true
- }
- }
- return false
-}
-
func setProviderValue(cfg *Config, key, value string) error {
parts := strings.SplitN(key, ".", 3)
if len(parts) != 3 || parts[1] == "" || parts[2] == "" {
diff --git a/cmd/opencodereview/emit_run_result_test.go b/cmd/opencodereview/emit_run_result_test.go
index 4082ca2..f153fe2 100644
--- a/cmd/opencodereview/emit_run_result_test.go
+++ b/cmd/opencodereview/emit_run_result_test.go
@@ -7,6 +7,9 @@ import (
"testing"
"time"
+ "go.opentelemetry.io/otel"
+ sdktrace "go.opentelemetry.io/otel/sdk/trace"
+
"github.com/open-code-review/open-code-review/internal/agent"
"github.com/open-code-review/open-code-review/internal/model"
)
@@ -22,6 +25,8 @@ type mockResultProvider struct {
warnings []agent.AgentWarning
projectSummary string
toolCalls map[string]int64
+ resumeInfo *agent.ResumeInfo
+ sessionID string
}
func (m *mockResultProvider) Diffs() []model.Diff { return m.diffs }
@@ -34,6 +39,8 @@ func (m *mockResultProvider) TotalCacheWriteTokens() int64 { return m.cacheWri
func (m *mockResultProvider) Warnings() []agent.AgentWarning { return m.warnings }
func (m *mockResultProvider) ProjectSummary() string { return m.projectSummary }
func (m *mockResultProvider) ToolCalls() map[string]int64 { return m.toolCalls }
+func (m *mockResultProvider) ResumeInfo() *agent.ResumeInfo { return m.resumeInfo }
+func (m *mockResultProvider) SessionID() string { return m.sessionID }
func TestEmitRunResult_JSONNoFiles(t *testing.T) {
ag := &mockResultProvider{filesReviewed: 0}
@@ -80,6 +87,32 @@ func TestEmitRunResult_JSONWithComments(t *testing.T) {
}
}
+func TestEmitRunResult_JSONWithResumeInfo(t *testing.T) {
+ ag := &mockResultProvider{
+ filesReviewed: 2,
+ resumeInfo: &agent.ResumeInfo{
+ ResumedFrom: "old-session",
+ ReusedFiles: 1,
+ RerunFiles: 1,
+ PreviousModel: "anthropic-model",
+ CurrentModel: "openai-model",
+ },
+ }
+ got := captureStdout(t, func() {
+ err := emitRunResult(context.Background(), ag, nil, time.Now(), "json", "developer", nil)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ })
+ var out jsonOutput
+ if err := json.Unmarshal([]byte(got), &out); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if out.Resume == nil || out.Resume.ResumedFrom != "old-session" || out.Resume.ReusedFiles != 1 || out.Resume.RerunFiles != 1 {
+ t.Fatalf("resume = %+v", out.Resume)
+ }
+}
+
func TestEmitRunResult_TextNoComments(t *testing.T) {
ag := &mockResultProvider{filesReviewed: 2}
got := captureStdout(t, func() {
@@ -93,6 +126,19 @@ func TestEmitRunResult_TextNoComments(t *testing.T) {
}
}
+func TestEmitRunResult_TextDoesNotPrintSuccessfulSessionHint(t *testing.T) {
+ ag := &mockResultProvider{filesReviewed: 2, sessionID: "session-123"}
+ got := captureStdout(t, func() {
+ err := emitRunResult(context.Background(), ag, nil, time.Now(), "text", "developer", nil)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ })
+ if strings.Contains(got, "session-123") || strings.Contains(got, "--resume") {
+ t.Fatalf("successful text output should not print session ID or resume hint, got %q", got)
+ }
+}
+
func TestEmitRunResult_TextWithComments(t *testing.T) {
ag := &mockResultProvider{filesReviewed: 1}
comments := []model.LlmComment{{Path: "a.go", Content: "rename", StartLine: 5, EndLine: 10}}
@@ -175,3 +221,78 @@ func TestEmitRunResult_NilQuietHandle(t *testing.T) {
})
_ = got
}
+
+func TestEmitRunResult_JSONTraceIDFromContext(t *testing.T) {
+ tp := sdktrace.NewTracerProvider()
+ defer func() { _ = tp.Shutdown(context.Background()) }()
+ otel.SetTracerProvider(tp)
+
+ ctx, span := tp.Tracer("test").Start(context.Background(), "test-root")
+ wantTraceID := span.SpanContext().TraceID().String()
+ defer span.End()
+
+ ag := &mockResultProvider{
+ filesReviewed: 2,
+ inputTokens: 10,
+ outputTokens: 5,
+ totalTokens: 15,
+ }
+ got := captureStdout(t, func() {
+ err := emitRunResult(ctx, ag, nil, time.Now(), "json", "developer", nil)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ })
+ var out jsonOutput
+ if err := json.Unmarshal([]byte(got), &out); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if out.TraceID != wantTraceID {
+ t.Errorf("trace_id = %q, want %q", out.TraceID, wantTraceID)
+ }
+}
+
+func TestEmitRunResult_JSONNoFilesTraceID(t *testing.T) {
+ tp := sdktrace.NewTracerProvider()
+ defer func() { _ = tp.Shutdown(context.Background()) }()
+ otel.SetTracerProvider(tp)
+
+ ctx, span := tp.Tracer("test").Start(context.Background(), "test-root")
+ wantTraceID := span.SpanContext().TraceID().String()
+ defer span.End()
+
+ ag := &mockResultProvider{filesReviewed: 0}
+ got := captureStdout(t, func() {
+ err := emitRunResult(ctx, ag, nil, time.Now(), "json", "developer", nil)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ })
+ var out jsonOutput
+ if err := json.Unmarshal([]byte(got), &out); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if out.Status != "skipped" {
+ t.Errorf("status = %q, want skipped", out.Status)
+ }
+ if out.TraceID != wantTraceID {
+ t.Errorf("trace_id = %q, want %q", out.TraceID, wantTraceID)
+ }
+}
+
+func TestEmitRunResult_JSONIncludesSessionID(t *testing.T) {
+ ag := &mockResultProvider{filesReviewed: 1, sessionID: "session-99"}
+ got := captureStdout(t, func() {
+ err := emitRunResult(context.Background(), ag, nil, time.Now(), "json", "developer", nil)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ })
+ var out jsonOutput
+ if err := json.Unmarshal([]byte(got), &out); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if out.SessionID != "session-99" {
+ t.Errorf("session_id = %q, want session-99", out.SessionID)
+ }
+}
diff --git a/cmd/opencodereview/flags.go b/cmd/opencodereview/flags.go
index ccda515..1f72bb1 100644
--- a/cmd/opencodereview/flags.go
+++ b/cmd/opencodereview/flags.go
@@ -101,10 +101,12 @@ type reviewOptions struct {
from string
to string
commit string
+ resume string
excludes string // --exclude: comma-separated gitignore-style patterns
outputFormat string
audience string // --audience: "human" (default) or "agent"
background string // --background: optional requirement context
+ backgroundFile string // --background-file: path to a Markdown file used as background
model string // --model: override resolved LLM model for this review
concurrency int
perFileTimeout int
@@ -125,12 +127,14 @@ func parseReviewFlags(args []string) (reviewOptions, error) {
a.StringVar(&opts.from, "from", "", "source ref to start diff from (e.g., 'main')")
a.StringVar(&opts.to, "to", "", "target ref to end diff at (e.g., 'feature-branch')")
a.StringVarP(&opts.commit, "commit", "c", "", "single commit hash or tag to review (vs its parent)")
+ a.StringVar(&opts.resume, "resume", "", "resume from a previous review session id")
a.StringVar(&opts.excludes, "exclude", "", "comma-separated gitignore-style patterns to exclude; merged with rule.json excludes")
a.StringVarP(&opts.outputFormat, "format", "f", "text", "output format: text or json")
a.IntVar(&opts.concurrency, "concurrency", 8, "max concurrent file reviews")
a.IntVar(&opts.perFileTimeout, "timeout", 10, "concurrent task timeout in minutes")
a.StringVar(&opts.audience, "audience", "human", "output audience: human (show progress) or agent (summary only)")
a.StringVarP(&opts.background, "background", "b", "", "optional requirement/business context for the review")
+ a.StringVarP(&opts.backgroundFile, "background-file", "B", "", "optional requirement/business context from a Markdown file (combined with --background; inline value appears first when both are set)")
a.StringVar(&opts.model, "model", "", "override LLM model for this review (e.g., claude-opus-4-6)")
a.IntVar(&opts.maxTools, "max-tools", 0, "max tool call rounds per file (0 = template default; min 10)")
a.IntVar(&opts.maxGitProcs, "max-git-procs", 16, "max concurrent git subprocesses")
@@ -162,6 +166,9 @@ func parseReviewFlags(args []string) (reviewOptions, error) {
if opts.to != "" && opts.from == "" {
return opts, fmt.Errorf("--from is required when --to is specified")
}
+ if opts.preview && opts.resume != "" {
+ return opts, fmt.Errorf("--preview and --resume cannot be used together")
+ }
switch opts.audience {
case "human", "agent":
@@ -203,6 +210,9 @@ Examples:
ocr review --commit abc123
ocr review -c abc123
+ # Resume a previous range review
+ ocr review --from master --to dev-ref --resume
+
# Output JSON format
ocr review --format json
ocr review -f json
@@ -214,22 +224,29 @@ Examples:
ocr review --preview
ocr review -c abc123 -p
+ # Provide requirement/business context inline, from a Markdown file, or both
+ ocr review --background "Adding rate limiting to the login API"
+ ocr review --background-file ./docs/requirements.md
+ ocr review --background "Focus on auth" --background-file ./docs/requirements.md
+
Flags:
- --audience string output audience: human (show progress) or agent (summary only) (default "human")
- -b, --background string optional requirement/business context for the review
- -c, --commit string single commit hash or tag to review (vs its parent)
- -f, --format string output format: text or json (default "text")
- --concurrency int max concurrent file reviews (default 8)
- --max-git-procs int max concurrent git subprocesses (default 16)
- --from string source ref to start diff from (e.g., 'main')
- --max-tools int max tool call rounds per file (0 = template default; min 10)
- --model string override LLM model for this review (e.g., claude-opus-4-6)
- -p, --preview preview which files will be reviewed without running the LLM
- --repo string root directory of the git repository (default: current dir)
- --rule string path to JSON file with system review rules
- --timeout int concurrent task timeout in minutes (default 10)
- --to string target ref to end diff at (e.g., 'feature-branch')
- --tools string path to JSON tools config file (default: embedded)`)
+ --audience string output audience: human (show progress) or agent (summary only) (default "human")
+ -b, --background string optional requirement/business context for the review
+ -B, --background-file string path to a Markdown file used as review background (combined with --background; inline value appears first when both are set)
+ -c, --commit string single commit hash or tag to review (vs its parent)
+ -f, --format string output format: text or json (default "text")
+ --concurrency int max concurrent file reviews (default 8)
+ --max-git-procs int max concurrent git subprocesses (default 16)
+ --from string source ref to start diff from (e.g., 'main')
+ --max-tools int max tool call rounds per file (0 = template default; min 10)
+ --model string override LLM model for this review (e.g., claude-opus-4-6)
+ -p, --preview preview which files will be reviewed without running the LLM
+ --repo string root directory of the git repository (default: current dir)
+ --resume string resume from a previous review session id
+ --rule string path to JSON file with system review rules
+ --timeout int concurrent task timeout in minutes (default 10)
+ --to string target ref to end diff at (e.g., 'feature-branch')
+ --tools string path to JSON tools config file (default: embedded)`)
}
// --- config subcommand ---
diff --git a/cmd/opencodereview/flags_test.go b/cmd/opencodereview/flags_test.go
index 15701ca..8e25996 100644
--- a/cmd/opencodereview/flags_test.go
+++ b/cmd/opencodereview/flags_test.go
@@ -5,6 +5,20 @@ import (
"time"
)
+func TestParseReviewFlagsBackgroundFile(t *testing.T) {
+ for _, flag := range []string{"--background-file", "-B"} {
+ t.Run(flag, func(t *testing.T) {
+ opts, err := parseReviewFlags([]string{flag, "./docs/req.md"})
+ if err != nil {
+ t.Fatalf("parseReviewFlags: %v", err)
+ }
+ if opts.backgroundFile != "./docs/req.md" {
+ t.Errorf("backgroundFile = %q, want %q", opts.backgroundFile, "./docs/req.md")
+ }
+ })
+ }
+}
+
func TestParseReviewFlagsModelOverride(t *testing.T) {
opts, err := parseReviewFlags([]string{"--model", "claude-opus-4-6"})
if err != nil {
@@ -22,6 +36,23 @@ func TestParseReviewFlagsModelOverride(t *testing.T) {
}
}
+func TestParseReviewFlagsResume(t *testing.T) {
+ opts, err := parseReviewFlags([]string{"--from", "main", "--to", "feature", "--resume", "session-123"})
+ if err != nil {
+ t.Fatalf("parseReviewFlags: %v", err)
+ }
+ if opts.resume != "session-123" {
+ t.Errorf("resume = %q, want session-123", opts.resume)
+ }
+}
+
+func TestParseReviewFlags_PreviewWithResume(t *testing.T) {
+ _, err := parseReviewFlags([]string{"--commit", "abc123", "--preview", "--resume", "session-123"})
+ if err == nil {
+ t.Fatal("expected error for --preview with --resume")
+ }
+}
+
func TestParseReviewFlags_InvalidAudience(t *testing.T) {
_, err := parseReviewFlags([]string{"--audience", "robot"})
if err == nil {
diff --git a/cmd/opencodereview/git.go b/cmd/opencodereview/git.go
index 0185b56..dba1d6f 100644
--- a/cmd/opencodereview/git.go
+++ b/cmd/opencodereview/git.go
@@ -12,6 +12,15 @@ func runGitCmd(repoDir string, args ...string) ([]byte, error) {
return cmd.CombinedOutput()
}
+// runGitCmdStdout is like runGitCmd but returns stdout only. Use it when the
+// output is consumed as data (e.g. a resolved path) so git's stderr warnings
+// (permissions, deprecations, config notices) can't pollute the result.
+func runGitCmdStdout(repoDir string, args ...string) ([]byte, error) {
+ fullArgs := append([]string{"-C", repoDir}, args...)
+ cmd := exec.Command("git", fullArgs...)
+ return cmd.Output()
+}
+
func getCommitMessage(repoDir, commit string) (string, error) {
out, err := runGitCmd(repoDir, "log", "-1", "--format=%B", "--end-of-options", commit)
if err != nil {
diff --git a/cmd/opencodereview/git_test.go b/cmd/opencodereview/git_test.go
index 0630a4c..4528b29 100644
--- a/cmd/opencodereview/git_test.go
+++ b/cmd/opencodereview/git_test.go
@@ -23,11 +23,17 @@ func initTestGitRepo(t *testing.T) string {
}
}
f := filepath.Join(dir, "README.md")
- os.WriteFile(f, []byte("hello"), 0o644)
+ if err := os.WriteFile(f, []byte("hello"), 0o644); err != nil {
+ t.Fatalf("write README: %v", err)
+ }
cmd := exec.Command("git", "-C", dir, "add", ".")
- cmd.CombinedOutput()
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("git add: %v: %s", err, out)
+ }
cmd = exec.Command("git", "-C", dir, "commit", "-m", "initial commit")
- cmd.CombinedOutput()
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("git commit: %v: %s", err, out)
+ }
return dir
}
@@ -93,9 +99,18 @@ func TestResolveRepoDir_NotGitRepo(t *testing.T) {
func TestResolveRepoDir_EmptyUsesWd(t *testing.T) {
dir := initTestGitRepo(t)
- origDir, _ := os.Getwd()
- defer os.Chdir(origDir)
- os.Chdir(dir)
+ origDir, err := os.Getwd()
+ if err != nil {
+ t.Fatalf("getwd: %v", err)
+ }
+ defer func() {
+ if err := os.Chdir(origDir); err != nil {
+ t.Errorf("restore chdir: %v", err)
+ }
+ }()
+ if err := os.Chdir(dir); err != nil {
+ t.Fatalf("chdir: %v", err)
+ }
resolved, err := resolveRepoDir("")
if err != nil {
diff --git a/cmd/opencodereview/main.go b/cmd/opencodereview/main.go
index fac044e..378e22e 100644
--- a/cmd/opencodereview/main.go
+++ b/cmd/opencodereview/main.go
@@ -56,6 +56,8 @@ func dispatch() error {
return runRules(args[1:])
case "viewer":
return runViewer(args[1:])
+ case "session", "sessions":
+ return runSession(args[1:])
case "-h", "--help":
printTopLevelUsage()
return nil
@@ -77,6 +79,7 @@ Commands:
config Manage configuration settings
llm LLM utility commands
viewer Start the WebUI session viewer
+ session, sessions List and inspect saved review sessions
version Show version information
Examples:
@@ -89,6 +92,7 @@ Examples:
ocr config set llm.model opus-4-6 Set a config value
ocr llm test Test LLM connectivity
ocr llm providers List built-in providers
+ ocr session list List saved review sessions
ocr version Show version info
Use "ocr review -h" for more information about review.
@@ -96,6 +100,7 @@ Use "ocr scan -h" for more information about scan.
Use "ocr rules -h" for more information about rules.
Use "ocr config" for more information about config.
Use "ocr llm" for more information about LLM utilities.
+Use "ocr session -h" for more information about session inspection.
GitHub: https://github.com/alibaba/open-code-review`)
}
diff --git a/cmd/opencodereview/output.go b/cmd/opencodereview/output.go
index 3ec31c3..0006164 100644
--- a/cmd/opencodereview/output.go
+++ b/cmd/opencodereview/output.go
@@ -61,7 +61,19 @@ func renderComment(comment model.LlmComment) {
fmt.Printf("\n\033[2m─── %s:%d-%d ───\033[0m\n", sanitizeTerminal(comment.Path), comment.StartLine, comment.EndLine)
if comment.Content != "" {
- for _, ln := range wrapByRunes(sanitizeTerminal(comment.Content), 100) {
+ badge := buildBadge(comment)
+ content := sanitizeTerminal(comment.Content)
+ if badge != "" {
+ // Prepend the plain badge text to the content so it wraps inline with
+ // the first line, then colorize just the badge prefix after wrapping.
+ content = badge + " " + content
+ }
+ lines := wrapByRunes(content, 100)
+ for i, ln := range lines {
+ if i == 0 && badge != "" && strings.HasPrefix(ln, badge) {
+ color := severityColor(comment.Severity)
+ ln = color + badge + "\033[0m" + ln[len(badge):]
+ }
fmt.Printf("%s\n", ln)
}
fmt.Println()
@@ -83,6 +95,41 @@ func renderComment(comment model.LlmComment) {
fmt.Println()
}
+// buildBadge renders a compact "[category · severity]" tag for a finding. It returns
+// an empty string when neither structured field is present, so text output for findings
+// without metadata is unchanged.
+func buildBadge(comment model.LlmComment) string {
+ category := sanitizeTerminal(comment.Category)
+ severity := sanitizeTerminal(comment.Severity)
+ switch {
+ case category != "" && severity != "":
+ return fmt.Sprintf("[%s · %s]", category, severity)
+ case category != "":
+ return fmt.Sprintf("[%s]", category)
+ case severity != "":
+ return fmt.Sprintf("[%s]", severity)
+ default:
+ return ""
+ }
+}
+
+// severityColor maps a finding severity to an ANSI color used for its badge.
+// Unknown or empty severities fall back to dim.
+func severityColor(severity string) string {
+ switch severity {
+ case "critical":
+ return "\033[1;91m" // bold bright red
+ case "high":
+ return "\033[91m" // bright red
+ case "medium":
+ return "\033[93m" // bright yellow
+ case "low":
+ return "\033[94m" // bright blue
+ default:
+ return "\033[2m" // dim
+ }
+}
+
// printDiffLine renders a single diff line with colored prefix and background on content.
func printDiffLine(prefix, content, fgColor, bgColor string) {
fmt.Printf("%s%s%s %s%s\033[0m\n", fgColor+bgColor, prefix, "\033[0m"+bgColor, content, "\033[0m")
@@ -193,12 +240,15 @@ type jsonToolCalls struct {
type jsonOutput struct {
Status string `json:"status"`
+ TraceID string `json:"trace_id,omitempty"`
Message string `json:"message,omitempty"`
Summary *jsonSummary `json:"summary,omitempty"`
ToolCalls *jsonToolCalls `json:"tool_calls"`
Comments []model.LlmComment `json:"comments"`
Warnings []agent.AgentWarning `json:"warnings,omitempty"`
ProjectSummary string `json:"project_summary,omitempty"`
+ Resume *agent.ResumeInfo `json:"resume,omitempty"`
+ SessionID string `json:"session_id,omitempty"`
}
func outputJSON(comments []model.LlmComment) error {
@@ -216,9 +266,10 @@ func outputJSON(comments []model.LlmComment) error {
func outputJSONWithWarnings(comments []model.LlmComment, warnings []agent.AgentWarning,
filesReviewed, inputTokens, outputTokens, totalTokens, cacheReadTokens, cacheWriteTokens int64,
- duration time.Duration, projectSummary string, toolCalls map[string]int64) error {
+ duration time.Duration, projectSummary string, toolCalls map[string]int64, traceID string, resumeInfo *agent.ResumeInfo, sessionID string) error {
out := jsonOutput{
Status: "success",
+ TraceID: traceID,
Comments: comments,
Summary: &jsonSummary{
FilesReviewed: filesReviewed,
@@ -231,6 +282,8 @@ func outputJSONWithWarnings(comments []model.LlmComment, warnings []agent.AgentW
Elapsed: duration.Round(time.Second).String(),
},
ProjectSummary: projectSummary,
+ Resume: resumeInfo,
+ SessionID: sessionID,
}
var total int64
for _, v := range toolCalls {
@@ -264,9 +317,10 @@ func outputJSONWithWarnings(comments []model.LlmComment, warnings []agent.AgentW
return enc.Encode(out)
}
-func outputJSONNoFiles() error {
+func outputJSONNoFiles(traceID string) error {
out := jsonOutput{
Status: "skipped",
+ TraceID: traceID,
Message: "No supported files changed.",
Comments: []model.LlmComment{},
ToolCalls: &jsonToolCalls{
diff --git a/cmd/opencodereview/output_helpers_test.go b/cmd/opencodereview/output_helpers_test.go
index d0d25b2..80520b2 100644
--- a/cmd/opencodereview/output_helpers_test.go
+++ b/cmd/opencodereview/output_helpers_test.go
@@ -168,9 +168,8 @@ func TestOutputJSONWithWarnings_NoCommentsSubtaskError(t *testing.T) {
os.Stdout = w
warnings := []agent.AgentWarning{{Type: "subtask_error", File: "x.go", Message: "fail"}}
- err := outputJSONWithWarnings(nil, warnings, 1, 10, 5, 15, 0, 0, time.Second, "", nil)
-
- w.Close()
+ err := outputJSONWithWarnings(nil, warnings, 1, 10, 5, 15, 0, 0, time.Second, "", nil, "abc123trace", nil, "")
+ _ = w.Close()
os.Stdout = old
if err != nil {
@@ -178,16 +177,21 @@ func TestOutputJSONWithWarnings_NoCommentsSubtaskError(t *testing.T) {
}
var buf bytes.Buffer
- buf.ReadFrom(r)
+ _, _ = buf.ReadFrom(r)
var out jsonOutput
- json.Unmarshal(buf.Bytes(), &out)
+ if err := json.Unmarshal(buf.Bytes(), &out); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
if out.Status != "completed_with_errors" {
t.Errorf("status = %q, want completed_with_errors", out.Status)
}
if !strings.Contains(out.Message, "errors") {
t.Errorf("message = %q, expected to mention errors", out.Message)
}
+ if out.TraceID != "abc123trace" {
+ t.Errorf("trace_id = %q, want abc123trace", out.TraceID)
+ }
}
func TestStatusBadge(t *testing.T) {
@@ -222,7 +226,7 @@ func TestOutputJSON(t *testing.T) {
}
err := outputJSON(comments)
- w.Close()
+ _ = w.Close()
os.Stdout = old
if err != nil {
@@ -230,7 +234,7 @@ func TestOutputJSON(t *testing.T) {
}
var buf bytes.Buffer
- buf.ReadFrom(r)
+ _, _ = buf.ReadFrom(r)
var out jsonOutput
if err := json.Unmarshal(buf.Bytes(), &out); err != nil {
@@ -251,7 +255,7 @@ func TestOutputJSON_NoComments(t *testing.T) {
err := outputJSON(nil)
- w.Close()
+ _ = w.Close()
os.Stdout = old
if err != nil {
@@ -259,10 +263,12 @@ func TestOutputJSON_NoComments(t *testing.T) {
}
var buf bytes.Buffer
- buf.ReadFrom(r)
+ _, _ = buf.ReadFrom(r)
var out jsonOutput
- json.Unmarshal(buf.Bytes(), &out)
+ if err := json.Unmarshal(buf.Bytes(), &out); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
if out.Message == "" {
t.Error("expected non-empty message when no comments")
}
@@ -275,9 +281,8 @@ func TestOutputJSONWithWarnings(t *testing.T) {
comments := []model.LlmComment{{Path: "b.go", Content: "test"}}
warnings := []agent.AgentWarning{{Type: "subtask_error", File: "c.go", Message: "failed"}}
- err := outputJSONWithWarnings(comments, warnings, 5, 100, 50, 150, 10, 5, 3*time.Second, "summary", map[string]int64{"file_read": 3})
-
- w.Close()
+ err := outputJSONWithWarnings(comments, warnings, 5, 100, 50, 150, 10, 5, 3*time.Second, "summary", map[string]int64{"file_read": 3}, "trace-xyz-789", nil, "")
+ _ = w.Close()
os.Stdout = old
if err != nil {
@@ -285,10 +290,12 @@ func TestOutputJSONWithWarnings(t *testing.T) {
}
var buf bytes.Buffer
- buf.ReadFrom(r)
+ _, _ = buf.ReadFrom(r)
var out jsonOutput
- json.Unmarshal(buf.Bytes(), &out)
+ if err := json.Unmarshal(buf.Bytes(), &out); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
if out.Status != "completed_with_errors" {
t.Errorf("status = %q, want completed_with_errors", out.Status)
}
@@ -301,6 +308,9 @@ func TestOutputJSONWithWarnings(t *testing.T) {
if out.ToolCalls == nil || out.ToolCalls.Total != 3 {
t.Errorf("ToolCalls.Total = %v", out.ToolCalls)
}
+ if out.TraceID != "trace-xyz-789" {
+ t.Errorf("trace_id = %q, want trace-xyz-789", out.TraceID)
+ }
}
func TestOutputJSONWithWarnings_NoCommentsNoErrors(t *testing.T) {
@@ -309,9 +319,8 @@ func TestOutputJSONWithWarnings_NoCommentsNoErrors(t *testing.T) {
os.Stdout = w
warnings := []agent.AgentWarning{{Type: "warning", Message: "something"}}
- err := outputJSONWithWarnings(nil, warnings, 2, 50, 20, 70, 0, 0, time.Second, "", nil)
-
- w.Close()
+ err := outputJSONWithWarnings(nil, warnings, 2, 50, 20, 70, 0, 0, time.Second, "", nil, "", nil, "")
+ _ = w.Close()
os.Stdout = old
if err != nil {
@@ -319,10 +328,12 @@ func TestOutputJSONWithWarnings_NoCommentsNoErrors(t *testing.T) {
}
var buf bytes.Buffer
- buf.ReadFrom(r)
+ _, _ = buf.ReadFrom(r)
var out jsonOutput
- json.Unmarshal(buf.Bytes(), &out)
+ if err := json.Unmarshal(buf.Bytes(), &out); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
if out.Status != "completed_with_warnings" {
t.Errorf("status = %q, want completed_with_warnings", out.Status)
}
@@ -336,9 +347,9 @@ func TestOutputJSONNoFiles(t *testing.T) {
r, w, _ := os.Pipe()
os.Stdout = w
- err := outputJSONNoFiles()
+ err := outputJSONNoFiles("test-trace-id-456")
- w.Close()
+ _ = w.Close()
os.Stdout = old
if err != nil {
@@ -346,13 +357,18 @@ func TestOutputJSONNoFiles(t *testing.T) {
}
var buf bytes.Buffer
- buf.ReadFrom(r)
+ _, _ = buf.ReadFrom(r)
var out jsonOutput
- json.Unmarshal(buf.Bytes(), &out)
+ if err := json.Unmarshal(buf.Bytes(), &out); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
if out.Status != "skipped" {
t.Errorf("status = %q, want skipped", out.Status)
}
+ if out.TraceID != "test-trace-id-456" {
+ t.Errorf("trace_id = %q, want test-trace-id-456", out.TraceID)
+ }
}
func captureStdout(t *testing.T, fn func()) string {
@@ -364,10 +380,10 @@ func captureStdout(t *testing.T, fn func()) string {
}
os.Stdout = w
fn()
- w.Close()
+ _ = w.Close()
os.Stdout = old
var buf bytes.Buffer
- buf.ReadFrom(r)
+ _, _ = buf.ReadFrom(r)
return buf.String()
}
diff --git a/cmd/opencodereview/output_test.go b/cmd/opencodereview/output_test.go
index e0cccfb..038cd84 100644
--- a/cmd/opencodereview/output_test.go
+++ b/cmd/opencodereview/output_test.go
@@ -1,6 +1,78 @@
package main
-import "testing"
+import (
+ "strings"
+ "testing"
+
+ "github.com/open-code-review/open-code-review/internal/model"
+)
+
+func TestBuildBadge(t *testing.T) {
+ tests := []struct {
+ name string
+ comment model.LlmComment
+ want string
+ }{
+ {"both fields", model.LlmComment{Category: "security", Severity: "high"}, "[security · high]"},
+ {"category only", model.LlmComment{Category: "bug"}, "[bug]"},
+ {"severity only", model.LlmComment{Severity: "low"}, "[low]"},
+ {"neither", model.LlmComment{}, ""},
+ {"strips control chars", model.LlmComment{Category: "bug\x1b[0m", Severity: "high"}, "[bug[0m · high]"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := buildBadge(tt.comment); got != tt.want {
+ t.Errorf("buildBadge() = %q, want %q", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestSeverityColor(t *testing.T) {
+ // Each known severity must map to a distinct color; unknown falls back to dim.
+ seen := map[string]string{}
+ for _, sev := range []string{"critical", "high", "medium", "low"} {
+ c := severityColor(sev)
+ if c == "" {
+ t.Errorf("severityColor(%q) is empty", sev)
+ }
+ if prev, ok := seen[c]; ok {
+ t.Errorf("severityColor(%q) shares color with %q", sev, prev)
+ }
+ seen[c] = sev
+ }
+ if got := severityColor("bogus"); got != "\033[2m" {
+ t.Errorf("severityColor(unknown) = %q, want dim", got)
+ }
+ if got := severityColor(""); got != "\033[2m" {
+ t.Errorf("severityColor(empty) = %q, want dim", got)
+ }
+}
+
+// TestRenderComment_BadgeInline verifies the badge is colorized and rendered inline
+// with the first line of the comment content.
+func TestRenderComment_BadgeInline(t *testing.T) {
+ out := captureStdout(t, func() {
+ renderComment(model.LlmComment{
+ Path: "internal/mcp/client.go",
+ StartLine: 27,
+ EndLine: 27,
+ Content: "Potential environment variable leak.",
+ Category: "security",
+ Severity: "high",
+ })
+ })
+ if !strings.Contains(out, "[security · high]") {
+ t.Errorf("expected badge in output, got:\n%s", out)
+ }
+ // severity high → bright red; the badge must be wrapped in the color + reset.
+ if !strings.Contains(out, "\033[91m[security · high]\033[0m") {
+ t.Errorf("expected colorized badge, got:\n%q", out)
+ }
+ if !strings.Contains(out, "Potential environment variable leak.") {
+ t.Errorf("expected content in output, got:\n%s", out)
+ }
+}
func TestSanitizeTerminal(t *testing.T) {
tests := []struct {
@@ -21,8 +93,8 @@ func TestSanitizeTerminal(t *testing.T) {
{"only control chars", "\x1b\x07\x00\x7f", ""},
{"unicode preserved", "代码审查 レビュー 🔍", "代码审查 レビュー 🔍"},
{"mixed safe and unsafe", "path\x1b[0m/file.go", "path[0m/file.go"},
- {"strips C1 CSI (U+009B)", "beforeafter", "beforeafter"},
- {"strips C1 OSC (U+009D)", "beforeafter", "beforeafter"},
+ {"strips C1 CSI (U+009B)", "before\u009bafter", "beforeafter"},
+ {"strips C1 OSC (U+009D)", "before\u009dafter", "beforeafter"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
diff --git a/cmd/opencodereview/provider_cmd.go b/cmd/opencodereview/provider_cmd.go
index 995efb7..db66b71 100644
--- a/cmd/opencodereview/provider_cmd.go
+++ b/cmd/opencodereview/provider_cmd.go
@@ -32,13 +32,9 @@ func runConfigProvider() error {
final := finalModel.(providerTUIModel)
if !final.confirmed {
- // TUI persists changes (create/edit/model/add/delete) directly to disk
- // during the session, so the on-disk file is already up to date for any
- // savedInSession operation. No additional post-TUI apply step is needed.
- if final.savedInSession {
- return nil
- }
- fmt.Println("Cancelled.")
+ // TUI persists changes during the session; Esc only abandons the final
+ // provider/API-key confirmation step.
+ printWizardCancelled(final.savedInSession, "Configuration changes")
return nil
}
@@ -55,6 +51,16 @@ func runConfigProvider() error {
return applyOfficialProviderConfig(configPath, cfg, result)
}
+// printWizardCancelled prints the standard Esc-cancel message for config wizards.
+// changesDescription is a short noun phrase, e.g. "Configuration changes".
+func printWizardCancelled(savedInSession bool, changesDescription string) {
+ if savedInSession {
+ fmt.Printf("Cancelled. (%s made during this session were kept.)\n", changesDescription)
+ return
+ }
+ fmt.Println("Cancelled.")
+}
+
func applyProviderDeletions(configPath string, cfg *Config, names []string) (bool, error) {
clearedActive := false
for _, name := range names {
@@ -133,7 +139,8 @@ func applyCustomProviderConfig(configPath string, cfg *Config, result providerTU
if result.provider == "" {
return fmt.Errorf("provider name is required")
}
- if result.model == "" {
+ model := result.resolvedModel()
+ if model == "" {
return fmt.Errorf("model is required")
}
@@ -142,11 +149,11 @@ func applyCustomProviderConfig(configPath string, cfg *Config, result providerTU
}
entry := cfg.CustomProviders[result.provider]
- entry.Model = result.model
+ entry.Model = model
if len(result.models) > 0 {
entry.Models = append([]string(nil), result.models...)
}
- entry.Models = ensureModelInList(entry.Models, result.model)
+ entry.Models = ensureModelInList(entry.Models, model)
if result.url != "" {
entry.URL = result.url
}
@@ -162,14 +169,16 @@ func applyCustomProviderConfig(configPath string, cfg *Config, result providerTU
}
if result.apiKey != "" {
entry.APIKey = result.apiKey
+ } else {
+ entry.APIKey = ""
}
cfg.CustomProviders[result.provider] = entry
if !result.isEdit {
cfg.Provider = result.provider
- cfg.Model = result.model
+ cfg.Model = model
} else if cfg.Provider == result.provider {
- cfg.Model = result.model
+ cfg.Model = model
}
if err := saveConfig(configPath, cfg); err != nil {
@@ -182,13 +191,13 @@ func applyCustomProviderConfig(configPath string, cfg *Config, result providerTU
} else {
fmt.Printf("\nCustom provider %q updated (not currently active).\n", result.provider)
}
- fmt.Printf("Model: %s\n", result.model)
+ fmt.Printf("Model: %s\n", model)
fmt.Println("\nTip: run 'ocr config model' to switch model later.")
return nil
}
fmt.Printf("\nProvider set to: %s (custom)\n", result.provider)
- fmt.Printf("Model: %s\n", result.model)
+ fmt.Printf("Model: %s\n", model)
fmt.Println("\nTesting connection...")
if err := runLLMTest(); err != nil {
@@ -202,7 +211,11 @@ func applyCustomProviderConfig(configPath string, cfg *Config, result providerTU
}
func applyOfficialProviderConfig(configPath string, cfg *Config, result providerTUIResult) error {
- if result.provider == "" || result.model == "" {
+ if result.provider == "" {
+ return fmt.Errorf("provider and model are required")
+ }
+ model := result.resolvedModel()
+ if model == "" {
return fmt.Errorf("provider and model are required")
}
@@ -223,12 +236,15 @@ func applyOfficialProviderConfig(configPath string, cfg *Config, result provider
}
entry := cfg.Providers[result.provider]
- entry.Model = result.model
+ entry.Model = model
if len(result.models) > 0 {
entry.Models = mergeModelLists(entry.Models, result.models)
}
if result.apiKey != "" {
entry.APIKey = result.apiKey
+ } else {
+ // Confirmed empty key: clear saved api_key so resolver falls back to $ENV_VAR.
+ entry.APIKey = ""
}
cfg.Providers[result.provider] = entry
@@ -236,14 +252,14 @@ func applyOfficialProviderConfig(configPath string, cfg *Config, result provider
cfg.Model = ""
}
cfg.Provider = result.provider
- cfg.Model = result.model
+ cfg.Model = model
if err := saveConfig(configPath, cfg); err != nil {
return err
}
fmt.Printf("\nProvider set to: %s\n", result.provider)
- fmt.Printf("Model: %s\n", result.model)
+ fmt.Printf("Model: %s\n", model)
fmt.Println("\nTesting connection...")
if err := runLLMTest(); err != nil {
@@ -274,8 +290,10 @@ func runConfigModel() error {
currentModel := ""
provider := llm.Provider{Name: cfg.Provider, DisplayName: cfg.Provider}
isCustom := false
+ registryModels := []string(nil)
if preset, isPreset := llm.LookupProvider(cfg.Provider); isPreset {
provider = preset
+ registryModels = append([]string(nil), preset.Models...)
if entry, ok := cfg.Providers[cfg.Provider]; ok {
currentModel = activeModelForProvider(cfg, cfg.Provider, entry)
provider.Models = mergeModelLists(provider.Models, entry.Models)
@@ -293,7 +311,15 @@ func runConfigModel() error {
provider.Models = mergeModelLists(entry.Models)
}
- m := newModelTUI(provider, currentModel)
+ m := newModelTUIConfig(modelTUIConfig{
+ Provider: provider,
+ CurrentModel: currentModel,
+ RegistryModels: registryModels,
+ ExistingCfg: cfg,
+ ConfigPath: configPath,
+ ProviderName: cfg.Provider,
+ IsCustom: isCustom,
+ })
p := tea.NewProgram(m)
finalModel, err := p.Run()
if err != nil {
@@ -302,7 +328,7 @@ func runConfigModel() error {
final := finalModel.(modelTUIModel)
if final.cancelled {
- fmt.Println("Cancelled.")
+ printWizardCancelled(final.savedInSession, "Model list changes")
return nil
}
@@ -325,7 +351,9 @@ func runConfigModel() error {
}
entry := cfg.Providers[cfg.Provider]
entry.Model = selectedModel
- if !modelListContains(provider.Models, selectedModel) {
+ // Use registry-only list: provider.Models was captured before the TUI and
+ // may include stale entry.Models from add/delete during the session.
+ if !llm.ModelListContains(registryModels, selectedModel) {
entry.Models = ensureModelInList(entry.Models, selectedModel)
}
cfg.Providers[cfg.Provider] = entry
diff --git a/cmd/opencodereview/provider_cmd_test.go b/cmd/opencodereview/provider_cmd_test.go
index 54dc906..d1f03bb 100644
--- a/cmd/opencodereview/provider_cmd_test.go
+++ b/cmd/opencodereview/provider_cmd_test.go
@@ -2,6 +2,7 @@ package main
import (
"encoding/json"
+ "io"
"os"
"path/filepath"
"testing"
@@ -203,3 +204,175 @@ func TestApplyOfficialProviderConfig_MissingFields(t *testing.T) {
t.Fatal("expected error for missing provider/model")
}
}
+
+func TestApplyOfficialProviderConfig_EmptyKeyClearsSavedAPIKey(t *testing.T) {
+ t.Setenv("DEEPSEEK_API_KEY", "sk-from-env")
+ dir := t.TempDir()
+ configPath := filepath.Join(dir, "config.json")
+ cfg := &Config{
+ Provider: "deepseek",
+ Model: "deepseek-v4-flash",
+ Providers: map[string]ProviderEntry{
+ "deepseek": {
+ APIKey: "old-saved-key",
+ Model: "deepseek-v4-flash",
+ },
+ },
+ }
+
+ err := applyOfficialProviderConfig(configPath, cfg, providerTUIResult{
+ provider: "deepseek",
+ model: "deepseek-v4-flash",
+ apiKey: "",
+ })
+ if err != nil {
+ t.Fatalf("applyOfficialProviderConfig: %v", err)
+ }
+ if got := cfg.Providers["deepseek"].APIKey; got != "" {
+ t.Errorf("in-memory APIKey = %q, want empty", got)
+ }
+ diskCfg, err := loadOrCreateConfig(configPath)
+ if err != nil {
+ t.Fatalf("load config: %v", err)
+ }
+ if got := diskCfg.Providers["deepseek"].APIKey; got != "" {
+ t.Errorf("persisted APIKey = %q, want empty", got)
+ }
+}
+
+func TestApplyCustomProviderConfig_EmptyKeyClearsSavedAPIKey(t *testing.T) {
+ dir := t.TempDir()
+ configPath := filepath.Join(dir, "config.json")
+ cfg := &Config{
+ Provider: "aaa",
+ Model: "test",
+ CustomProviders: map[string]ProviderEntry{
+ "aaa": {
+ URL: "https://example.com/v1",
+ Protocol: "openai",
+ APIKey: "old-saved-key",
+ Model: "test",
+ Models: []string{"test"},
+ },
+ },
+ }
+
+ err := applyCustomProviderConfig(configPath, cfg, providerTUIResult{
+ provider: "aaa",
+ model: "test",
+ models: []string{"test"},
+ apiKey: "",
+ isCustom: true,
+ url: "https://example.com/v1",
+ protocol: "openai",
+ })
+ if err != nil {
+ t.Fatalf("applyCustomProviderConfig: %v", err)
+ }
+ if got := cfg.CustomProviders["aaa"].APIKey; got != "" {
+ t.Errorf("APIKey = %q, want empty", got)
+ }
+}
+
+func TestProviderTUIResult_ResolvedModel(t *testing.T) {
+ r := providerTUIResult{
+ provider: "baidu-qianfan",
+ model: "glm-5",
+ }
+ if got := r.resolvedModel(); got != "glm-5" {
+ t.Errorf("resolvedModel() = %q, want glm-5", got)
+ }
+
+ r = providerTUIResult{
+ provider: "baidu-qianfan",
+ sessionModelPick: map[string]string{
+ "baidu-qianfan": "glm-5",
+ },
+ }
+ if got := r.resolvedModel(); got != "glm-5" {
+ t.Errorf("resolvedModel() from session pick = %q, want glm-5", got)
+ }
+
+ r = providerTUIResult{provider: "baidu-qianfan"}
+ if got := r.resolvedModel(); got != "" {
+ t.Errorf("resolvedModel() = %q, want empty", got)
+ }
+}
+
+func TestApplyOfficialProviderConfig_UsesSessionModelPick(t *testing.T) {
+ t.Setenv("QIANFAN_API_KEY", "sk-from-env")
+ dir := t.TempDir()
+ configPath := filepath.Join(dir, "config.json")
+ cfg := &Config{
+ Provider: "deepseek",
+ Model: "deepseek-v4-flash",
+ Providers: map[string]ProviderEntry{
+ "deepseek": {Model: "deepseek-v4-flash"},
+ },
+ }
+
+ err := applyOfficialProviderConfig(configPath, cfg, providerTUIResult{
+ provider: "baidu-qianfan",
+ apiKey: "",
+ sessionModelPick: map[string]string{
+ "baidu-qianfan": "glm-5",
+ },
+ })
+ if err != nil {
+ t.Fatalf("applyOfficialProviderConfig: %v", err)
+ }
+ if cfg.Provider != "baidu-qianfan" {
+ t.Errorf("Provider = %q, want baidu-qianfan", cfg.Provider)
+ }
+ if cfg.Model != "glm-5" {
+ t.Errorf("Model = %q, want glm-5", cfg.Model)
+ }
+}
+
+func TestPrintWizardCancelled(t *testing.T) {
+ tests := []struct {
+ name string
+ savedInSession bool
+ scope string
+ want string
+ }{
+ {
+ name: "no changes",
+ savedInSession: false,
+ scope: "Configuration changes",
+ want: "Cancelled.\n",
+ },
+ {
+ name: "provider wizard kept changes",
+ savedInSession: true,
+ scope: "Configuration changes",
+ want: "Cancelled. (Configuration changes made during this session were kept.)\n",
+ },
+ {
+ name: "model wizard kept changes",
+ savedInSession: true,
+ scope: "Model list changes",
+ want: "Cancelled. (Model list changes made during this session were kept.)\n",
+ },
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ old := os.Stdout
+ r, w, err := os.Pipe()
+ if err != nil {
+ t.Fatal(err)
+ }
+ os.Stdout = w
+ printWizardCancelled(tc.savedInSession, tc.scope)
+ _ = w.Close()
+ os.Stdout = old
+ got, err := io.ReadAll(r)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(got) != tc.want {
+ t.Errorf("output = %q, want %q", string(got), tc.want)
+ }
+ })
+ }
+}
diff --git a/cmd/opencodereview/provider_tui.go b/cmd/opencodereview/provider_tui.go
index 2e0b795..8e55a76 100644
--- a/cmd/opencodereview/provider_tui.go
+++ b/cmd/opencodereview/provider_tui.go
@@ -58,17 +58,50 @@ type customProviderListItem struct {
}
type providerTUIResult struct {
- provider string
- model string
- models []string
- apiKey string
- isCustom bool
- isEdit bool
- editTargetName string
- isManual bool
- url string
- protocol string
- authHeader string
+ provider string
+ model string
+ models []string
+ apiKey string
+ isCustom bool
+ isEdit bool
+ editTargetName string
+ isManual bool
+ url string
+ protocol string
+ authHeader string
+ sessionModelPick map[string]string
+}
+
+// resolvedModel returns the model to persist, falling back to the in-session pick
+// for the provider being finalized when result.model is empty.
+func (r providerTUIResult) resolvedModel() string {
+ if r.model != "" {
+ return r.model
+ }
+ if r.sessionModelPick != nil {
+ if pick, ok := r.sessionModelPick[r.provider]; ok && pick != "" {
+ return pick
+ }
+ }
+ return ""
+}
+
+func (m providerTUIModel) sessionModelPickFor(providerName string) string {
+ if providerName == "" || m.sessionModelPick == nil {
+ return ""
+ }
+ return m.sessionModelPick[providerName]
+}
+
+func (m providerTUIModel) sessionModelPickSnapshot() map[string]string {
+ if len(m.sessionModelPick) == 0 {
+ return nil
+ }
+ out := make(map[string]string, len(m.sessionModelPick))
+ for k, v := range m.sessionModelPick {
+ out[k] = v
+ }
+ return out
}
type providerTUIModel struct {
@@ -120,6 +153,9 @@ type providerTUIModel struct {
cancelled bool
formError string
savedInSession bool
+ // sessionModelPick remembers model choices per provider during a wizard run
+ // without persisting inactive-provider selections to disk.
+ sessionModelPick map[string]string
// --- delete confirmation ---
confirmingDelete bool
@@ -146,6 +182,17 @@ func (m providerTUIModel) customProviderActiveModel(cp customProviderListItem) s
return activeModelForProvider(m.existingCfg, cp.name, entry)
}
+func (m providerTUIModel) officialProviderActiveModel(p llm.Provider) string {
+ if m.existingCfg == nil || m.existingCfg.Provider != p.Name {
+ return ""
+ }
+ entry := ProviderEntry{}
+ if m.existingCfg.Providers != nil {
+ entry = m.existingCfg.Providers[p.Name]
+ }
+ return activeModelForProvider(m.existingCfg, p.Name, entry)
+}
+
func collectCustomProviders(cfg *Config) []customProviderListItem {
if cfg == nil || cfg.CustomProviders == nil {
return nil
@@ -287,7 +334,7 @@ func newProviderTUI(cfg *Config, configPath string) providerTUIModel {
if cfg.Llm.AuthToken != "" {
m.manualTokenOriginal = cfg.Llm.AuthToken
m.manualTokenMasked = true
- m.manualTokenInput.SetValue(strings.Repeat("*", 20))
+ m.manualTokenInput.SetValue(maskedSecretPlaceholder())
}
if cfg.Llm.UseAnthropic == nil || *cfg.Llm.UseAnthropic {
m.manualProtocolIdx = 0 // anthropic
@@ -330,12 +377,97 @@ func (m providerTUIModel) modelProviderName() string {
return provider.Name
}
+func isUserAddedOfficialModelName(name, providerName string, registryModels []string, cfg *Config) bool {
+ if providerName == "" || cfg == nil {
+ return false
+ }
+ if llm.ModelListContains(registryModels, name) {
+ return false
+ }
+ entry, ok := cfg.Providers[providerName]
+ if !ok {
+ return false
+ }
+ return llm.ModelListContains(entry.Models, name)
+}
+
+func registryModelsForProvider(name string, fallback []string) []string {
+ if preset, ok := llm.LookupProvider(name); ok {
+ return append([]string(nil), preset.Models...)
+ }
+ if len(fallback) == 0 {
+ return nil
+ }
+ return append([]string(nil), fallback...)
+}
+
+func applyModelDeleteToEntry(entry ProviderEntry, name string) ProviderEntry {
+ entry.Models = removeModels(entry.Models, []string{name})
+ if entry.Model == name {
+ entry.Model = ""
+ }
+ return entry
+}
+
+func clearCfgActiveModelIfDeleted(cfg *Config, providerName, name string) {
+ if cfg != nil && cfg.Provider == providerName && cfg.Model == name {
+ cfg.Model = ""
+ }
+}
+
+func rollbackCfgActiveModel(cfg *Config, providerName, prevModel string) {
+ if cfg != nil && cfg.Provider == providerName {
+ cfg.Model = prevModel
+ }
+}
+
+func (m *modelTUIModel) rollbackModelDelete(prevEntry ProviderEntry, prevCfgModel string) {
+ if m.existingCfg == nil {
+ return
+ }
+ if m.isCustomProvider {
+ m.existingCfg.CustomProviders[m.providerName] = prevEntry
+ m.syncModelsFromConfig()
+ } else {
+ m.existingCfg.Providers[m.providerName] = prevEntry
+ }
+ rollbackCfgActiveModel(m.existingCfg, m.providerName, prevCfgModel)
+}
+
+func (m providerTUIModel) isUserAddedOfficialModel(name string) bool {
+ if m.activeTab != tabOfficial {
+ return false
+ }
+ provider := m.currentProvider()
+ return isUserAddedOfficialModelName(name, provider.Name, registryModelsForProvider(provider.Name, provider.Models), m.existingCfg)
+}
+
+// cursorOnDeletableModel reports whether the model-step cursor is on a row that
+// can be deleted (not on "Enter custom model name...").
+func (m providerTUIModel) cursorOnDeletableModel() bool {
+ if m.step != stepModel || m.confirmingDeleteModel {
+ return false
+ }
+ models := m.models()
+ if m.modelIdx >= len(models) {
+ return false
+ }
+ switch m.activeTab {
+ case tabCustom:
+ return m.customIdx < len(m.customProviders)
+ case tabOfficial:
+ return m.isUserAddedOfficialModel(models[m.modelIdx])
+ default:
+ return false
+ }
+}
+
func (m providerTUIModel) models() []string {
switch m.activeTab {
case tabOfficial:
- models := m.currentProvider().Models
+ provider := m.currentProvider()
+ models := registryModelsForProvider(provider.Name, provider.Models)
if m.existingCfg != nil {
- provider := m.currentProvider()
if entry, ok := m.existingCfg.Providers[provider.Name]; ok {
models = mergeModelLists(models, entry.Models)
}
@@ -349,12 +481,19 @@ func (m providerTUIModel) models() []string {
return nil
}
-func (m *providerTUIModel) prepareModelSelection(currentModel string) {
+func (m *providerTUIModel) prepareModelSelection(providerName, configModel string) {
m.modelIdx = 0
m.customModel = false
m.modelInput.Blur()
m.modelInput.SetValue("")
+ currentModel := configModel
+ if providerName != "" && m.sessionModelPick != nil {
+ if pick, ok := m.sessionModelPick[providerName]; ok && pick != "" {
+ currentModel = pick
+ }
+ }
+
models := m.models()
if currentModel == "" {
return
@@ -370,6 +509,32 @@ func (m *providerTUIModel) prepareModelSelection(currentModel string) {
m.modelInput.SetValue(currentModel)
}
+func (m providerTUIModel) providerNameForModelStep() string {
+ switch m.activeTab {
+ case tabOfficial:
+ return m.currentProvider().Name
+ case tabCustom:
+ if cp, ok := m.selectedCustomProvider(); ok {
+ return cp.name
+ }
+ }
+ return ""
+}
+
+func (m *providerTUIModel) recordSessionModelPick(model string) {
+ if model == "" {
+ return
+ }
+ name := m.providerNameForModelStep()
+ if name == "" {
+ return
+ }
+ if m.sessionModelPick == nil {
+ m.sessionModelPick = make(map[string]string)
+ }
+ m.sessionModelPick[name] = model
+}
+
func (m *providerTUIModel) customProviderEntry(name string, fallback ProviderEntry) ProviderEntry {
if m.existingCfg != nil {
if entry, ok := m.existingCfg.CustomProviders[name]; ok {
@@ -387,43 +552,9 @@ func (m *providerTUIModel) syncSessionModelSelection() error {
if model == "" {
return nil
}
-
- switch m.activeTab {
- case tabCustom:
- cp, ok := m.selectedCustomProvider()
- if !ok {
- return nil
- }
- entry := m.customProviderEntry(cp.name, cp.entry)
- entry.Model = model
- if m.existingCfg.CustomProviders == nil {
- m.existingCfg.CustomProviders = make(map[string]ProviderEntry)
- }
- m.existingCfg.CustomProviders[cp.name] = entry
- cp.entry = entry
- m.customProviders[m.customIdx] = cp
- if m.existingCfg.Provider == cp.name {
- m.existingCfg.Model = model
- }
- case tabOfficial:
- provider := m.currentProvider()
- if m.existingCfg.Providers == nil {
- m.existingCfg.Providers = make(map[string]ProviderEntry)
- }
- entry := m.existingCfg.Providers[provider.Name]
- entry.Model = model
- m.existingCfg.Providers[provider.Name] = entry
- if m.existingCfg.Provider == provider.Name {
- m.existingCfg.Model = model
- }
- }
-
- if m.configPath != "" {
- if err := saveConfig(m.configPath, m.existingCfg); err != nil {
- return fmt.Errorf("failed to save: %w", err)
- }
- }
- m.savedInSession = true
+ // Remember the pick for in-wizard navigation only; persist provider/model on
+ // final confirm (applyOfficialProviderConfig / applyCustomProviderConfig).
+ m.recordSessionModelPick(model)
return nil
}
@@ -530,12 +661,11 @@ func (m providerTUIModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.deleteTargetName = m.customProviders[m.customIdx].name
return m, nil
}
- if m.step == stepModel && m.activeTab == tabCustom && m.customIdx < len(m.customProviders) {
+ if m.step == stepModel && m.cursorOnDeletableModel() {
models := m.models()
- if m.modelIdx < len(models) {
- m.confirmingDeleteModel = true
- m.deleteModelName = models[m.modelIdx]
- }
+ m.confirmingDeleteModel = true
+ m.deleteModelName = models[m.modelIdx]
+ return m, nil
}
return m, nil
@@ -555,6 +685,9 @@ func (m providerTUIModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.passThroughManualInput(msg)
}
if m.step == stepAPIKey {
+ if m.apiKeyMasked && isUserEditMsg(msg) {
+ m.beginAPIKeyReplace()
+ }
var cmd tea.Cmd
m.apiKeyInput, cmd = m.apiKeyInput.Update(msg)
return m, cmd
@@ -581,17 +714,21 @@ func (m providerTUIModel) updateCustomModelInput(key string, msg tea.KeyPressMsg
if name == "" {
return m, nil
}
- for _, existing := range m.models() {
- if existing == name {
- m.formError = fmt.Sprintf("Already in list: %s", name)
- return m, nil
- }
+ if llm.ModelListContains(m.models(), name) {
+ m.formError = fmt.Sprintf("Already in list: %s", name)
+ return m, nil
}
m.formError = ""
- if err := m.addCustomModelToSession(name); err != nil {
+ persisted, err := m.persistCustomModelName(name)
+ if err != nil {
m.formError = err.Error()
return m, nil
}
+ if !persisted {
+ // No active provider context — refuse with an error message.
+ m.formError = "no active provider to attach this model to"
+ return m, nil
+ }
m.customModel = false
m.modelInput.Blur()
m.modelInput.SetValue("")
@@ -607,37 +744,122 @@ func (m providerTUIModel) updateCustomModelInput(key string, msg tea.KeyPressMsg
}
}
-// addCustomModelToSession appends a single model name to the current custom
-// provider's Models list and persists in-memory state to disk. It does not
-// change the active model — the user picks that explicitly from the list
-// afterwards.
-func (m *providerTUIModel) addCustomModelToSession(name string) error {
+// persistCustomModelName appends a single model name to the active provider's
+// Models list (official or custom) and saves the config. It does not change
+// the active model — the user picks that explicitly from the list afterwards.
+//
+// Returns (persisted, error). When no provider is active (neither official
+// nor custom), persisted is false and the caller decides how to handle it.
+func (m *providerTUIModel) persistCustomModelName(name string) (bool, error) {
+ if name == "" {
+ return false, fmt.Errorf("model name must not be empty")
+ }
if m.existingCfg == nil {
- return nil
+ return false, nil
}
- cp, ok := m.selectedCustomProvider()
- if !ok {
- return nil
- }
- entry := m.customProviderEntry(cp.name, cp.entry)
- prevEntry := cloneProviderEntry(entry)
- entry.Models = append(entry.Models, name)
- if m.existingCfg.CustomProviders == nil {
- m.existingCfg.CustomProviders = make(map[string]ProviderEntry)
- }
- m.existingCfg.CustomProviders[cp.name] = entry
- cp.entry = entry
- m.customProviders[m.customIdx] = cp
- if m.configPath != "" {
- if err := saveConfig(m.configPath, m.existingCfg); err != nil {
- m.existingCfg.CustomProviders[cp.name] = prevEntry
- cp.entry = prevEntry
- m.customProviders[m.customIdx] = cp
- return fmt.Errorf("failed to save models: %w", err)
+ switch m.activeTab {
+ case tabCustom:
+ cp, ok := m.selectedCustomProvider()
+ if !ok {
+ return false, nil
}
+ entry := m.customProviderEntry(cp.name, cp.entry)
+ prevEntry := cloneProviderEntry(entry)
+ entry.Models = append(entry.Models, name)
+ if m.existingCfg.CustomProviders == nil {
+ m.existingCfg.CustomProviders = make(map[string]ProviderEntry)
+ }
+ m.existingCfg.CustomProviders[cp.name] = entry
+ cp.entry = entry
+ m.customProviders[m.customIdx] = cp
+ if m.configPath != "" {
+ if err := saveConfig(m.configPath, m.existingCfg); err != nil {
+ if !m.reloadConfigAfterSaveFailure() {
+ m.existingCfg.CustomProviders[cp.name] = prevEntry
+ cp.entry = prevEntry
+ m.customProviders[m.customIdx] = cp
+ }
+ return false, fmt.Errorf("failed to save models: %w", err)
+ }
+ }
+ m.savedInSession = true
+ return true, nil
+ case tabOfficial:
+ provider := m.currentProvider()
+ if provider.Name == "" {
+ return false, nil
+ }
+ if m.existingCfg.Providers == nil {
+ m.existingCfg.Providers = make(map[string]ProviderEntry)
+ }
+ entry := m.existingCfg.Providers[provider.Name]
+ prevEntry := cloneProviderEntry(entry)
+ entry.Models = append(entry.Models, name)
+ m.existingCfg.Providers[provider.Name] = entry
+ // Intentionally do not mutate m.providers[officialIdx].Models: that slice
+ // is a read-only snapshot from the provider registry (llm.ListProviders).
+ // User-added models live only in existingCfg.Providers; models() merges both
+ // at display time, unlike custom tab where customProviders is the sole list.
+ if m.configPath != "" {
+ if err := saveConfig(m.configPath, m.existingCfg); err != nil {
+ if !m.reloadConfigAfterSaveFailure() {
+ m.existingCfg.Providers[provider.Name] = prevEntry
+ }
+ return false, fmt.Errorf("failed to save models: %w", err)
+ }
+ }
+ m.savedInSession = true
+ return true, nil
+ default:
+ return false, fmt.Errorf("unsupported tab for custom model: %v", m.activeTab)
}
- m.savedInSession = true
- return nil
+}
+
+const maskedSecretDisplayLen = 20
+
+func maskedSecretPlaceholder() string {
+ return strings.Repeat("*", maskedSecretDisplayLen)
+}
+
+// isUserEditMsg reports whether msg represents user text input (typing or paste).
+func isUserEditMsg(msg tea.Msg) bool {
+ switch msg.(type) {
+ case tea.KeyPressMsg, tea.PasteMsg:
+ return true
+ default:
+ return false
+ }
+}
+
+// beginAPIKeyReplace switches from the fixed-length mask placeholder to edit
+// mode so the next keystroke or paste fully replaces the saved key. While
+// editing, EchoPassword shows one '*' per typed character.
+func (m *providerTUIModel) beginAPIKeyReplace() {
+ if !m.apiKeyMasked {
+ return
+ }
+ m.apiKeyMasked = false
+ m.apiKeyOriginal = ""
+ m.apiKeyInput.SetValue("")
+}
+
+// customAPIKeyForSave reports the API key to persist and whether the user edited
+// the field (vs left the masked placeholder untouched).
+func (m providerTUIModel) customAPIKeyForSave() (key string, edited bool) {
+ if m.apiKeyMasked {
+ return m.apiKeyOriginal, false
+ }
+ return strings.TrimSpace(m.apiKeyInput.Value()), true
+}
+
+// beginManualTokenReplace is the manual-tab equivalent of beginAPIKeyReplace.
+func (m *providerTUIModel) beginManualTokenReplace() {
+ if !m.manualTokenMasked {
+ return
+ }
+ m.manualTokenMasked = false
+ m.manualTokenOriginal = ""
+ m.manualTokenInput.SetValue("")
}
// refreshModelSelectionForCustom moves the cursor to "Enter custom model name..."
@@ -651,6 +873,34 @@ func (m *providerTUIModel) refreshModelSelectionForCustom() {
m.modelIdx = len(models) // land on "Enter custom model name..."
}
+func officialProviderEnvKeySet(p llm.Provider) bool {
+ return p.EnvVar != "" && os.Getenv(p.EnvVar) != ""
+}
+
+func officialAPIKeyRequiredError(p llm.Provider) string {
+ if p.EnvVar != "" {
+ return fmt.Sprintf("API key is required (or set $%s)", p.EnvVar)
+ }
+ return "API key is required"
+}
+
+func (m providerTUIModel) apiKeyStepCanConfirm() (ok bool, errMsg string) {
+ if m.apiKeyOriginal != "" {
+ return true, ""
+ }
+ if !m.apiKeyMasked && strings.TrimSpace(m.apiKeyInput.Value()) != "" {
+ return true, ""
+ }
+ if m.activeTab == tabOfficial {
+ p := m.currentProvider()
+ if officialProviderEnvKeySet(p) {
+ return true, ""
+ }
+ return false, officialAPIKeyRequiredError(p)
+ }
+ return false, "API key is required"
+}
+
func (m providerTUIModel) updateAPIKeyInput(key string, msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
switch key {
case "esc":
@@ -659,6 +909,11 @@ func (m providerTUIModel) updateAPIKeyInput(key string, msg tea.KeyPressMsg) (te
m.formError = ""
return m, nil
case "enter":
+ if ok, errMsg := m.apiKeyStepCanConfirm(); !ok {
+ m.formError = errMsg
+ return m, nil
+ }
+ m.formError = ""
m.confirmed = true
return m, tea.Quit
case "ctrl+c":
@@ -666,12 +921,7 @@ func (m providerTUIModel) updateAPIKeyInput(key string, msg tea.KeyPressMsg) (te
return m, tea.Quit
default:
if m.apiKeyMasked {
- if len(key) == 1 {
- m.apiKeyMasked = false
- m.apiKeyInput.SetValue("")
- } else {
- return m, nil
- }
+ m.beginAPIKeyReplace()
}
var cmd tea.Cmd
m.apiKeyInput, cmd = m.apiKeyInput.Update(msg)
@@ -727,12 +977,7 @@ func (m providerTUIModel) updateCustomProviderForm(key string, msg tea.KeyPressM
}
if m.cpStep == cpStepAPIKey {
if m.apiKeyMasked {
- if len(key) == 1 {
- m.apiKeyMasked = false
- m.apiKeyInput.SetValue("")
- } else {
- return m, nil
- }
+ m.beginAPIKeyReplace()
}
var cmd tea.Cmd
m.apiKeyInput, cmd = m.apiKeyInput.Update(msg)
@@ -760,7 +1005,7 @@ func (m *providerTUIModel) enterEditCustomProvider() {
if entry.APIKey != "" {
m.apiKeyOriginal = entry.APIKey
m.apiKeyMasked = true
- m.apiKeyInput.SetValue(strings.Repeat("*", 20))
+ m.apiKeyInput.SetValue(maskedSecretPlaceholder())
} else {
m.apiKeyInput.SetValue("")
m.apiKeyMasked = false
@@ -775,6 +1020,8 @@ func authHeaderFormError(raw string) string {
)
}
+const manualAuthTokenRequiredError = "Auth token is required (whitespace-only input is not accepted)"
+
func (m providerTUIModel) handleCustomFormEnter() (tea.Model, tea.Cmd) {
switch m.cpStep {
case cpStepName:
@@ -827,11 +1074,14 @@ func (m providerTUIModel) handleCustomFormEnter() (tea.Model, tea.Cmd) {
// Edit succeeded — drop the user into the model list for this provider.
m.editingCustom = false
m.editTargetName = ""
+ m.apiKeyInput.SetValue("")
+ m.apiKeyMasked = false
+ m.apiKeyOriginal = ""
if idx := m.findCustomIdx(r.provider); idx >= 0 {
m.customIdx = idx
}
m.step = stepModel
- m.prepareModelSelection(m.customProviderEntry(r.provider, ProviderEntry{}).Model)
+ m.prepareModelSelection(r.provider, m.customProviderEntry(r.provider, ProviderEntry{}).Model)
return m, nil
}
if m.creatingCustom {
@@ -872,9 +1122,7 @@ func (m providerTUIModel) applyCreateCustomProvider() (tea.Model, tea.Cmd) {
URL: r.url,
Protocol: r.protocol,
AuthHeader: r.authHeader,
- }
- if r.apiKey != "" {
- entry.APIKey = r.apiKey
+ APIKey: strings.TrimSpace(m.apiKeyInput.Value()),
}
m.existingCfg.CustomProviders[r.provider] = entry
@@ -900,7 +1148,7 @@ func (m providerTUIModel) applyCreateCustomProvider() (tea.Model, tea.Cmd) {
// Drop into the model selection step so the user picks/adds a model for
// the newly created provider right away.
m.step = stepModel
- m.prepareModelSelection("")
+ m.prepareModelSelection(r.provider, "")
return m, nil
}
@@ -977,8 +1225,8 @@ func (m *providerTUIModel) applyEditCustomProviderSave() error {
entry.URL = r.url
entry.Protocol = r.protocol
entry.AuthHeader = r.authHeader
- if r.apiKey != "" {
- entry.APIKey = r.apiKey
+ if key, edited := m.customAPIKeyForSave(); edited {
+ entry.APIKey = key
}
// If name changed, delete old key
if r.editTargetName != "" && r.editTargetName != r.provider {
@@ -1059,7 +1307,9 @@ func (m providerTUIModel) passThroughCPInput(msg tea.Msg) (tea.Model, tea.Cmd) {
case cpStepBaseURL:
m.cpURLInput, cmd = m.cpURLInput.Update(msg)
case cpStepAPIKey:
- // masked unlock is handled in updateCustomProviderForm default branch
+ if m.apiKeyMasked && isUserEditMsg(msg) {
+ m.beginAPIKeyReplace()
+ }
m.apiKeyInput, cmd = m.apiKeyInput.Update(msg)
case cpStepAuthHeader:
m.cpAuthInput, cmd = m.cpAuthInput.Update(msg)
@@ -1086,7 +1336,7 @@ func (m providerTUIModel) updateManualForm(key string, msg tea.KeyPressMsg) (tea
if m.existingCfg.Llm.AuthToken != "" {
m.manualTokenOriginal = m.existingCfg.Llm.AuthToken
m.manualTokenMasked = true
- m.manualTokenInput.SetValue(strings.Repeat("*", 20))
+ m.manualTokenInput.SetValue(maskedSecretPlaceholder())
} else {
m.manualTokenInput.SetValue("")
m.manualTokenMasked = false
@@ -1125,12 +1375,7 @@ func (m providerTUIModel) updateManualForm(key string, msg tea.KeyPressMsg) (tea
}
}
if m.manualStep == manualStepAuthToken && m.manualTokenMasked {
- if len(key) == 1 {
- m.manualTokenMasked = false
- m.manualTokenInput.SetValue("")
- } else {
- return m, nil
- }
+ m.beginManualTokenReplace()
}
return m.passThroughManualInput(msg)
}
@@ -1187,50 +1432,14 @@ func (m providerTUIModel) updateDeleteConfirm(key string) (tea.Model, tea.Cmd) {
func (m providerTUIModel) updateDeleteModelConfirm(key string) (tea.Model, tea.Cmd) {
switch key {
case "y", "Y":
- if m.customIdx >= len(m.customProviders) {
+ switch m.activeTab {
+ case tabCustom:
+ return m.confirmDeleteCustomModel()
+ case tabOfficial:
+ return m.confirmDeleteOfficialModel()
+ default:
m.confirmingDeleteModel = false
- return m, nil
}
- models := m.models()
- if m.modelIdx < len(models) {
- cp := m.customProviders[m.customIdx]
- cp.entry.Models = removeModels(cp.entry.Models, []string{m.deleteModelName})
- if cp.entry.Model == m.deleteModelName {
- cp.entry.Model = ""
- }
- if m.existingCfg != nil && m.existingCfg.Provider == cp.name &&
- m.existingCfg.Model == m.deleteModelName {
- m.existingCfg.Model = ""
- }
- m.customProviders[m.customIdx] = cp
- if m.existingCfg != nil {
- if m.existingCfg.CustomProviders == nil {
- m.existingCfg.CustomProviders = make(map[string]ProviderEntry)
- }
- m.existingCfg.CustomProviders[cp.name] = cp.entry
- }
- if m.configPath != "" {
- if err := saveConfig(m.configPath, m.existingCfg); err != nil {
- if reloaded, reloadErr := loadOrCreateConfig(m.configPath); reloadErr == nil {
- m.existingCfg = reloaded
- m.customProviders = collectCustomProviders(reloaded)
- }
- m.formError = fmt.Sprintf("failed to save: %v", err)
- m.confirmingDeleteModel = false
- return m, nil
- }
- }
- updated := m.models()
- if m.modelIdx >= len(updated) {
- if len(updated) > 0 {
- m.modelIdx = len(updated) - 1
- } else {
- m.modelIdx = 0
- }
- }
- }
- m.savedInSession = true
- m.confirmingDeleteModel = false
return m, nil
case "n", "N", "esc":
m.confirmingDeleteModel = false
@@ -1242,6 +1451,118 @@ func (m providerTUIModel) updateDeleteModelConfirm(key string) (tea.Model, tea.C
return m, nil
}
+func (m providerTUIModel) confirmDeleteCustomModel() (tea.Model, tea.Cmd) {
+ if m.customIdx >= len(m.customProviders) {
+ m.confirmingDeleteModel = false
+ return m, nil
+ }
+ models := m.models()
+ if m.modelIdx < len(models) {
+ cp := m.customProviders[m.customIdx]
+ prevEntry := cloneProviderEntry(cp.entry)
+ prevCfgModel := ""
+ if m.existingCfg != nil && m.existingCfg.Provider == cp.name {
+ prevCfgModel = m.existingCfg.Model
+ }
+ cp.entry = applyModelDeleteToEntry(cp.entry, m.deleteModelName)
+ clearCfgActiveModelIfDeleted(m.existingCfg, cp.name, m.deleteModelName)
+ m.customProviders[m.customIdx] = cp
+ if m.existingCfg != nil {
+ if m.existingCfg.CustomProviders == nil {
+ m.existingCfg.CustomProviders = make(map[string]ProviderEntry)
+ }
+ m.existingCfg.CustomProviders[cp.name] = cp.entry
+ }
+ if m.configPath != "" {
+ if err := saveConfig(m.configPath, m.existingCfg); err != nil {
+ if !m.reloadConfigAfterSaveFailure() {
+ cp.entry = prevEntry
+ m.customProviders[m.customIdx] = cp
+ if m.existingCfg != nil {
+ m.existingCfg.CustomProviders[cp.name] = prevEntry
+ rollbackCfgActiveModel(m.existingCfg, cp.name, prevCfgModel)
+ }
+ }
+ m.formError = fmt.Sprintf("failed to save: %v", err)
+ m.adjustModelIdxAfterDelete()
+ m.confirmingDeleteModel = false
+ return m, nil
+ }
+ }
+ m.adjustModelIdxAfterDelete()
+ m.savedInSession = true
+ }
+ m.confirmingDeleteModel = false
+ return m, nil
+}
+
+func (m providerTUIModel) confirmDeleteOfficialModel() (tea.Model, tea.Cmd) {
+ if !m.isUserAddedOfficialModel(m.deleteModelName) {
+ m.confirmingDeleteModel = false
+ return m, nil
+ }
+ provider := m.currentProvider()
+ if m.existingCfg == nil || provider.Name == "" {
+ m.confirmingDeleteModel = false
+ return m, nil
+ }
+ if m.existingCfg.Providers == nil {
+ m.existingCfg.Providers = make(map[string]ProviderEntry)
+ }
+ prevEntry := cloneProviderEntry(m.existingCfg.Providers[provider.Name])
+ prevCfgModel := ""
+ if m.existingCfg.Provider == provider.Name {
+ prevCfgModel = m.existingCfg.Model
+ }
+ entry := applyModelDeleteToEntry(m.existingCfg.Providers[provider.Name], m.deleteModelName)
+ clearCfgActiveModelIfDeleted(m.existingCfg, provider.Name, m.deleteModelName)
+ m.existingCfg.Providers[provider.Name] = entry
+ if m.configPath != "" {
+ if err := saveConfig(m.configPath, m.existingCfg); err != nil {
+ if !m.reloadConfigAfterSaveFailure() {
+ m.existingCfg.Providers[provider.Name] = prevEntry
+ rollbackCfgActiveModel(m.existingCfg, provider.Name, prevCfgModel)
+ }
+ m.formError = fmt.Sprintf("failed to save: %v", err)
+ m.adjustModelIdxAfterDelete()
+ m.confirmingDeleteModel = false
+ return m, nil
+ }
+ }
+ m.adjustModelIdxAfterDelete()
+ // In-memory delete succeeded; configPath may be empty in tests (no disk write).
+ m.savedInSession = true
+ m.confirmingDeleteModel = false
+ return m, nil
+}
+
+func (m *providerTUIModel) adjustModelIdxAfterDelete() {
+ updated := m.models()
+ if m.modelIdx >= len(updated) {
+ if len(updated) > 0 {
+ m.modelIdx = len(updated) - 1
+ } else {
+ m.modelIdx = 0
+ }
+ }
+}
+
+// reloadConfigAfterSaveFailure reloads on-disk config and refreshes derived
+// provider TUI state so the UI matches persisted data after a failed save.
+// Returns false when reload could not run (e.g. missing/invalid config path).
+func (m *providerTUIModel) reloadConfigAfterSaveFailure() bool {
+ if m.configPath == "" {
+ return false
+ }
+ reloaded, err := loadOrCreateConfig(m.configPath)
+ if err != nil {
+ return false
+ }
+ m.existingCfg = reloaded
+ m.customProviders = collectCustomProviders(reloaded)
+ return true
+}
+
func (m providerTUIModel) handleManualFormEnter() (tea.Model, tea.Cmd) {
switch m.manualStep {
case manualStepURL:
@@ -1262,9 +1583,11 @@ func (m providerTUIModel) handleManualFormEnter() (tea.Model, tea.Cmd) {
m.manualStep = manualStepAuthToken
return m, m.manualTokenInput.Focus()
case manualStepAuthToken:
- if m.manualTokenInput.Value() == "" && m.manualTokenOriginal == "" {
+ if strings.TrimSpace(m.manualTokenInput.Value()) == "" && m.manualTokenOriginal == "" {
+ m.formError = manualAuthTokenRequiredError
return m, nil
}
+ m.formError = ""
m.manualTokenInput.Blur()
m.manualStep = manualStepAuthHeader
return m, m.manualAuthHeaderInput.Focus()
@@ -1322,6 +1645,9 @@ func (m providerTUIModel) passThroughManualInput(msg tea.Msg) (tea.Model, tea.Cm
case manualStepModel:
m.manualModelInput, cmd = m.manualModelInput.Update(msg)
case manualStepAuthToken:
+ if m.manualTokenMasked && isUserEditMsg(msg) {
+ m.beginManualTokenReplace()
+ }
m.manualTokenInput, cmd = m.manualTokenInput.Update(msg)
case manualStepAuthHeader:
m.manualAuthHeaderInput, cmd = m.manualAuthHeaderInput.Update(msg)
@@ -1344,7 +1670,7 @@ func (m providerTUIModel) handleEnter() (tea.Model, tea.Cmd) {
currentModel = activeModelForProvider(m.existingCfg, m.currentProvider().Name, entry)
}
}
- m.prepareModelSelection(currentModel)
+ m.prepareModelSelection(m.currentProvider().Name, currentModel)
return m, nil
case tabCustom:
@@ -1364,7 +1690,7 @@ func (m providerTUIModel) handleEnter() (tea.Model, tea.Cmd) {
cp := m.customProviders[m.customIdx]
m.step = stepModel
entry := m.customProviderEntry(cp.name, cp.entry)
- m.prepareModelSelection(activeModelForProvider(m.existingCfg, cp.name, entry))
+ m.prepareModelSelection(cp.name, activeModelForProvider(m.existingCfg, cp.name, entry))
return m, nil
case tabManual:
@@ -1452,7 +1778,7 @@ func (m *providerTUIModel) loadExistingAPIKey() {
if cp, ok := m.selectedCustomProvider(); ok && cp.entry.APIKey != "" {
m.apiKeyOriginal = cp.entry.APIKey
m.apiKeyMasked = true
- m.apiKeyInput.SetValue(strings.Repeat("*", 20))
+ m.apiKeyInput.SetValue(maskedSecretPlaceholder())
}
return
}
@@ -1463,7 +1789,7 @@ func (m *providerTUIModel) loadExistingAPIKey() {
if entry, ok := m.existingCfg.Providers[p.Name]; ok && entry.APIKey != "" {
m.apiKeyOriginal = entry.APIKey
m.apiKeyMasked = true
- m.apiKeyInput.SetValue(strings.Repeat("*", 20))
+ m.apiKeyInput.SetValue(maskedSecretPlaceholder())
}
}
@@ -1483,24 +1809,28 @@ func (m providerTUIModel) result() providerTUIResult {
case tabOfficial:
p := m.currentProvider()
model := m.selectedModelFromState()
+ if model == "" {
+ model = m.sessionModelPickFor(p.Name)
+ }
apiKey := ""
if m.apiKeyMasked {
apiKey = m.apiKeyOriginal
} else {
- apiKey = m.apiKeyInput.Value()
+ apiKey = strings.TrimSpace(m.apiKeyInput.Value())
}
return providerTUIResult{
- provider: p.Name,
- model: model,
- apiKey: apiKey,
+ provider: p.Name,
+ model: model,
+ apiKey: apiKey,
+ sessionModelPick: m.sessionModelPickSnapshot(),
}
case tabCustom:
if m.creatingCustom || m.editingCustom {
protocol := cpProtocols[m.cpProtocolIdx]
- apiKey := m.apiKeyInput.Value()
+ apiKey := strings.TrimSpace(m.apiKeyInput.Value())
if m.apiKeyMasked {
apiKey = m.apiKeyOriginal
}
@@ -1528,6 +1858,9 @@ func (m providerTUIModel) result() providerTUIResult {
if m.customIdx < len(m.customProviders) {
cp := m.customProviders[m.customIdx]
model := m.selectedModelFromState()
+ if model == "" {
+ model = m.sessionModelPickFor(cp.name)
+ }
if model == "" {
model = cp.entry.Model
}
@@ -1535,17 +1868,18 @@ func (m providerTUIModel) result() providerTUIResult {
if m.apiKeyMasked {
apiKey = m.apiKeyOriginal
} else {
- apiKey = m.apiKeyInput.Value()
+ apiKey = strings.TrimSpace(m.apiKeyInput.Value())
}
return providerTUIResult{
- provider: cp.name,
- model: model,
- models: append([]string(nil), cp.entry.Models...),
- apiKey: apiKey,
- isCustom: true,
- url: cp.entry.URL,
- protocol: cp.entry.Protocol,
- authHeader: cp.entry.AuthHeader,
+ provider: cp.name,
+ model: model,
+ models: append([]string(nil), cp.entry.Models...),
+ apiKey: apiKey,
+ isCustom: true,
+ url: cp.entry.URL,
+ protocol: cp.entry.Protocol,
+ authHeader: cp.entry.AuthHeader,
+ sessionModelPick: m.sessionModelPickSnapshot(),
}
}
return providerTUIResult{}
@@ -1576,6 +1910,16 @@ func listCursorPrefix(isCursor bool) string {
return " "
}
+func listCursorPrefixForModel(isCursor, userAdded bool) string {
+ if !isCursor {
+ return " "
+ }
+ if userAdded {
+ return " " + tuiUserModelCursorStyle.Render(tuiCursor) + " "
+ }
+ return listCursorPrefix(true)
+}
+
func renderListName(name string, isCursor bool) string {
if isCursor {
return tuiSelectedItemStyle.Render(name)
@@ -1583,6 +1927,13 @@ func renderListName(name string, isCursor bool) string {
return tuiItemStyle.Render(name)
}
+func renderModelName(name string, isCursor, userAdded bool) string {
+ if isCursor && userAdded {
+ return tuiUserModelSelectedStyle.Render(name)
+ }
+ return renderListName(name, isCursor)
+}
+
// --- View ---
func (m providerTUIModel) View() tea.View {
@@ -1657,6 +2008,9 @@ func (m providerTUIModel) viewOfficialTab(s *strings.Builder) {
for i, p := range m.providers {
isCursor := i == m.officialIdx
s.WriteString(listCursorPrefix(isCursor) + renderListName(p.DisplayName, isCursor))
+ if activeModel := m.officialProviderActiveModel(p); activeModel != "" {
+ s.WriteString(" " + tuiDimStyle.Render("("+activeModel+")"))
+ }
s.WriteString("\n")
}
}
@@ -1673,8 +2027,11 @@ func (m providerTUIModel) viewCustomTab(s *strings.Builder) {
for i, cp := range m.customProviders {
isCursor := i == m.customIdx
activeModel := m.customProviderActiveModel(cp)
+ // userAdded here means "green highlight when selected" for user-managed rows.
+ highlight := isCursor
- s.WriteString(listCursorPrefix(isCursor) + renderListName(cp.name, isCursor))
+ s.WriteString(listCursorPrefixForModel(isCursor, highlight))
+ s.WriteString(renderModelName(cp.name, isCursor, highlight))
if activeModel != "" {
s.WriteString(" " + tuiDimStyle.Render("("+activeModel+")"))
}
@@ -1750,6 +2107,9 @@ func (m providerTUIModel) viewCustomProviderForm(s *strings.Builder) {
s.WriteString(" " + m.cpURLInput.View() + "\n")
case cpStepAPIKey:
s.WriteString(" " + m.apiKeyInput.View() + "\n")
+ if m.apiKeyMasked && m.apiKeyOriginal != "" {
+ s.WriteString(tuiDimStyle.Render(" "+savedSecretReplaceHint(m.apiKeyOriginal)) + "\n")
+ }
case cpStepAuthHeader:
s.WriteString(" " + m.cpAuthInput.View() + "\n")
}
@@ -1827,6 +2187,9 @@ func (m providerTUIModel) viewManualTab(s *strings.Builder) {
s.WriteString(" " + m.manualModelInput.View() + "\n")
case manualStepAuthToken:
s.WriteString(" " + m.manualTokenInput.View() + "\n")
+ if m.manualTokenMasked && m.manualTokenOriginal != "" {
+ s.WriteString(tuiDimStyle.Render(" "+savedSecretReplaceHint(m.manualTokenOriginal)) + "\n")
+ }
case manualStepAuthHeader:
s.WriteString(" " + m.manualAuthHeaderInput.View() + "\n")
}
@@ -1858,7 +2221,16 @@ func (m providerTUIModel) viewModel(s *strings.Builder) {
for i, model := range models {
isCursor := i == m.modelIdx
- s.WriteString(listCursorPrefix(isCursor) + renderListName(model, isCursor))
+ if m.activeTab == tabOfficial {
+ userAdded := m.isUserAddedOfficialModel(model)
+ s.WriteString(listCursorPrefixForModel(isCursor, userAdded))
+ s.WriteString(renderModelName(model, isCursor, userAdded))
+ } else {
+ // Custom tab: all models are user-managed; pass isCursor as userAdded
+ // so green highlight applies only to the selected row (not registry semantics).
+ s.WriteString(listCursorPrefixForModel(isCursor, isCursor))
+ s.WriteString(renderModelName(model, isCursor, isCursor))
+ }
s.WriteString("\n")
}
@@ -1888,7 +2260,7 @@ func (m providerTUIModel) viewModel(s *strings.Builder) {
s.WriteString(" " + tuiSelectedItemStyle.Render(fmt.Sprintf("Delete %q? (y/n)", m.deleteModelName)))
s.WriteString("\n")
s.WriteString(tuiHelpStyle.Render(" y Confirm · n/Esc Cancel"))
- } else if m.activeTab == tabCustom && m.customIdx < len(m.customProviders) {
+ } else if m.cursorOnDeletableModel() {
s.WriteString(tuiHelpStyle.Render(" ↑/↓ Select Enter Confirm d Delete Esc Back"))
} else {
s.WriteString(tuiHelpStyle.Render(" ↑/↓ Select Enter Confirm Esc Back"))
@@ -1910,11 +2282,22 @@ func (m providerTUIModel) viewAPIKey(s *strings.Builder) {
s.WriteString(" " + m.apiKeyInput.View())
s.WriteString("\n")
+ // When an API key is already saved, the input starts masked. Surface a
+ // hint so the user knows typing or pasting will replace the saved key,
+ // and show a short prefix fingerprint so they can sanity-check which key
+ // is currently saved without exposing it.
+ if m.apiKeyMasked && m.apiKeyOriginal != "" {
+ s.WriteString("\n")
+ s.WriteString(tuiDimStyle.Render(savedSecretReplaceHintLine(m.apiKeyOriginal)))
+ s.WriteString("\n")
+ }
+
if m.activeTab == tabOfficial {
provider := m.currentProvider()
if envKey := os.Getenv(provider.EnvVar); envKey != "" {
s.WriteString("\n")
- s.WriteString(tuiDimStyle.Render(fmt.Sprintf(" $%s is set", provider.EnvVar)))
+ hasSavedKey := m.apiKeyMasked && m.apiKeyOriginal != ""
+ s.WriteString(tuiDimStyle.Render(officialAPIKeyEnvSetHintLine(provider.EnvVar, hasSavedKey)))
s.WriteString("\n")
} else {
s.WriteString("\n")
@@ -1923,11 +2306,70 @@ func (m providerTUIModel) viewAPIKey(s *strings.Builder) {
}
}
+ if m.formError != "" {
+ s.WriteString("\n")
+ s.WriteString(tuiErrorStyle.Render(" " + m.formError))
+ s.WriteString("\n")
+ }
+
s.WriteString("\n")
s.WriteString(tuiHelpStyle.Render(" Enter Confirm Esc Back"))
s.WriteString("\n")
}
+// savedSecretFingerprintMinHiddenLen is the minimum number of runes that must
+// sit between the visible prefix and suffix so the fingerprint does not expose
+// the entire key (e.g. a 10-rune key with prefix 6 + suffix 4).
+const savedSecretFingerprintMinHiddenLen = 5
+
+// savedSecretFingerprintMinLen is the minimum trimmed secret length required
+// before a fingerprint is shown. Shorter keys hide the parenthetical hint.
+const savedSecretFingerprintMinLen = savedSecretFingerprintPrefixLen + savedSecretFingerprintSuffixLen + savedSecretFingerprintMinHiddenLen
+
+// savedSecretFingerprintPrefixLen is how many leading runes to show.
+const savedSecretFingerprintPrefixLen = 6
+
+// savedSecretFingerprintSuffixLen is how many trailing runes to show.
+const savedSecretFingerprintSuffixLen = 4
+
+// savedSecretFingerprint returns a short fingerprint for display, e.g.
+// "sk-a1b2...wxyz" (first 6 + "..." + last 4). Returns "" when the trimmed
+// secret is shorter than savedSecretFingerprintMinLen runes.
+func savedSecretFingerprint(s string) string {
+ s = strings.TrimSpace(s)
+ runes := []rune(s)
+ if len(runes) < savedSecretFingerprintMinLen {
+ return ""
+ }
+ prefix := string(runes[:savedSecretFingerprintPrefixLen])
+ suffix := string(runes[len(runes)-savedSecretFingerprintSuffixLen:])
+ return prefix + "..." + suffix
+}
+
+// savedSecretReplaceHint builds the replace hint text without leading indent.
+func savedSecretReplaceHint(original string) string {
+ hint := "Type or paste to replace the saved key."
+ if fp := savedSecretFingerprint(original); fp != "" {
+ hint += fmt.Sprintf(" (saved: %s)", fp)
+ }
+ return hint
+}
+
+func savedSecretReplaceHintLine(original string) string {
+ return " " + savedSecretReplaceHint(original)
+}
+
+func officialAPIKeyEnvSetHint(envVar string, hasSavedKey bool) string {
+ if hasSavedKey {
+ return fmt.Sprintf("$%s is set; used only when no key is saved here.", envVar)
+ }
+ return fmt.Sprintf("$%s is set. Leave empty to use it; enter a key here to override.", envVar)
+}
+
+func officialAPIKeyEnvSetHintLine(envVar string, hasSavedKey bool) string {
+ return " " + officialAPIKeyEnvSetHint(envVar, hasSavedKey)
+}
+
// --- Styles ---
const tuiCursor = "▸"
@@ -1944,6 +2386,13 @@ var (
Bold(true).
Foreground(lipgloss.Color("12"))
+ tuiUserModelSelectedStyle = lipgloss.NewStyle().
+ Bold(true).
+ Foreground(lipgloss.Color("10"))
+
+ tuiUserModelCursorStyle = lipgloss.NewStyle().
+ Foreground(lipgloss.Color("10"))
+
tuiItemStyle = lipgloss.NewStyle()
tuiDimStyle = lipgloss.NewStyle().
@@ -1965,6 +2414,16 @@ var (
// --- Model-only TUI (for `ocr config model`) ---
+type modelTUIConfig struct {
+ Provider llm.Provider
+ CurrentModel string
+ RegistryModels []string
+ ExistingCfg *Config
+ ConfigPath string
+ ProviderName string
+ IsCustom bool
+}
+
type modelTUIModel struct {
width int
height int
@@ -1976,52 +2435,231 @@ type modelTUIModel struct {
modelInput textinput.Model
activeModel string
+ registryModels []string
+ existingCfg *Config
+ configPath string
+ providerName string
+ isCustomProvider bool
+
+ confirmingDeleteModel bool
+ deleteModelName string
+ formError string
+
confirmed bool
cancelled bool
+
+ // savedInSession is true after a model add/delete was persisted during the session.
+ savedInSession bool
}
+// newModelTUI builds a model-only TUI for tests. It has no config path or existing
+// config, so add/delete/persist operations are unavailable — use newModelTUIConfig
+// in production (ocr config model). IsCustom follows whether the provider is a preset.
func newModelTUI(provider llm.Provider, currentModel string) modelTUIModel {
+ registryModels := append([]string(nil), provider.Models...)
+ isCustom := true
+ if preset, ok := llm.LookupProvider(provider.Name); ok {
+ isCustom = false
+ registryModels = append([]string(nil), preset.Models...)
+ }
+ return newModelTUIConfig(modelTUIConfig{
+ Provider: provider,
+ CurrentModel: currentModel,
+ ProviderName: provider.Name,
+ RegistryModels: registryModels,
+ IsCustom: isCustom,
+ })
+}
+
+func newModelTUIConfig(cfg modelTUIConfig) modelTUIModel {
mi := textinput.New()
mi.Placeholder = "model name(s), comma-separated"
mi.SetWidth(50)
m := modelTUIModel{
- provider: provider,
- models: provider.Models,
- width: 80,
- height: 24,
- modelInput: mi,
- activeModel: currentModel,
+ provider: cfg.Provider,
+ width: 80,
+ height: 24,
+ modelInput: mi,
+ activeModel: cfg.CurrentModel,
+ registryModels: append([]string(nil), cfg.RegistryModels...),
+ existingCfg: cfg.ExistingCfg,
+ configPath: cfg.ConfigPath,
+ providerName: cfg.ProviderName,
+ isCustomProvider: cfg.IsCustom,
}
- if currentModel != "" {
+ if cfg.IsCustom {
+ m.models = append([]string(nil), cfg.Provider.Models...)
+ }
+
+ if cfg.CurrentModel != "" {
found := false
- for i, model := range m.models {
- if model == currentModel {
+ models := m.displayModels()
+ for i, model := range models {
+ if model == cfg.CurrentModel {
m.modelIdx = i
found = true
break
}
}
if !found {
- m.modelIdx = len(m.models)
- m.modelInput.SetValue(currentModel)
+ m.modelIdx = len(models)
+ m.modelInput.SetValue(cfg.CurrentModel)
}
}
return m
}
+func (m modelTUIModel) displayModels() []string {
+ // Custom providers store the list in m.models (updated on add/delete).
+ // Official providers derive the list from registry + config on each call.
+ if m.isCustomProvider {
+ return m.models
+ }
+ models := append([]string(nil), m.registryModels...)
+ if m.existingCfg != nil && m.providerName != "" {
+ if entry, ok := m.existingCfg.Providers[m.providerName]; ok {
+ models = mergeModelLists(models, entry.Models)
+ }
+ }
+ return models
+}
+
+func (m modelTUIModel) isUserAddedModel(name string) bool {
+ if m.isCustomProvider {
+ // Custom providers have no llm registry; every model comes from config and
+ // is user-managed. List membership guards confirm against stale names.
+ return llm.ModelListContains(m.displayModels(), name)
+ }
+ return isUserAddedOfficialModelName(name, m.providerName, m.registryModels, m.existingCfg)
+}
+
+func (m modelTUIModel) cursorOnUserAddedModel() bool {
+ if m.confirmingDeleteModel {
+ return false
+ }
+ models := m.displayModels()
+ if m.modelIdx >= len(models) {
+ return false
+ }
+ if m.isCustomProvider {
+ return true
+ }
+ return m.isUserAddedModel(models[m.modelIdx])
+}
+
+func (m *modelTUIModel) adjustModelIdxAfterDelete() {
+ m.syncModelsFromConfig()
+ models := m.displayModels()
+ if m.modelIdx >= len(models) {
+ if len(models) > 0 {
+ m.modelIdx = len(models) - 1
+ } else {
+ m.modelIdx = 0
+ }
+ }
+}
+
+// reloadConfigAfterSaveFailure reloads on-disk config so in-memory state matches
+// persisted data after a failed save. Returns false when reload could not run.
+func (m *modelTUIModel) reloadConfigAfterSaveFailure() bool {
+ if m.configPath == "" {
+ return false
+ }
+ reloaded, err := loadOrCreateConfig(m.configPath)
+ if err != nil {
+ return false
+ }
+ m.existingCfg = reloaded
+ m.syncModelsFromConfig()
+ return true
+}
+
+func (m *modelTUIModel) syncModelsFromConfig() {
+ if !m.isCustomProvider || m.existingCfg == nil || m.providerName == "" {
+ return
+ }
+ if entry, ok := m.existingCfg.CustomProviders[m.providerName]; ok {
+ m.models = append([]string(nil), entry.Models...)
+ }
+}
+
+func (m *modelTUIModel) refreshModelSelectionAfterAdd(name string) {
+ models := m.displayModels()
+ for i, model := range models {
+ if model == name {
+ m.modelIdx = i
+ return
+ }
+ }
+ if len(models) > 0 {
+ m.modelIdx = len(models) - 1
+ } else {
+ m.modelIdx = 0
+ }
+}
+
+// persistAddedModelName appends a model to the provider's Models list in config
+// and saves to disk. It does not change the active model.
+func (m *modelTUIModel) persistAddedModelName(name string) error {
+ if name == "" {
+ return fmt.Errorf("model name must not be empty")
+ }
+ if m.existingCfg == nil || m.providerName == "" {
+ return fmt.Errorf("config not available")
+ }
+ if m.isCustomProvider {
+ if m.existingCfg.CustomProviders == nil {
+ m.existingCfg.CustomProviders = make(map[string]ProviderEntry)
+ }
+ entry := m.existingCfg.CustomProviders[m.providerName]
+ prevEntry := cloneProviderEntry(entry)
+ entry.Models = ensureModelInList(entry.Models, name)
+ m.existingCfg.CustomProviders[m.providerName] = entry
+ if m.configPath != "" {
+ if err := saveConfig(m.configPath, m.existingCfg); err != nil {
+ if !m.reloadConfigAfterSaveFailure() {
+ m.existingCfg.CustomProviders[m.providerName] = prevEntry
+ m.models = append([]string(nil), prevEntry.Models...)
+ }
+ return fmt.Errorf("failed to save models: %w", err)
+ }
+ }
+ m.models = append([]string(nil), entry.Models...)
+ m.savedInSession = true
+ return nil
+ }
+ if m.existingCfg.Providers == nil {
+ m.existingCfg.Providers = make(map[string]ProviderEntry)
+ }
+ entry := m.existingCfg.Providers[m.providerName]
+ prevEntry := cloneProviderEntry(entry)
+ entry.Models = ensureModelInList(entry.Models, name)
+ m.existingCfg.Providers[m.providerName] = entry
+ if m.configPath != "" {
+ if err := saveConfig(m.configPath, m.existingCfg); err != nil {
+ if !m.reloadConfigAfterSaveFailure() {
+ m.existingCfg.Providers[m.providerName] = prevEntry
+ }
+ return fmt.Errorf("failed to save models: %w", err)
+ }
+ }
+ m.savedInSession = true
+ return nil
+}
+
func (m modelTUIModel) Init() tea.Cmd {
return nil
}
func (m modelTUIModel) isCustomItem(idx int) bool {
- return idx == len(m.models)
+ return idx == len(m.displayModels())
}
func (m modelTUIModel) itemCount() int {
- return len(m.models) + 1
+ return len(m.displayModels()) + 1
}
func (m modelTUIModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -2034,6 +2672,10 @@ func (m modelTUIModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeyPressMsg:
key := msg.String()
+ if m.confirmingDeleteModel {
+ return m.updateDeleteModelConfirm(key)
+ }
+
if m.customModel {
switch key {
case "esc":
@@ -2042,14 +2684,28 @@ func (m modelTUIModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.modelInput.SetValue("")
return m, nil
case "enter":
- if m.modelInput.Value() != "" {
- m.confirmed = true
- return m, tea.Quit
+ name := strings.TrimSpace(m.modelInput.Value())
+ if name == "" {
+ return m, nil
}
+ if llm.ModelListContains(m.displayModels(), name) {
+ m.formError = fmt.Sprintf("Already in list: %s", name)
+ return m, nil
+ }
+ m.formError = ""
+ if err := m.persistAddedModelName(name); err != nil {
+ m.formError = err.Error()
+ return m, nil
+ }
+ m.customModel = false
+ m.modelInput.Blur()
+ m.modelInput.SetValue("")
+ m.refreshModelSelectionAfterAdd(name)
return m, nil
default:
var cmd tea.Cmd
m.modelInput, cmd = m.modelInput.Update(msg)
+ m.formError = ""
return m, cmd
}
}
@@ -2079,6 +2735,13 @@ func (m modelTUIModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.modelIdx = 0
}
return m, nil
+ case "d":
+ if m.cursorOnUserAddedModel() {
+ models := m.displayModels()
+ m.confirmingDeleteModel = true
+ m.deleteModelName = models[m.modelIdx]
+ }
+ return m, nil
}
default:
@@ -2091,12 +2754,116 @@ func (m modelTUIModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
+func (m *modelTUIModel) updateDeleteModelConfirm(key string) (tea.Model, tea.Cmd) {
+ switch key {
+ case "y", "Y":
+ return m.confirmDeleteModel()
+ case "n", "N", "esc":
+ m.confirmingDeleteModel = false
+ return *m, nil
+ case "ctrl+c":
+ m.cancelled = true
+ return *m, tea.Quit
+ }
+ return *m, nil
+}
+
+func (m *modelTUIModel) confirmDeleteModel() (tea.Model, tea.Cmd) {
+ if m.isCustomProvider {
+ return m.confirmDeleteCustomProviderModel()
+ }
+ return m.confirmDeleteOfficialModel()
+}
+
+func (m *modelTUIModel) confirmDeleteCustomProviderModel() (tea.Model, tea.Cmd) {
+ if !m.isUserAddedModel(m.deleteModelName) {
+ m.confirmingDeleteModel = false
+ return *m, nil
+ }
+ if m.existingCfg == nil || m.providerName == "" {
+ m.confirmingDeleteModel = false
+ return *m, nil
+ }
+ if m.existingCfg.CustomProviders == nil {
+ m.existingCfg.CustomProviders = make(map[string]ProviderEntry)
+ }
+ prevEntry := cloneProviderEntry(m.existingCfg.CustomProviders[m.providerName])
+ prevCfgModel := ""
+ if m.existingCfg.Provider == m.providerName {
+ prevCfgModel = m.existingCfg.Model
+ }
+ entry := applyModelDeleteToEntry(m.existingCfg.CustomProviders[m.providerName], m.deleteModelName)
+ clearCfgActiveModelIfDeleted(m.existingCfg, m.providerName, m.deleteModelName)
+ m.existingCfg.CustomProviders[m.providerName] = entry
+ if m.configPath != "" {
+ if err := saveConfig(m.configPath, m.existingCfg); err != nil {
+ if !m.reloadConfigAfterSaveFailure() {
+ m.rollbackModelDelete(prevEntry, prevCfgModel)
+ }
+ m.formError = fmt.Sprintf("failed to save: %v", err)
+ m.adjustModelIdxAfterDelete()
+ m.confirmingDeleteModel = false
+ return *m, nil
+ }
+ }
+ m.adjustModelIdxAfterDelete()
+ m.resetCustomModelInput()
+ m.savedInSession = true
+ m.confirmingDeleteModel = false
+ return *m, nil
+}
+
+func (m *modelTUIModel) confirmDeleteOfficialModel() (tea.Model, tea.Cmd) {
+ if !m.isUserAddedModel(m.deleteModelName) {
+ m.confirmingDeleteModel = false
+ return *m, nil
+ }
+ if m.existingCfg == nil || m.providerName == "" {
+ m.confirmingDeleteModel = false
+ return *m, nil
+ }
+ if m.existingCfg.Providers == nil {
+ m.existingCfg.Providers = make(map[string]ProviderEntry)
+ }
+ prevEntry := cloneProviderEntry(m.existingCfg.Providers[m.providerName])
+ prevCfgModel := ""
+ if m.existingCfg.Provider == m.providerName {
+ prevCfgModel = m.existingCfg.Model
+ }
+ entry := applyModelDeleteToEntry(m.existingCfg.Providers[m.providerName], m.deleteModelName)
+ clearCfgActiveModelIfDeleted(m.existingCfg, m.providerName, m.deleteModelName)
+ m.existingCfg.Providers[m.providerName] = entry
+ if m.configPath != "" {
+ if err := saveConfig(m.configPath, m.existingCfg); err != nil {
+ if !m.reloadConfigAfterSaveFailure() {
+ m.rollbackModelDelete(prevEntry, prevCfgModel)
+ }
+ m.formError = fmt.Sprintf("failed to save: %v", err)
+ m.adjustModelIdxAfterDelete()
+ m.confirmingDeleteModel = false
+ return *m, nil
+ }
+ }
+ m.adjustModelIdxAfterDelete()
+ m.resetCustomModelInput()
+ m.savedInSession = true
+ m.confirmingDeleteModel = false
+ return *m, nil
+}
+
+func (m *modelTUIModel) resetCustomModelInput() {
+ m.customModel = false
+ m.modelInput.SetValue("")
+ m.modelInput.Blur()
+}
+
func (m modelTUIModel) selectedModel() string {
if m.customModel || m.isCustomItem(m.modelIdx) {
return m.modelInput.Value()
}
- if m.modelIdx < len(m.models) {
- return m.models[m.modelIdx]
+ models := m.displayModels()
+ if m.modelIdx < len(models) {
+ return models[m.modelIdx]
}
return ""
}
@@ -2107,13 +2874,22 @@ func (m modelTUIModel) View() tea.View {
s.WriteString(tuiTitleStyle.Render(fmt.Sprintf(" Select a model (%s)", m.provider.DisplayName)))
s.WriteString("\n\n")
- for i, model := range m.models {
+ models := m.displayModels()
+ for i, model := range models {
isCursor := i == m.modelIdx
- s.WriteString(listCursorPrefix(isCursor) + renderListName(model, isCursor))
+ if m.isCustomProvider {
+ // All models are user-managed; isCursor drives green highlight on selection.
+ s.WriteString(listCursorPrefixForModel(isCursor, isCursor))
+ s.WriteString(renderModelName(model, isCursor, isCursor))
+ } else {
+ userAdded := m.isUserAddedModel(model)
+ s.WriteString(listCursorPrefixForModel(isCursor, userAdded))
+ s.WriteString(renderModelName(model, isCursor, userAdded))
+ }
s.WriteString("\n")
}
- customIdx := len(m.models)
+ customIdx := len(models)
isCursor := m.modelIdx == customIdx
customLabel := "Enter custom model name..."
if isCursor {
@@ -2129,8 +2905,23 @@ func (m modelTUIModel) View() tea.View {
s.WriteString("\n")
}
+ if m.formError != "" {
+ s.WriteString("\n")
+ s.WriteString(tuiErrorStyle.Render(" " + m.formError))
+ s.WriteString("\n")
+ }
+
s.WriteString("\n")
- s.WriteString(tuiHelpStyle.Render(" ↑/↓ Select Enter Confirm Esc Cancel"))
+
+ if m.confirmingDeleteModel {
+ s.WriteString(" " + tuiSelectedItemStyle.Render(fmt.Sprintf("Delete %q? (y/n)", m.deleteModelName)))
+ s.WriteString("\n")
+ s.WriteString(tuiHelpStyle.Render(" y Confirm · n/Esc Cancel"))
+ } else if m.cursorOnUserAddedModel() {
+ s.WriteString(tuiHelpStyle.Render(" ↑/↓ Select Enter Confirm d Delete Esc Cancel"))
+ } else {
+ s.WriteString(tuiHelpStyle.Render(" ↑/↓ Select Enter Confirm Esc Cancel"))
+ }
s.WriteString("\n")
v := tea.NewView(s.String())
diff --git a/cmd/opencodereview/provider_tui_funcs_test.go b/cmd/opencodereview/provider_tui_funcs_test.go
index a5f0001..932b1b0 100644
--- a/cmd/opencodereview/provider_tui_funcs_test.go
+++ b/cmd/opencodereview/provider_tui_funcs_test.go
@@ -1,6 +1,8 @@
package main
import (
+ "os"
+ "path/filepath"
"strings"
"testing"
@@ -44,6 +46,73 @@ func TestCustomProviderActiveModel_MatchingProvider(t *testing.T) {
}
}
+func TestOfficialProviderActiveModel_NilCfg(t *testing.T) {
+ m := providerTUIModel{existingCfg: nil}
+ got := m.officialProviderActiveModel(llm.Provider{Name: "anthropic"})
+ if got != "" {
+ t.Errorf("expected empty string for nil cfg, got %q", got)
+ }
+}
+
+func TestOfficialProviderActiveModel_DifferentProvider(t *testing.T) {
+ cfg := &Config{
+ Provider: "deepseek",
+ Model: "deepseek-v4-flash",
+ Providers: map[string]ProviderEntry{
+ "deepseek": {Model: "deepseek-v4-flash"},
+ },
+ }
+ m := newProviderTUI(cfg, "")
+ got := m.officialProviderActiveModel(llm.Provider{Name: "anthropic", DisplayName: "Anthropic Claude API"})
+ if got != "" {
+ t.Errorf("expected empty string for non-active provider, got %q", got)
+ }
+}
+
+func TestOfficialProviderActiveModel_MatchingProvider(t *testing.T) {
+ cfg := &Config{
+ Provider: "anthropic",
+ Model: "claude-opus-4-8",
+ Providers: map[string]ProviderEntry{
+ "anthropic": {Model: "claude-opus-4-8"},
+ },
+ }
+ m := newProviderTUI(cfg, "")
+ got := m.officialProviderActiveModel(llm.Provider{Name: "anthropic", DisplayName: "Anthropic Claude API"})
+ if got != "claude-opus-4-8" {
+ t.Errorf("expected claude-opus-4-8, got %q", got)
+ }
+}
+
+func TestOfficialProviderActiveModel_EmptyModel(t *testing.T) {
+ cfg := &Config{
+ Provider: "anthropic",
+ Providers: map[string]ProviderEntry{
+ "anthropic": {},
+ },
+ }
+ m := newProviderTUI(cfg, "")
+ got := m.officialProviderActiveModel(llm.Provider{Name: "anthropic"})
+ if got != "" {
+ t.Errorf("expected empty model, got %q", got)
+ }
+}
+
+func TestProviderTUIView_OfficialTab_ShowsActiveModelSuffix(t *testing.T) {
+ cfg := &Config{
+ Provider: "anthropic",
+ Model: "claude-opus-4-8",
+ Providers: map[string]ProviderEntry{
+ "anthropic": {Model: "claude-opus-4-8"},
+ },
+ }
+ m := newProviderTUI(cfg, "")
+ got := stripANSI(m.View().Content)
+ if !strings.Contains(got, "(claude-opus-4-8)") {
+ t.Errorf("view missing active model suffix; got:\n%s", got)
+ }
+}
+
func TestModelProviderName_OfficialTab(t *testing.T) {
m := newProviderTUI(&Config{}, "")
name := m.modelProviderName()
@@ -547,6 +616,12 @@ func TestNewModelTUI(t *testing.T) {
t.Skip("no providers")
}
m := newModelTUI(p[0], "")
+ if m.isCustomProvider {
+ t.Error("preset provider test helper should set isCustomProvider=false")
+ }
+ if len(m.registryModels) == 0 {
+ t.Error("registryModels should be populated for preset provider")
+ }
if m.modelIdx != 0 {
t.Errorf("modelIdx = %d, want 0 for empty currentModel", m.modelIdx)
}
@@ -861,6 +936,447 @@ func TestModelTUI_View_CustomModel(t *testing.T) {
}
}
+func officialConfigModelTUI(t *testing.T, configPath string, extraModels []string) modelTUIModel {
+ t.Helper()
+ preset, ok := llm.LookupProvider("dashscope")
+ if !ok {
+ t.Skip("dashscope provider not in registry")
+ }
+ models := []string{"qwen3.7-max"}
+ models = append(models, extraModels...)
+ cfg := &Config{
+ Provider: "dashscope",
+ Model: "qwen3.7-max",
+ Providers: map[string]ProviderEntry{
+ "dashscope": {
+ Model: "qwen3.7-max",
+ Models: models,
+ },
+ },
+ }
+ provider := preset
+ provider.Models = mergeModelLists(preset.Models, cfg.Providers["dashscope"].Models)
+ return newModelTUIConfig(modelTUIConfig{
+ Provider: provider,
+ RegistryModels: preset.Models,
+ ExistingCfg: cfg,
+ ConfigPath: configPath,
+ ProviderName: "dashscope",
+ IsCustom: false,
+ })
+}
+
+func customConfigModelTUI(t *testing.T, configPath string, models []string) modelTUIModel {
+ t.Helper()
+ cfg := &Config{
+ Provider: "my-llm",
+ Model: "m1",
+ CustomProviders: map[string]ProviderEntry{
+ "my-llm": {
+ URL: "https://custom.api/v1",
+ Protocol: "openai",
+ Model: "m1",
+ Models: append([]string(nil), models...),
+ },
+ },
+ }
+ provider := llm.Provider{
+ Name: "my-llm",
+ DisplayName: "my-llm (custom)",
+ Models: mergeModelLists(models),
+ }
+ return newModelTUIConfig(modelTUIConfig{
+ Provider: provider,
+ CurrentModel: "m1",
+ ExistingCfg: cfg,
+ ConfigPath: configPath,
+ ProviderName: "my-llm",
+ IsCustom: true,
+ })
+}
+
+func asModelTUIModel(t *testing.T, m tea.Model) modelTUIModel {
+ t.Helper()
+ switch v := m.(type) {
+ case modelTUIModel:
+ return v
+ case *modelTUIModel:
+ return *v
+ default:
+ t.Fatalf("expected modelTUIModel, got %T", m)
+ return modelTUIModel{}
+ }
+}
+
+func modelTUIEnterCustomModelName(t *testing.T, m modelTUIModel, name string) modelTUIModel {
+ t.Helper()
+ m.modelIdx = len(m.displayModels())
+ result, _ := m.Update(enterKey())
+ m2 := result.(modelTUIModel)
+ if !m2.customModel {
+ t.Fatal("expected customModel after enter on custom item")
+ }
+ m2.modelInput.SetValue(name)
+ result, _ = m2.Update(enterKey())
+ return result.(modelTUIModel)
+}
+
+func modelTUIIdxForName(t *testing.T, m modelTUIModel, name string) int {
+ t.Helper()
+ for i, model := range m.displayModels() {
+ if model == name {
+ return i
+ }
+ }
+ t.Fatalf("model %q not found in %v", name, m.displayModels())
+ return -1
+}
+
+func TestModelTUI_Official_AddCustomModelStaysOnList(t *testing.T) {
+ dir := t.TempDir()
+ configPath := filepath.Join(dir, "config.json")
+ m := officialConfigModelTUI(t, configPath, nil)
+ m3 := modelTUIEnterCustomModelName(t, m, "new-model")
+
+ if m3.confirmed {
+ t.Error("adding a model should not confirm and quit")
+ }
+ if m3.customModel {
+ t.Error("customModel should be cleared after add")
+ }
+ got := m3.existingCfg.Providers["dashscope"].Models
+ if !llm.ModelListContains(got, "new-model") {
+ t.Errorf("Models = %v, want new-model appended", got)
+ }
+ if m3.existingCfg.Model != "qwen3.7-max" {
+ t.Errorf("cfg.Model = %q, want active model unchanged", m3.existingCfg.Model)
+ }
+ diskCfg, err := loadOrCreateConfig(configPath)
+ if err != nil {
+ t.Fatalf("load disk config: %v", err)
+ }
+ if !llm.ModelListContains(diskCfg.Providers["dashscope"].Models, "new-model") {
+ t.Errorf("disk Models = %v, want new-model persisted", diskCfg.Providers["dashscope"].Models)
+ }
+}
+
+func TestModelTUI_Official_ListEnterConfirmsSelection(t *testing.T) {
+ m := officialConfigModelTUI(t, "", nil)
+ m2 := modelTUIEnterCustomModelName(t, m, "picked-model")
+ m2.modelIdx = modelTUIIdxForName(t, m2, "picked-model")
+ result, _ := m2.Update(enterKey())
+ m3 := result.(modelTUIModel)
+ if !m3.confirmed {
+ t.Error("enter on list item should confirm selection")
+ }
+ if m3.selectedModel() != "picked-model" {
+ t.Errorf("selectedModel() = %q, want picked-model", m3.selectedModel())
+ }
+}
+
+func TestModelTUI_CustomProvider_AddCustomModelStaysOnList(t *testing.T) {
+ dir := t.TempDir()
+ configPath := filepath.Join(dir, "config.json")
+ m := customConfigModelTUI(t, configPath, []string{"m1"})
+ m3 := modelTUIEnterCustomModelName(t, m, "new-custom-model")
+
+ if m3.confirmed {
+ t.Error("adding a model should not confirm and quit")
+ }
+ if !llm.ModelListContains(m3.existingCfg.CustomProviders["my-llm"].Models, "new-custom-model") {
+ t.Errorf("Models = %v, want new-custom-model appended", m3.existingCfg.CustomProviders["my-llm"].Models)
+ }
+ if m3.existingCfg.CustomProviders["my-llm"].Model != "m1" {
+ t.Errorf("entry.Model = %q, want active model unchanged", m3.existingCfg.CustomProviders["my-llm"].Model)
+ }
+ if !m3.savedInSession {
+ t.Error("savedInSession should be true after add")
+ }
+}
+
+func TestModelTUI_EscCancelWithoutChangesNoSavedInSession(t *testing.T) {
+ m := officialConfigModelTUI(t, "", nil)
+ result, _ := m.Update(escKey())
+ m2 := result.(modelTUIModel)
+ if !m2.cancelled {
+ t.Fatal("esc should cancel")
+ }
+ if m2.savedInSession {
+ t.Error("savedInSession should be false when no add/delete occurred")
+ }
+}
+
+func TestModelTUI_DeleteSetsSavedInSession(t *testing.T) {
+ dir := t.TempDir()
+ configPath := filepath.Join(dir, "config.json")
+ m := customConfigModelTUI(t, configPath, []string{"m1", "aaa"})
+ m.modelIdx = modelTUIIdxForName(t, m, "aaa")
+ m.deleteModelName = "aaa"
+ m.confirmingDeleteModel = true
+
+ result, _ := m.confirmDeleteCustomProviderModel()
+ m2 := asModelTUIModel(t, result)
+ if !m2.savedInSession {
+ t.Error("savedInSession should be true after delete")
+ }
+}
+
+func TestModelTUI_AddCustomModelRejectsDuplicate(t *testing.T) {
+ m := officialConfigModelTUI(t, "", []string{"dup-model"})
+ m.modelIdx = len(m.displayModels())
+ result, _ := m.Update(enterKey())
+ m2 := result.(modelTUIModel)
+ m2.modelInput.SetValue("dup-model")
+ result, _ = m2.Update(enterKey())
+ m3 := result.(modelTUIModel)
+ if m3.formError != "Already in list: dup-model" {
+ t.Errorf("formError = %q, want duplicate message", m3.formError)
+ }
+ if !m3.customModel {
+ t.Error("customModel should stay open after duplicate reject")
+ }
+}
+
+func TestModelTUI_Official_DeleteUserAddedModel(t *testing.T) {
+ dir := t.TempDir()
+ configPath := filepath.Join(dir, "config.json")
+ m := officialConfigModelTUI(t, configPath, []string{"my-custom-model"})
+ m.modelIdx = modelTUIIdxForName(t, m, "my-custom-model")
+
+ result, _ := m.Update(dKey())
+ m2 := result.(modelTUIModel)
+ if !m2.confirmingDeleteModel {
+ t.Fatal("pressing d on user-added model should set confirmingDeleteModel = true")
+ }
+
+ result, _ = m2.Update(yKey())
+ m3 := result.(modelTUIModel)
+ got := m3.existingCfg.Providers["dashscope"].Models
+ if len(got) != 1 || got[0] != "qwen3.7-max" {
+ t.Errorf("Models = %v, want [qwen3.7-max]", got)
+ }
+
+ diskCfg, err := loadOrCreateConfig(configPath)
+ if err != nil {
+ t.Fatalf("load disk config: %v", err)
+ }
+ if len(diskCfg.Providers["dashscope"].Models) != 1 {
+ t.Errorf("disk Models = %v, want [qwen3.7-max]", diskCfg.Providers["dashscope"].Models)
+ }
+}
+
+func TestModelTUI_Official_DeleteBuiltInModelIgnored(t *testing.T) {
+ m := officialConfigModelTUI(t, "", []string{"my-custom-model"})
+ m.modelIdx = modelTUIIdxForName(t, m, "qwen3.7-max")
+
+ result, _ := m.Update(dKey())
+ m2 := result.(modelTUIModel)
+ if m2.confirmingDeleteModel {
+ t.Error("pressing d on built-in model should not trigger delete confirmation")
+ }
+}
+
+func TestIsUserAddedOfficialModelName_RegistryDuplicateNotUserAdded(t *testing.T) {
+ preset, ok := llm.LookupProvider("dashscope")
+ if !ok {
+ t.Skip("dashscope provider not in registry")
+ }
+ cfg := &Config{
+ Providers: map[string]ProviderEntry{
+ "dashscope": {Models: []string{"qwen3.7-max", "my-custom-model"}},
+ },
+ }
+ if isUserAddedOfficialModelName("qwen3.7-max", "dashscope", preset.Models, cfg) {
+ t.Error("registry model should not be treated as user-added even when listed in config")
+ }
+ if !isUserAddedOfficialModelName("my-custom-model", "dashscope", preset.Models, cfg) {
+ t.Error("config-only model should be user-added")
+ }
+}
+
+func TestModelTUI_Official_RegistryModelNotDeletable(t *testing.T) {
+ m := officialConfigModelTUI(t, "", []string{"my-custom-model"})
+ m.modelIdx = modelTUIIdxForName(t, m, "qwen3.7-max")
+
+ if m.isUserAddedModel("qwen3.7-max") {
+ t.Error("qwen3.7-max should not be user-added when it is in the registry")
+ }
+
+ result, _ := m.Update(dKey())
+ m2 := result.(modelTUIModel)
+ if m2.confirmingDeleteModel {
+ t.Error("pressing d on registry model should not trigger delete confirmation")
+ }
+}
+
+func TestModelTUI_Official_DeleteOnCustomModelInputIgnored(t *testing.T) {
+ m := officialConfigModelTUI(t, "", []string{"my-custom-model"})
+ m.modelIdx = len(m.displayModels())
+
+ result, _ := m.Update(dKey())
+ m2 := result.(modelTUIModel)
+ if m2.confirmingDeleteModel {
+ t.Error("pressing d on Enter custom model name... should not trigger delete confirmation")
+ }
+}
+
+func TestModelTUI_CustomProvider_DeleteOnCustomModelInputIgnored(t *testing.T) {
+ m := customConfigModelTUI(t, "", []string{"m1", "aaa"})
+ m.modelIdx = len(m.displayModels())
+
+ result, _ := m.Update(dKey())
+ m2 := result.(modelTUIModel)
+ if m2.confirmingDeleteModel {
+ t.Error("pressing d on Enter custom model name... should not trigger delete confirmation")
+ }
+}
+
+func TestModelTUI_Official_UserAddedModelShowsDeleteHint(t *testing.T) {
+ m := officialConfigModelTUI(t, "", []string{"my-custom-model"})
+
+ m.modelIdx = modelTUIIdxForName(t, m, "qwen3.7-max")
+ got := stripANSI(m.View().Content)
+ if strings.Contains(got, "d Delete") {
+ t.Errorf("built-in model should not show d Delete hint; got:\n%s", got)
+ }
+
+ m.modelIdx = modelTUIIdxForName(t, m, "my-custom-model")
+ got = stripANSI(m.View().Content)
+ if !strings.Contains(got, "d Delete") {
+ t.Errorf("user-added model should show d Delete hint; got:\n%s", got)
+ }
+}
+
+func TestModelTUI_CustomProvider_ShowsDeleteHint(t *testing.T) {
+ m := customConfigModelTUI(t, "", []string{"m1", "aaa"})
+
+ m.modelIdx = len(m.displayModels())
+ got := stripANSI(m.View().Content)
+ if strings.Contains(got, "d Delete") {
+ t.Errorf("custom input row should not show d Delete hint; got:\n%s", got)
+ }
+
+ m.modelIdx = modelTUIIdxForName(t, m, "aaa")
+ got = stripANSI(m.View().Content)
+ if !strings.Contains(got, "d Delete") {
+ t.Errorf("custom model row should show d Delete hint; got:\n%s", got)
+ }
+}
+
+func TestModelTUI_CustomProvider_DeleteClearsCustomInput(t *testing.T) {
+ dir := t.TempDir()
+ configPath := filepath.Join(dir, "config.json")
+ m := customConfigModelTUI(t, configPath, []string{"m1", "aaa"})
+ m.modelIdx = modelTUIIdxForName(t, m, "aaa")
+ m.customModel = true
+ m.modelInput.SetValue("aaa")
+ m.deleteModelName = "aaa"
+ m.confirmingDeleteModel = true
+
+ result, _ := m.confirmDeleteCustomProviderModel()
+ m2 := asModelTUIModel(t, result)
+
+ if m2.customModel {
+ t.Error("customModel should be false after delete")
+ }
+ if m2.modelInput.Value() != "" {
+ t.Errorf("modelInput = %q, want empty after delete", m2.modelInput.Value())
+ }
+}
+
+func TestModelTUI_CustomProvider_DeleteModelViaDKey(t *testing.T) {
+ dir := t.TempDir()
+ configPath := filepath.Join(dir, "config.json")
+ m := customConfigModelTUI(t, configPath, []string{"m1", "aaa"})
+ m.modelIdx = modelTUIIdxForName(t, m, "aaa")
+
+ result, _ := m.Update(dKey())
+ m2 := result.(modelTUIModel)
+ if !m2.confirmingDeleteModel || m2.deleteModelName != "aaa" {
+ t.Fatalf("after d: confirming=%v deleteModelName=%q", m2.confirmingDeleteModel, m2.deleteModelName)
+ }
+
+ result, _ = m2.Update(yKey())
+ m3 := result.(modelTUIModel)
+ got := m3.existingCfg.CustomProviders["my-llm"].Models
+ if len(got) != 1 || got[0] != "m1" {
+ t.Errorf("Models = %v, want [m1]", got)
+ }
+}
+
+func TestModelTUI_CustomProvider_DeleteCancel(t *testing.T) {
+ m := customConfigModelTUI(t, "", []string{"m1", "aaa"})
+ m.modelIdx = modelTUIIdxForName(t, m, "aaa")
+
+ result, _ := m.Update(dKey())
+ m2 := result.(modelTUIModel)
+ result, _ = m2.Update(nKey())
+ m3 := result.(modelTUIModel)
+ if m3.confirmingDeleteModel {
+ t.Error("confirmingDeleteModel should be false after n")
+ }
+ got := m3.existingCfg.CustomProviders["my-llm"].Models
+ if len(got) != 2 {
+ t.Errorf("Models = %v, want unchanged", got)
+ }
+}
+
+func TestModelTUI_Official_DeleteCancel(t *testing.T) {
+ cancelKeys := []struct {
+ name string
+ key tea.KeyPressMsg
+ }{
+ {"n", nKey()},
+ {"esc", escKey()},
+ }
+ for _, tc := range cancelKeys {
+ t.Run(tc.name, func(t *testing.T) {
+ m := officialConfigModelTUI(t, "", []string{"my-custom-model"})
+ m.modelIdx = modelTUIIdxForName(t, m, "my-custom-model")
+
+ result, _ := m.Update(dKey())
+ m2 := result.(modelTUIModel)
+ if !m2.confirmingDeleteModel {
+ t.Fatal("expected confirmingDeleteModel after d")
+ }
+
+ result, _ = m2.Update(tc.key)
+ m3 := result.(modelTUIModel)
+ if m3.confirmingDeleteModel {
+ t.Error("confirmingDeleteModel should be false after cancel")
+ }
+ got := m3.existingCfg.Providers["dashscope"].Models
+ if len(got) != 2 || got[1] != "my-custom-model" {
+ t.Errorf("Models = %v, want model unchanged", got)
+ }
+ })
+ }
+}
+
+func TestModelTUI_Official_DeleteActiveUserModelClearsCfg(t *testing.T) {
+ dir := t.TempDir()
+ configPath := filepath.Join(dir, "config.json")
+ m := officialConfigModelTUI(t, configPath, []string{"my-custom-model"})
+ m.existingCfg.Model = "my-custom-model"
+ m.existingCfg.Providers["dashscope"] = ProviderEntry{
+ Model: "my-custom-model",
+ Models: []string{"qwen3.7-max", "my-custom-model"},
+ }
+ m.modelIdx = modelTUIIdxForName(t, m, "my-custom-model")
+
+ result, _ := m.Update(dKey())
+ m2 := result.(modelTUIModel)
+ result, _ = m2.Update(yKey())
+ m3 := result.(modelTUIModel)
+
+ if m3.existingCfg.Providers["dashscope"].Model != "" {
+ t.Errorf("entry.Model = %q, want empty", m3.existingCfg.Providers["dashscope"].Model)
+ }
+ if m3.existingCfg.Model != "" {
+ t.Errorf("cfg.Model = %q, want empty", m3.existingCfg.Model)
+ }
+}
+
// --- result() tests ---
func TestResult_OfficialTab(t *testing.T) {
@@ -1077,6 +1593,269 @@ func TestSyncSessionModelSelection_EmptyModel(t *testing.T) {
}
}
+func TestSyncSessionModelSelection_CrossOfficialProviderNoPersist(t *testing.T) {
+ dir := t.TempDir()
+ configPath := filepath.Join(dir, "config.json")
+ cfg := &Config{
+ Provider: "deepseek",
+ Model: "deepseek-v4-flash",
+ Providers: map[string]ProviderEntry{
+ "deepseek": {Model: "deepseek-v4-flash"},
+ },
+ }
+ m := newProviderTUI(cfg, configPath)
+ m.activeTab = tabOfficial
+ for i, p := range m.providers {
+ if p.Name == "baidu-qianfan" {
+ m.officialIdx = i
+ break
+ }
+ }
+ m.modelIdx = 0
+ for i, name := range m.models() {
+ if name == "glm-5" {
+ m.modelIdx = i
+ break
+ }
+ }
+
+ if err := m.syncSessionModelSelection(); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if m.savedInSession {
+ t.Error("savedInSession should be false when browsing a non-active provider")
+ }
+ if _, err := os.Stat(configPath); err == nil {
+ t.Fatal("config file should not be written for cross-provider navigation")
+ }
+ if got := cfg.Providers["baidu-qianfan"].Model; got != "" {
+ t.Errorf("in-memory baidu model = %q, want empty", got)
+ }
+}
+
+func TestSyncSessionModelSelection_ActiveOfficialProviderDefersPersist(t *testing.T) {
+ dir := t.TempDir()
+ configPath := filepath.Join(dir, "config.json")
+ cfg := &Config{
+ Provider: "deepseek",
+ Model: "deepseek-v4-flash",
+ Providers: map[string]ProviderEntry{
+ "deepseek": {Model: "deepseek-v4-flash"},
+ },
+ }
+ m := newProviderTUI(cfg, configPath)
+ m.activeTab = tabOfficial
+ for i, name := range m.models() {
+ if name == "deepseek-v4-pro" {
+ m.modelIdx = i
+ break
+ }
+ }
+
+ if err := m.syncSessionModelSelection(); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if m.savedInSession {
+ t.Error("savedInSession should be false before wizard confirm")
+ }
+ if got := m.sessionModelPick["deepseek"]; got != "deepseek-v4-pro" {
+ t.Errorf("sessionModelPick = %q, want deepseek-v4-pro", got)
+ }
+ if _, err := os.Stat(configPath); err == nil {
+ t.Fatal("config file should not be written before wizard confirm")
+ }
+ if cfg.Model != "deepseek-v4-flash" {
+ t.Errorf("cfg.Model = %q, want deepseek-v4-flash", cfg.Model)
+ }
+}
+
+func TestSyncSessionModelSelection_CrossCustomProviderNoPersist(t *testing.T) {
+ dir := t.TempDir()
+ configPath := filepath.Join(dir, "config.json")
+ cfg := &Config{
+ Provider: "stepfun",
+ Model: "step-3.5-flash",
+ CustomProviders: map[string]ProviderEntry{
+ "stepfun": {
+ URL: "https://api.stepfun.com/v1",
+ Model: "step-3.5-flash",
+ Models: []string{"step-3.5-flash", "step-3.7-flash"},
+ },
+ "other": {
+ URL: "https://example.com/v1",
+ Model: "step-3.7-flash",
+ Models: []string{"step-3.7-flash"},
+ },
+ },
+ }
+ m := newProviderTUI(cfg, configPath)
+ m.activeTab = tabCustom
+ for i, cp := range m.customProviders {
+ if cp.name == "other" {
+ m.customIdx = i
+ break
+ }
+ }
+ m.modelIdx = 0
+
+ if err := m.syncSessionModelSelection(); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if m.savedInSession {
+ t.Error("savedInSession should be false when browsing a non-active custom provider")
+ }
+ if _, err := os.Stat(configPath); err == nil {
+ t.Fatal("config file should not be written for cross-provider navigation")
+ }
+}
+
+func TestSyncSessionModelSelection_RecordsSessionPickForInactiveOfficialProvider(t *testing.T) {
+ dir := t.TempDir()
+ configPath := filepath.Join(dir, "config.json")
+ cfg := &Config{
+ Provider: "deepseek",
+ Model: "deepseek-v4-flash",
+ Providers: map[string]ProviderEntry{
+ "deepseek": {Model: "deepseek-v4-flash"},
+ },
+ }
+ m := newProviderTUI(cfg, configPath)
+ m.activeTab = tabOfficial
+ for i, p := range m.providers {
+ if p.Name == "baidu-qianfan" {
+ m.officialIdx = i
+ break
+ }
+ }
+ for i, name := range m.models() {
+ if name == "glm-5" {
+ m.modelIdx = i
+ break
+ }
+ }
+
+ if err := m.syncSessionModelSelection(); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if m.savedInSession {
+ t.Error("savedInSession should be false for inactive provider")
+ }
+ if got := m.sessionModelPick["baidu-qianfan"]; got != "glm-5" {
+ t.Errorf("sessionModelPick = %q, want glm-5", got)
+ }
+ if _, err := os.Stat(configPath); err == nil {
+ t.Fatal("config file should not be written for inactive provider")
+ }
+}
+
+func TestProviderTUI_ResultUsesSessionModelPickWhenSelectionEmpty(t *testing.T) {
+ cfg := &Config{
+ Provider: "deepseek",
+ Model: "deepseek-v4-flash",
+ Providers: map[string]ProviderEntry{
+ "deepseek": {Model: "deepseek-v4-flash"},
+ },
+ }
+ m := newProviderTUI(cfg, "")
+ m.activeTab = tabOfficial
+ for i, p := range m.providers {
+ if p.Name == "baidu-qianfan" {
+ m.officialIdx = i
+ break
+ }
+ }
+ m.sessionModelPick = map[string]string{"baidu-qianfan": "glm-5"}
+ m.modelIdx = 9999 // force selectedModelFromState() empty
+
+ r := m.result()
+ if r.model != "glm-5" {
+ t.Errorf("result().model = %q, want glm-5", r.model)
+ }
+ if got := r.resolvedModel(); got != "glm-5" {
+ t.Errorf("resolvedModel() = %q, want glm-5", got)
+ }
+}
+
+func TestApiKeyStepCanConfirm_OfficialEmptyWithoutEnv(t *testing.T) {
+ t.Setenv("DEEPSEEK_API_KEY", "")
+ cfg := &Config{
+ Provider: "deepseek",
+ Model: "deepseek-v4-flash",
+ Providers: map[string]ProviderEntry{
+ "deepseek": {Model: "deepseek-v4-flash"},
+ },
+ }
+ m := newProviderTUI(cfg, "")
+ m.activeTab = tabOfficial
+ m.step = stepAPIKey
+
+ ok, errMsg := m.apiKeyStepCanConfirm()
+ if ok {
+ t.Fatal("expected confirmation to be blocked")
+ }
+ if errMsg != "API key is required (or set $DEEPSEEK_API_KEY)" {
+ t.Errorf("errMsg = %q", errMsg)
+ }
+}
+
+func TestApiKeyStepCanConfirm_OfficialEmptyWithEnv(t *testing.T) {
+ t.Setenv("DEEPSEEK_API_KEY", "sk-from-env")
+ cfg := &Config{
+ Provider: "deepseek",
+ Model: "deepseek-v4-flash",
+ Providers: map[string]ProviderEntry{
+ "deepseek": {Model: "deepseek-v4-flash"},
+ },
+ }
+ m := newProviderTUI(cfg, "")
+ m.activeTab = tabOfficial
+ m.step = stepAPIKey
+
+ ok, errMsg := m.apiKeyStepCanConfirm()
+ if !ok {
+ t.Fatalf("expected confirmation allowed, errMsg = %q", errMsg)
+ }
+}
+
+func TestApiKeyStepCanConfirm_CustomEmpty(t *testing.T) {
+ cfg := &Config{
+ Provider: "stepfun",
+ CustomProviders: map[string]ProviderEntry{
+ "stepfun": {APIKey: ""},
+ },
+ }
+ m := newProviderTUI(cfg, "")
+ m.activeTab = tabCustom
+ m.customIdx = 0
+ m.step = stepAPIKey
+
+ ok, errMsg := m.apiKeyStepCanConfirm()
+ if ok {
+ t.Fatal("expected confirmation to be blocked")
+ }
+ if errMsg != "API key is required" {
+ t.Errorf("errMsg = %q", errMsg)
+ }
+}
+
+func TestApiKeyStepCanConfirm_MaskedSavedKey(t *testing.T) {
+ cfg := &Config{
+ Provider: "deepseek",
+ Providers: map[string]ProviderEntry{
+ "deepseek": {APIKey: "keep-me"},
+ },
+ }
+ m := newProviderTUI(cfg, "")
+ m.activeTab = tabOfficial
+ m.step = stepAPIKey
+ m.loadExistingAPIKey()
+
+ ok, errMsg := m.apiKeyStepCanConfirm()
+ if !ok {
+ t.Fatalf("expected confirmation allowed, errMsg = %q", errMsg)
+ }
+}
+
// --- viewCustomProviderForm field steps ---
func TestProviderTUIView_CustomForm_AllSteps(t *testing.T) {
@@ -1146,15 +1925,23 @@ func TestProviderTUIView_ManualTab_WithExistingConfig(t *testing.T) {
func TestProviderTUIView_StepModel_CustomTabDeleteHelp(t *testing.T) {
cfg := &Config{
CustomProviders: map[string]ProviderEntry{
- "cp": {URL: "http://cp", Models: []string{"m1"}},
+ "cp": {URL: "http://cp", Models: []string{"m1", "m2"}},
},
}
m := newProviderTUI(cfg, "")
m.step = stepModel
m.activeTab = tabCustom
m.customIdx = 0
- v := m.View()
- if !strings.Contains(v.Content, "Delete") {
- t.Errorf("expected delete help for custom tab model view")
+
+ m.modelIdx = len(m.models())
+ got := stripANSI(m.View().Content)
+ if strings.Contains(got, "d Delete") {
+ t.Errorf("custom input row should not show d Delete hint; got:\n%s", got)
+ }
+
+ m.modelIdx = 0
+ got = stripANSI(m.View().Content)
+ if !strings.Contains(got, "d Delete") {
+ t.Errorf("custom model row should show d Delete hint; got:\n%s", got)
}
}
diff --git a/cmd/opencodereview/provider_tui_test.go b/cmd/opencodereview/provider_tui_test.go
index 42e9d41..624bc50 100644
--- a/cmd/opencodereview/provider_tui_test.go
+++ b/cmd/opencodereview/provider_tui_test.go
@@ -8,6 +8,8 @@ import (
"testing"
tea "charm.land/bubbletea/v2"
+
+ "github.com/open-code-review/open-code-review/internal/llm"
)
func escKey() tea.KeyPressMsg {
@@ -443,11 +445,76 @@ func TestProviderTUI_ManualFormRequiresTokenOnFirstSetup(t *testing.T) {
m.manualTokenInput.SetValue("")
m.manualTokenInput.Focus()
- result, _ := m.Update(enterKey())
+ result, cmd := m.Update(enterKey())
m2 := result.(providerTUIModel)
if m2.manualStep != manualStepAuthToken {
t.Errorf("should stay on auth token step, got %d", m2.manualStep)
}
+ if m2.formError != manualAuthTokenRequiredError {
+ t.Errorf("formError = %q, want %q", m2.formError, manualAuthTokenRequiredError)
+ }
+ if cmd != nil {
+ t.Error("Enter with empty token should not quit")
+ }
+}
+
+func TestProviderTUI_ManualFormRejectsWhitespaceOnlyToken(t *testing.T) {
+ m := newProviderTUI(&Config{}, "")
+ m.inManualForm = true
+ m.manualStep = manualStepAuthToken
+ m.manualTokenInput.SetValue(" ")
+ m.manualTokenInput.Focus()
+
+ result, cmd := m.Update(enterKey())
+ m2 := result.(providerTUIModel)
+ if m2.manualStep != manualStepAuthToken {
+ t.Errorf("should stay on auth token step, got %d", m2.manualStep)
+ }
+ if m2.formError != manualAuthTokenRequiredError {
+ t.Errorf("formError = %q, want %q", m2.formError, manualAuthTokenRequiredError)
+ }
+ if cmd != nil {
+ t.Error("Enter with whitespace-only token should not quit")
+ }
+}
+
+func TestProviderTUI_SessionModelPickSurvivesOfficialProviderSwitch(t *testing.T) {
+ cfg := &Config{
+ Provider: "deepseek",
+ Model: "deepseek-v4-flash",
+ Providers: map[string]ProviderEntry{
+ "deepseek": {Model: "deepseek-v4-flash"},
+ },
+ }
+ m := newProviderTUI(cfg, "")
+ m.activeTab = tabOfficial
+ for i, p := range m.providers {
+ if p.Name == "baidu-qianfan" {
+ m.officialIdx = i
+ break
+ }
+ }
+
+ result, _ := m.Update(enterKey())
+ m2 := result.(providerTUIModel)
+ m2.modelIdx = modelIdxForName(t, m2, "glm-5")
+
+ result, _ = m2.Update(enterKey())
+ m3 := result.(providerTUIModel)
+ if got := m3.sessionModelPick["baidu-qianfan"]; got != "glm-5" {
+ t.Errorf("sessionModelPick = %q, want glm-5", got)
+ }
+
+ result, _ = m3.Update(escKey())
+ m4 := result.(providerTUIModel)
+ result, _ = m4.Update(escKey())
+ m5 := result.(providerTUIModel)
+
+ result, _ = m5.Update(enterKey())
+ m6 := result.(providerTUIModel)
+ if got := m6.models()[m6.modelIdx]; got != "glm-5" {
+ t.Errorf("model cursor = %q, want glm-5", got)
+ }
}
// --- Custom tab tests ---
@@ -681,6 +748,180 @@ func TestProviderTUI_EditCustomProviderSaveRejectsDuplicateRename(t *testing.T)
}
}
+func TestApplyEditCustomProviderSave_ClearsAPIKeyWhenEditedEmpty(t *testing.T) {
+ dir := t.TempDir()
+ configPath := filepath.Join(dir, "config.json")
+ cfg := &Config{
+ CustomProviders: map[string]ProviderEntry{
+ "aaa": {
+ URL: "https://example.com/v1",
+ Protocol: "anthropic",
+ APIKey: "old-saved-key",
+ Model: "test",
+ Models: []string{"test"},
+ },
+ },
+ }
+ m := newProviderTUI(cfg, configPath)
+ m.activeTab = tabCustom
+ m.editingCustom = true
+ m.editTargetName = "aaa"
+ m.cpProtocolIdx = 0
+ m.cpNameInput.SetValue("aaa")
+ m.cpURLInput.SetValue("https://example.com/v1")
+ m.beginAPIKeyReplace()
+
+ if err := m.applyEditCustomProviderSave(); err != nil {
+ t.Fatalf("applyEditCustomProviderSave: %v", err)
+ }
+ if got := cfg.CustomProviders["aaa"].APIKey; got != "" {
+ t.Errorf("APIKey = %q, want empty", got)
+ }
+ diskCfg, err := loadOrCreateConfig(configPath)
+ if err != nil {
+ t.Fatalf("load config: %v", err)
+ }
+ if got := diskCfg.CustomProviders["aaa"].APIKey; got != "" {
+ t.Errorf("persisted APIKey = %q, want empty", got)
+ }
+}
+
+func TestApplyEditCustomProviderSave_PreservesAPIKeyWhenMasked(t *testing.T) {
+ dir := t.TempDir()
+ configPath := filepath.Join(dir, "config.json")
+ cfg := &Config{
+ CustomProviders: map[string]ProviderEntry{
+ "aaa": {
+ URL: "https://example.com/v1",
+ Protocol: "anthropic",
+ APIKey: "keep-me",
+ },
+ },
+ }
+ m := newProviderTUI(cfg, configPath)
+ m.activeTab = tabCustom
+ m.customIdx = 0
+ m.enterEditCustomProvider()
+
+ if err := m.applyEditCustomProviderSave(); err != nil {
+ t.Fatalf("applyEditCustomProviderSave: %v", err)
+ }
+ if got := cfg.CustomProviders["aaa"].APIKey; got != "keep-me" {
+ t.Errorf("APIKey = %q, want keep-me", got)
+ }
+}
+
+func TestProviderTUI_EditCustomClearKey_NoMaskedOnStepAPIKey(t *testing.T) {
+ dir := t.TempDir()
+ configPath := filepath.Join(dir, "config.json")
+ cfg := &Config{
+ CustomProviders: map[string]ProviderEntry{
+ "aaa": {
+ URL: "https://example.com/v1",
+ Protocol: "anthropic",
+ APIKey: "old-saved-key",
+ Model: "test",
+ Models: []string{"test", "aaa"},
+ },
+ },
+ }
+ m := newProviderTUI(cfg, configPath)
+ m.activeTab = tabCustom
+ for i, cp := range m.customProviders {
+ if cp.name == "aaa" {
+ m.customIdx = i
+ break
+ }
+ }
+ m.enterEditCustomProvider()
+ m.cpStep = cpStepAPIKey
+ m.beginAPIKeyReplace()
+ m.cpStep = cpStepAuthHeader
+ m.cpAuthInput.SetValue("")
+ m.cpAuthInput.Focus()
+
+ result, _ := m.Update(enterKey())
+ m2 := result.(providerTUIModel)
+ if m2.step != stepModel {
+ t.Fatalf("step = %d, want stepModel", m2.step)
+ }
+ if got := m2.customProviders[m2.customIdx].entry.APIKey; got != "" {
+ t.Errorf("saved APIKey = %q, want empty", got)
+ }
+
+ m2.modelIdx = modelIdxForName(t, m2, "test")
+ result, _ = m2.Update(enterKey())
+ m3 := result.(providerTUIModel)
+ if m3.step != stepAPIKey {
+ t.Fatalf("step = %d, want stepAPIKey", m3.step)
+ }
+ if m3.apiKeyMasked {
+ t.Error("apiKeyMasked should be false after clearing key in edit")
+ }
+ got := stripANSI(m3.View().Content)
+ if strings.Contains(got, "Type or paste to replace the saved key") {
+ t.Errorf("view should not show replace hint; got:\n%s", got)
+ }
+}
+
+func TestProviderTUI_ReenterEditAfterClearKey_ShowsEmpty(t *testing.T) {
+ dir := t.TempDir()
+ configPath := filepath.Join(dir, "config.json")
+ cfg := &Config{
+ CustomProviders: map[string]ProviderEntry{
+ "aaa": {
+ URL: "https://example.com/v1",
+ Protocol: "anthropic",
+ APIKey: "old-saved-key",
+ },
+ },
+ }
+ m := newProviderTUI(cfg, configPath)
+ m.activeTab = tabCustom
+ m.customIdx = 0
+ m.editingCustom = true
+ m.editTargetName = "aaa"
+ m.cpProtocolIdx = 0
+ m.cpNameInput.SetValue("aaa")
+ m.cpURLInput.SetValue("https://example.com/v1")
+ m.beginAPIKeyReplace()
+ if err := m.applyEditCustomProviderSave(); err != nil {
+ t.Fatalf("applyEditCustomProviderSave: %v", err)
+ }
+
+ m.enterEditCustomProvider()
+ if m.apiKeyMasked {
+ t.Error("apiKeyMasked should be false when key was cleared")
+ }
+ if got := m.apiKeyInput.Value(); got != "" {
+ t.Errorf("apiKeyInput = %q, want empty", got)
+ }
+}
+
+func TestCustomAPIKeyForSave(t *testing.T) {
+ m := providerTUIModel{
+ apiKeyMasked: true,
+ apiKeyOriginal: "keep-me",
+ }
+ key, edited := m.customAPIKeyForSave()
+ if edited {
+ t.Fatal("masked key should not count as edited")
+ }
+ if key != "keep-me" {
+ t.Errorf("key = %q, want keep-me", key)
+ }
+
+ m.apiKeyMasked = false
+ m.apiKeyInput.SetValue(" ")
+ key, edited = m.customAPIKeyForSave()
+ if !edited {
+ t.Fatal("cleared field should count as edited")
+ }
+ if key != "" {
+ t.Errorf("key = %q, want empty", key)
+ }
+}
+
func TestProviderTUI_CustomFormCreateReturnsToModelList(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
@@ -1112,6 +1353,478 @@ func TestProviderTUI_CustomModelInput_RejectsDuplicate(t *testing.T) {
}
}
+func TestProviderTUI_OfficialTab_CustomModelInput_PersistsName(t *testing.T) {
+ dir := t.TempDir()
+ configPath := filepath.Join(dir, "config.json")
+ cfg := &Config{
+ Provider: "dashscope",
+ Model: "qwen3.7-max",
+ Providers: map[string]ProviderEntry{
+ "dashscope": {
+ Model: "qwen3.7-max",
+ Models: []string{"qwen3.7-max"},
+ },
+ },
+ }
+ m := newProviderTUI(cfg, configPath)
+ m.activeTab = tabOfficial
+
+ // Land the cursor on the official provider we configured.
+ for i, p := range m.providers {
+ if p.Name == "dashscope" {
+ m.officialIdx = i
+ break
+ }
+ }
+ m.step = stepModel
+ m.modelIdx = len(m.models()) // "Enter custom model name..."
+ m.customModel = true
+ m.modelInput.SetValue("my-custom-model")
+ m.modelInput.Focus()
+
+ result, _ := m.Update(enterKey())
+ m2 := result.(providerTUIModel)
+
+ if m2.customModel {
+ t.Error("customModel should be cleared after Enter")
+ }
+ if m2.formError != "" {
+ t.Errorf("formError = %q, want empty", m2.formError)
+ }
+ if !m2.savedInSession {
+ t.Error("savedInSession should be true after successful add")
+ }
+ got := m2.existingCfg.Providers["dashscope"].Models
+ want := []string{"qwen3.7-max", "my-custom-model"}
+ if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
+ t.Errorf("official Models = %v, want %v", got, want)
+ }
+
+ diskCfg, err := loadOrCreateConfig(configPath)
+ if err != nil {
+ t.Fatalf("load disk config: %v", err)
+ }
+ diskModels := diskCfg.Providers["dashscope"].Models
+ if len(diskModels) != 2 || diskModels[1] != "my-custom-model" {
+ t.Errorf("disk Models = %v, want [qwen3.7-max my-custom-model]", diskModels)
+ }
+}
+
+func TestProviderTUI_OfficialTab_CustomModelInput_RejectsDuplicate(t *testing.T) {
+ dir := t.TempDir()
+ configPath := filepath.Join(dir, "config.json")
+ cfg := &Config{
+ Provider: "dashscope",
+ Model: "qwen3.7-max",
+ Providers: map[string]ProviderEntry{
+ "dashscope": {
+ Model: "qwen3.7-max",
+ Models: []string{"qwen3.7-max"},
+ },
+ },
+ }
+ m := newProviderTUI(cfg, configPath)
+ m.activeTab = tabOfficial
+ for i, p := range m.providers {
+ if p.Name == "dashscope" {
+ m.officialIdx = i
+ break
+ }
+ }
+ m.step = stepModel
+ m.modelIdx = len(m.models())
+ m.customModel = true
+ m.modelInput.SetValue("qwen3.7-max")
+ m.modelInput.Focus()
+
+ result, _ := m.Update(enterKey())
+ m2 := result.(providerTUIModel)
+
+ if !m2.customModel {
+ t.Error("customModel should stay true after duplicate reject")
+ }
+ if m2.formError != "Already in list: qwen3.7-max" {
+ t.Errorf("formError = %q, want %q", m2.formError, "Already in list: qwen3.7-max")
+ }
+ if _, err := os.Stat(configPath); err == nil {
+ t.Errorf("disk file should not exist; duplicate did not persist")
+ }
+}
+
+func officialDashscopeModelTUI(t *testing.T, configPath string, extraModels []string) providerTUIModel {
+ t.Helper()
+ models := []string{"qwen3.7-max"}
+ models = append(models, extraModels...)
+ cfg := &Config{
+ Provider: "dashscope",
+ Model: "qwen3.7-max",
+ Providers: map[string]ProviderEntry{
+ "dashscope": {
+ Model: "qwen3.7-max",
+ Models: models,
+ },
+ },
+ }
+ m := newProviderTUI(cfg, configPath)
+ m.activeTab = tabOfficial
+ for i, p := range m.providers {
+ if p.Name == "dashscope" {
+ m.officialIdx = i
+ break
+ }
+ }
+ m.step = stepModel
+ return m
+}
+
+func customStepfunModelTUI(t *testing.T, configPath string, models []string) providerTUIModel {
+ t.Helper()
+ if len(models) == 0 {
+ models = []string{"step-3.5-flash"}
+ }
+ cfg := &Config{
+ Provider: "stepfun",
+ Model: models[0],
+ CustomProviders: map[string]ProviderEntry{
+ "stepfun": {
+ URL: "https://api.stepfun.com/v1",
+ Protocol: "openai",
+ Model: models[0],
+ Models: models,
+ },
+ },
+ }
+ m := newProviderTUI(cfg, configPath)
+ m.activeTab = tabCustom
+ for i, cp := range m.customProviders {
+ if cp.name == "stepfun" {
+ m.customIdx = i
+ break
+ }
+ }
+ m.step = stepModel
+ return m
+}
+
+func modelIdxForName(t *testing.T, m providerTUIModel, name string) int {
+ t.Helper()
+ for i, model := range m.models() {
+ if model == name {
+ return i
+ }
+ }
+ t.Fatalf("model %q not found in %v", name, m.models())
+ return -1
+}
+
+func TestProviderTUI_OfficialTab_DeleteUserAddedModel(t *testing.T) {
+ dir := t.TempDir()
+ configPath := filepath.Join(dir, "config.json")
+ m := officialDashscopeModelTUI(t, configPath, []string{"my-custom-model"})
+ m.modelIdx = modelIdxForName(t, m, "my-custom-model")
+
+ result, _ := m.Update(dKey())
+ m2 := result.(providerTUIModel)
+ if !m2.confirmingDeleteModel {
+ t.Fatal("pressing d on user-added model should set confirmingDeleteModel = true")
+ }
+ if m2.deleteModelName != "my-custom-model" {
+ t.Errorf("deleteModelName = %q, want my-custom-model", m2.deleteModelName)
+ }
+
+ result, _ = m2.Update(yKey())
+ m3 := result.(providerTUIModel)
+ if m3.confirmingDeleteModel {
+ t.Error("confirmingDeleteModel should be false after y")
+ }
+ got := m3.existingCfg.Providers["dashscope"].Models
+ if len(got) != 1 || got[0] != "qwen3.7-max" {
+ t.Errorf("Models = %v, want [qwen3.7-max]", got)
+ }
+ if !m3.savedInSession {
+ t.Error("savedInSession should be true after delete")
+ }
+
+ diskCfg, err := loadOrCreateConfig(configPath)
+ if err != nil {
+ t.Fatalf("load disk config: %v", err)
+ }
+ if len(diskCfg.Providers["dashscope"].Models) != 1 {
+ t.Errorf("disk Models = %v, want [qwen3.7-max]", diskCfg.Providers["dashscope"].Models)
+ }
+}
+
+func TestProviderTUI_OfficialTab_DeleteBuiltInModelIgnored(t *testing.T) {
+ m := officialDashscopeModelTUI(t, "", []string{"my-custom-model"})
+ m.modelIdx = modelIdxForName(t, m, "qwen3.7-max")
+
+ result, _ := m.Update(dKey())
+ m2 := result.(providerTUIModel)
+ if m2.confirmingDeleteModel {
+ t.Error("pressing d on built-in model should not trigger delete confirmation")
+ }
+}
+
+func TestProviderTUI_OfficialTab_RegistryModelNotDeletable(t *testing.T) {
+ m := officialDashscopeModelTUI(t, "", []string{"my-custom-model"})
+ m.modelIdx = modelIdxForName(t, m, "qwen3.7-max")
+
+ if m.isUserAddedOfficialModel("qwen3.7-max") {
+ t.Error("qwen3.7-max should not be user-added when it is in the registry")
+ }
+
+ result, _ := m.Update(dKey())
+ m2 := result.(providerTUIModel)
+ if m2.confirmingDeleteModel {
+ t.Error("pressing d on registry model should not trigger delete confirmation")
+ }
+}
+
+func TestProviderTUI_OfficialTab_DeleteOnCustomModelInputIgnored(t *testing.T) {
+ m := officialDashscopeModelTUI(t, "", []string{"my-custom-model"})
+ m.modelIdx = len(m.models())
+
+ result, _ := m.Update(dKey())
+ m2 := result.(providerTUIModel)
+ if m2.confirmingDeleteModel {
+ t.Error("pressing d on Enter custom model name... should not trigger delete confirmation")
+ }
+}
+
+func TestProviderTUI_CustomTab_DeleteOnCustomModelInputIgnored(t *testing.T) {
+ m := customStepfunModelTUI(t, "", []string{"step-3.5-flash", "aaa"})
+ m.modelIdx = len(m.models())
+
+ result, _ := m.Update(dKey())
+ m2 := result.(providerTUIModel)
+ if m2.confirmingDeleteModel {
+ t.Error("pressing d on Enter custom model name... should not trigger delete confirmation")
+ }
+}
+
+func TestProviderTUI_CustomTab_ModelShowsDeleteHint(t *testing.T) {
+ m := customStepfunModelTUI(t, "", []string{"step-3.5-flash", "aaa"})
+
+ m.modelIdx = len(m.models())
+ got := stripANSI(m.View().Content)
+ if strings.Contains(got, "d Delete") {
+ t.Errorf("custom input row should not show d Delete hint; got:\n%s", got)
+ }
+
+ m.modelIdx = modelIdxForName(t, m, "aaa")
+ got = stripANSI(m.View().Content)
+ if !strings.Contains(got, "d Delete") {
+ t.Errorf("custom model row should show d Delete hint; got:\n%s", got)
+ }
+}
+
+func TestProviderTUI_OfficialTab_UserAddedModelShowsDeleteHint(t *testing.T) {
+ m := officialDashscopeModelTUI(t, "", []string{"my-custom-model"})
+
+ m.modelIdx = modelIdxForName(t, m, "qwen3.7-max")
+ got := stripANSI(m.View().Content)
+ if strings.Contains(got, "d Delete") {
+ t.Errorf("built-in model should not show d Delete hint; got:\n%s", got)
+ }
+
+ m.modelIdx = modelIdxForName(t, m, "my-custom-model")
+ got = stripANSI(m.View().Content)
+ if !strings.Contains(got, "d Delete") {
+ t.Errorf("user-added model should show d Delete hint; got:\n%s", got)
+ }
+}
+
+func TestProviderTUI_OfficialTab_DeleteModelPreservesActiveModel(t *testing.T) {
+ dir := t.TempDir()
+ configPath := filepath.Join(dir, "config.json")
+ m := officialDashscopeModelTUI(t, configPath, []string{"my-custom-model"})
+ m.existingCfg.Model = "qwen3.7-max"
+ m.existingCfg.Providers["dashscope"] = ProviderEntry{
+ Model: "qwen3.7-max",
+ Models: []string{"qwen3.7-max", "my-custom-model"},
+ }
+ m.modelIdx = modelIdxForName(t, m, "my-custom-model")
+
+ result, _ := m.Update(dKey())
+ m2 := result.(providerTUIModel)
+ result, _ = m2.Update(yKey())
+ m3 := result.(providerTUIModel)
+
+ if m3.existingCfg.Providers["dashscope"].Model != "qwen3.7-max" {
+ t.Errorf("entry.Model = %q, want qwen3.7-max", m3.existingCfg.Providers["dashscope"].Model)
+ }
+ if m3.existingCfg.Model != "qwen3.7-max" {
+ t.Errorf("cfg.Model = %q, want qwen3.7-max", m3.existingCfg.Model)
+ }
+}
+
+func TestProviderTUI_OfficialTab_DeleteUserAddedModelCancel(t *testing.T) {
+ cancelKeys := []struct {
+ name string
+ key tea.KeyPressMsg
+ }{
+ {"n", nKey()},
+ {"esc", escKey()},
+ }
+ for _, tc := range cancelKeys {
+ t.Run(tc.name, func(t *testing.T) {
+ m := officialDashscopeModelTUI(t, "", []string{"my-custom-model"})
+ m.modelIdx = modelIdxForName(t, m, "my-custom-model")
+
+ result, _ := m.Update(dKey())
+ m2 := result.(providerTUIModel)
+ if !m2.confirmingDeleteModel {
+ t.Fatal("expected confirmingDeleteModel after d")
+ }
+
+ result, _ = m2.Update(tc.key)
+ m3 := result.(providerTUIModel)
+ if m3.confirmingDeleteModel {
+ t.Error("confirmingDeleteModel should be false after cancel")
+ }
+ got := m3.existingCfg.Providers["dashscope"].Models
+ if len(got) != 2 || got[1] != "my-custom-model" {
+ t.Errorf("Models = %v, want model unchanged", got)
+ }
+ })
+ }
+}
+
+func TestProviderTUI_OfficialTab_DeleteActiveUserModelClearsCfg(t *testing.T) {
+ dir := t.TempDir()
+ configPath := filepath.Join(dir, "config.json")
+ m := officialDashscopeModelTUI(t, configPath, []string{"my-custom-model"})
+ m.existingCfg.Model = "my-custom-model"
+ m.existingCfg.Providers["dashscope"] = ProviderEntry{
+ Model: "my-custom-model",
+ Models: []string{"qwen3.7-max", "my-custom-model"},
+ }
+ m.modelIdx = modelIdxForName(t, m, "my-custom-model")
+
+ result, _ := m.Update(dKey())
+ m2 := result.(providerTUIModel)
+ result, _ = m2.Update(yKey())
+ m3 := result.(providerTUIModel)
+
+ if m3.existingCfg.Providers["dashscope"].Model != "" {
+ t.Errorf("entry.Model = %q, want empty", m3.existingCfg.Providers["dashscope"].Model)
+ }
+ if m3.existingCfg.Model != "" {
+ t.Errorf("cfg.Model = %q, want empty", m3.existingCfg.Model)
+ }
+}
+
+func TestOfficialSelectedModelUsesRegistryNotStaleMerge(t *testing.T) {
+ registry := []string{"built-in"}
+ deletedCustom := "my-custom"
+ staleMerged := mergeModelLists(registry, []string{deletedCustom})
+
+ if llm.ModelListContains(registry, deletedCustom) {
+ t.Fatal("test setup: custom name should not be in registry")
+ }
+ if !llm.ModelListContains(staleMerged, deletedCustom) {
+ t.Fatal("test setup: stale merge should still list deleted custom name")
+ }
+ // runConfigModel must use registryModels, not staleMerged, or re-selected custom
+ // names would skip ensureModelInList when still present in the pre-TUI merge.
+ if llm.ModelListContains(staleMerged, deletedCustom) && llm.ModelListContains(registry, deletedCustom) {
+ t.Error("would skip persisting re-selected custom model")
+ }
+ if llm.ModelListContains(registry, deletedCustom) {
+ t.Error("registry-only check must not treat custom model as built-in")
+ }
+}
+
+func TestProviderTUI_CustomTab_DeleteModelSkipsSavedInSessionWhenNoModelDeleted(t *testing.T) {
+ m := customStepfunModelTUI(t, "", []string{"step-3.5-flash", "aaa"})
+ m.modelIdx = len(m.models()) // out of range — no model row selected
+ m.deleteModelName = "aaa"
+ m.confirmingDeleteModel = true
+
+ result, _ := m.confirmDeleteCustomModel()
+ m2 := result.(providerTUIModel)
+ if m2.savedInSession {
+ t.Error("savedInSession should be false when modelIdx is out of range")
+ }
+}
+
+func TestProviderTUI_CustomTab_DeleteModelViaDKey(t *testing.T) {
+ dir := t.TempDir()
+ configPath := filepath.Join(dir, "config.json")
+ cfg := &Config{
+ Provider: "stepfun",
+ Model: "step-3.5-flash",
+ CustomProviders: map[string]ProviderEntry{
+ "stepfun": {
+ URL: "https://api.stepfun.com/v1",
+ Protocol: "openai",
+ Model: "step-3.5-flash",
+ Models: []string{"step-3.5-flash", "aaa"},
+ },
+ },
+ }
+ m := newProviderTUI(cfg, configPath)
+ m.activeTab = tabCustom
+ for i, cp := range m.customProviders {
+ if cp.name == "stepfun" {
+ m.customIdx = i
+ break
+ }
+ }
+ m.step = stepModel
+ m.modelIdx = modelIdxForName(t, m, "aaa")
+
+ result, _ := m.Update(dKey())
+ m2 := result.(providerTUIModel)
+ if !m2.confirmingDeleteModel || m2.deleteModelName != "aaa" {
+ t.Fatalf("after d: confirming=%v deleteModelName=%q", m2.confirmingDeleteModel, m2.deleteModelName)
+ }
+
+ result, _ = m2.Update(yKey())
+ m3 := result.(providerTUIModel)
+ got := m3.existingCfg.CustomProviders["stepfun"].Models
+ if len(got) != 1 || got[0] != "step-3.5-flash" {
+ t.Errorf("Models = %v, want [step-3.5-flash]", got)
+ }
+
+ diskCfg, err := loadOrCreateConfig(configPath)
+ if err != nil {
+ t.Fatalf("load disk config: %v", err)
+ }
+ if len(diskCfg.CustomProviders["stepfun"].Models) != 1 {
+ t.Errorf("disk Models = %v, want [step-3.5-flash]", diskCfg.CustomProviders["stepfun"].Models)
+ }
+}
+
+func TestProviderTUI_PersistCustomModelName_SaveFailureRollsBack(t *testing.T) {
+ blockPath := filepath.Join(t.TempDir(), "blocked")
+ if err := os.Mkdir(blockPath, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ m := officialDashscopeModelTUI(t, blockPath, nil)
+ before := append([]string(nil), m.existingCfg.Providers["dashscope"].Models...)
+ m.modelIdx = len(m.models())
+ m.customModel = true
+ m.modelInput.SetValue("failed-model")
+ m.modelInput.Focus()
+
+ result, _ := m.Update(enterKey())
+ m2 := result.(providerTUIModel)
+ if m2.formError == "" {
+ t.Fatal("expected formError on save failure")
+ }
+ if !strings.Contains(m2.formError, "failed to save") {
+ t.Errorf("formError = %q, want save failure message", m2.formError)
+ }
+ got := m2.existingCfg.Providers["dashscope"].Models
+ if len(got) != len(before) {
+ t.Errorf("Models = %v, want unchanged %v", got, before)
+ }
+ if m2.savedInSession {
+ t.Error("savedInSession should be false after failed persist")
+ }
+}
+
func TestProviderTUI_ManualFormPassesKToAuthHeaderInput(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
@@ -1167,6 +1880,692 @@ func TestProviderTUI_CustomFormPassesKToAuthHeaderInput(t *testing.T) {
}
}
+func TestProviderTUI_ViewAPIKey_MaskedShowsReplaceHintAndLastFour(t *testing.T) {
+ cfg := &Config{
+ Provider: "dashscope",
+ Model: "qwen3.7-max",
+ Providers: map[string]ProviderEntry{
+ "dashscope": {APIKey: "sk-secret-1234567890abcd"},
+ },
+ }
+ m := newProviderTUI(cfg, "")
+ m.activeTab = tabOfficial
+ for i, p := range m.providers {
+ if p.Name == "dashscope" {
+ m.officialIdx = i
+ break
+ }
+ }
+ m.step = stepAPIKey
+ m.loadExistingAPIKey()
+ m.apiKeyInput.Focus()
+
+ if !m.apiKeyMasked {
+ t.Fatal("apiKeyMasked should be true when an existing key is loaded")
+ }
+
+ got := stripANSI(m.View().Content)
+ if !strings.Contains(got, "Type or paste to replace the saved key") {
+ t.Errorf("view missing replace hint; got:\n%s", got)
+ }
+ if !strings.Contains(got, "(saved: sk-sec...abcd)") {
+ t.Errorf("view missing prefix+suffix fingerprint; got:\n%s", got)
+ }
+}
+
+func TestProviderTUI_ViewAPIKey_ShortKeyOmitsFingerprint(t *testing.T) {
+ cfg := &Config{
+ Provider: "dashscope",
+ Model: "qwen3.7-max",
+ Providers: map[string]ProviderEntry{
+ "dashscope": {APIKey: "12345678901234"}, // 14 runes — below min length 15
+ },
+ }
+ m := newProviderTUI(cfg, "")
+ m.activeTab = tabOfficial
+ for i, p := range m.providers {
+ if p.Name == "dashscope" {
+ m.officialIdx = i
+ break
+ }
+ }
+ m.step = stepAPIKey
+ m.loadExistingAPIKey()
+ m.apiKeyInput.Focus()
+
+ got := stripANSI(m.View().Content)
+ if !strings.Contains(got, "Type or paste to replace the saved key") {
+ t.Errorf("view missing replace hint; got:\n%s", got)
+ }
+ if strings.Contains(got, "(saved:") {
+ t.Errorf("view should omit fingerprint for keys shorter than 15 runes; got:\n%s", got)
+ }
+}
+
+func TestProviderTUI_ViewAPIKey_MinLenKeyShowsFingerprint(t *testing.T) {
+ cfg := &Config{
+ Provider: "dashscope",
+ Model: "qwen3.7-max",
+ Providers: map[string]ProviderEntry{
+ "dashscope": {APIKey: "123456789012345"}, // 15 runes — at min length
+ },
+ }
+ m := newProviderTUI(cfg, "")
+ m.activeTab = tabOfficial
+ for i, p := range m.providers {
+ if p.Name == "dashscope" {
+ m.officialIdx = i
+ break
+ }
+ }
+ m.step = stepAPIKey
+ m.loadExistingAPIKey()
+ m.apiKeyInput.Focus()
+
+ got := stripANSI(m.View().Content)
+ if !strings.Contains(got, "(saved: 123456...2345)") {
+ t.Errorf("view should show fingerprint at min length 15; got:\n%s", got)
+ }
+}
+
+func TestSavedSecretFingerprint_TrimsLeadingWhitespace(t *testing.T) {
+ const key = "sk-secret-1234567890abcd"
+ got := savedSecretFingerprint(" " + key)
+ want := "sk-sec...abcd"
+ if got != want {
+ t.Errorf("savedSecretFingerprint(%q) = %q, want %q", " "+key, got, want)
+ }
+}
+
+func TestProviderTUI_ViewAPIKey_FreshHidesReplaceHint(t *testing.T) {
+ cfg := &Config{
+ Provider: "dashscope",
+ Model: "qwen3.7-max",
+ Providers: map[string]ProviderEntry{
+ "dashscope": {},
+ },
+ }
+ m := newProviderTUI(cfg, "")
+ m.activeTab = tabOfficial
+ for i, p := range m.providers {
+ if p.Name == "dashscope" {
+ m.officialIdx = i
+ break
+ }
+ }
+ m.step = stepAPIKey
+ m.loadExistingAPIKey()
+
+ if m.apiKeyMasked {
+ t.Fatal("apiKeyMasked should be false when no key is loaded")
+ }
+
+ got := stripANSI(m.View().Content)
+ if strings.Contains(got, "Type or paste to replace the saved key") {
+ t.Errorf("view should not show replace hint when fresh; got:\n%s", got)
+ }
+}
+
+func TestOfficialAPIKeyEnvSetHint(t *testing.T) {
+ const envVar = "DEEPSEEK_API_KEY"
+ if got := officialAPIKeyEnvSetHint(envVar, false); got != "$DEEPSEEK_API_KEY is set. Leave empty to use it; enter a key here to override." {
+ t.Errorf("no saved key hint = %q", got)
+ }
+ if got := officialAPIKeyEnvSetHint(envVar, true); got != "$DEEPSEEK_API_KEY is set; used only when no key is saved here." {
+ t.Errorf("saved key hint = %q", got)
+ }
+}
+
+func TestProviderTUI_ViewAPIKey_EnvSetNoSavedKey(t *testing.T) {
+ t.Setenv("DEEPSEEK_API_KEY", "sk-from-env")
+ cfg := &Config{
+ Provider: "deepseek",
+ Model: "deepseek-v4-flash",
+ Providers: map[string]ProviderEntry{
+ "deepseek": {Model: "deepseek-v4-flash"},
+ },
+ }
+ m := newProviderTUI(cfg, "")
+ m.activeTab = tabOfficial
+ for i, p := range m.providers {
+ if p.Name == "deepseek" {
+ m.officialIdx = i
+ break
+ }
+ }
+ m.step = stepAPIKey
+ m.loadExistingAPIKey()
+ m.apiKeyInput.Focus()
+
+ got := stripANSI(m.View().Content)
+ want := "$DEEPSEEK_API_KEY is set. Leave empty to use it; enter a key here to override."
+ if !strings.Contains(got, want) {
+ t.Errorf("view missing env hint; want %q; got:\n%s", want, got)
+ }
+}
+
+func TestProviderTUI_ViewAPIKey_EnvSetWithSavedKey(t *testing.T) {
+ t.Setenv("DEEPSEEK_API_KEY", "sk-from-env")
+ cfg := &Config{
+ Provider: "deepseek",
+ Model: "deepseek-v4-flash",
+ Providers: map[string]ProviderEntry{
+ "deepseek": {APIKey: "sk-secret-1234567890abcd", Model: "deepseek-v4-flash"},
+ },
+ }
+ m := newProviderTUI(cfg, "")
+ m.activeTab = tabOfficial
+ for i, p := range m.providers {
+ if p.Name == "deepseek" {
+ m.officialIdx = i
+ break
+ }
+ }
+ m.step = stepAPIKey
+ m.loadExistingAPIKey()
+ m.apiKeyInput.Focus()
+
+ got := stripANSI(m.View().Content)
+ if !strings.Contains(got, "Type or paste to replace the saved key") {
+ t.Errorf("view missing replace hint; got:\n%s", got)
+ }
+ want := "$DEEPSEEK_API_KEY is set; used only when no key is saved here."
+ if !strings.Contains(got, want) {
+ t.Errorf("view missing env hint; want %q; got:\n%s", want, got)
+ }
+ if strings.Contains(got, "Leave empty to use it") {
+ t.Errorf("saved-key view should not show empty-env hint; got:\n%s", got)
+ }
+}
+
+func TestProviderTUI_ApiKeyPasteReplacesMaskedKey(t *testing.T) {
+ cfg := &Config{
+ Provider: "stepfun",
+ Model: "step-3.5-flash",
+ CustomProviders: map[string]ProviderEntry{
+ "stepfun": {
+ URL: "https://api.stepfun.com/v1",
+ APIKey: "old-key-ssss",
+ Model: "step-3.5-flash",
+ Models: []string{"step-3.5-flash"},
+ },
+ },
+ }
+ m := newProviderTUI(cfg, "")
+ m.activeTab = tabCustom
+ m.customIdx = 0
+ m.step = stepAPIKey
+ m.loadExistingAPIKey()
+ m.apiKeyInput.Focus()
+
+ if !m.apiKeyMasked {
+ t.Fatal("expected masked key on load")
+ }
+ if m.apiKeyInput.Value() != maskedSecretPlaceholder() {
+ t.Fatalf("placeholder = %q, want fixed %d asterisks", m.apiKeyInput.Value(), maskedSecretDisplayLen)
+ }
+
+ result, _ := m.Update(tea.PasteMsg{Content: "sk-new-pasted-key"})
+ m2 := result.(providerTUIModel)
+
+ if m2.apiKeyMasked {
+ t.Fatal("paste should unmask the field")
+ }
+ if got := m2.apiKeyInput.Value(); got != "sk-new-pasted-key" {
+ t.Errorf("input value = %q, want pasted key", got)
+ }
+ if r := m2.result(); r.apiKey != "sk-new-pasted-key" {
+ t.Errorf("result().apiKey = %q, want pasted key", r.apiKey)
+ }
+}
+
+func TestProviderTUI_ManualTokenPasteReplacesMaskedToken(t *testing.T) {
+ cfg := &Config{
+ Llm: LlmConfig{
+ URL: "https://example.com/v1",
+ Model: "m",
+ AuthToken: "old-token-secret",
+ },
+ }
+ m := newProviderTUI(cfg, "")
+ m.activeTab = tabManual
+ m.inManualForm = true
+ m.manualStep = manualStepAuthToken
+ m.manualTokenMasked = true
+ m.manualTokenOriginal = "old-token-secret"
+ m.manualTokenInput.SetValue(maskedSecretPlaceholder())
+ m.manualTokenInput.Focus()
+
+ if !m.manualTokenMasked {
+ t.Fatal("expected masked token on load")
+ }
+
+ result, _ := m.Update(tea.PasteMsg{Content: "new-pasted-token"})
+ m2 := result.(providerTUIModel)
+
+ if m2.manualTokenMasked {
+ t.Fatal("paste should unmask the token field")
+ }
+ if got := m2.manualTokenInput.Value(); got != "new-pasted-token" {
+ t.Errorf("input value = %q, want pasted token", got)
+ }
+ if r := m2.result(); r.apiKey != "new-pasted-token" {
+ t.Errorf("result().apiKey = %q, want pasted token", r.apiKey)
+ }
+}
+
+func TestProviderTUI_ApiKeyTypingShowsOneStarPerChar(t *testing.T) {
+ cfg := &Config{
+ Provider: "stepfun",
+ Model: "step-3.5-flash",
+ CustomProviders: map[string]ProviderEntry{
+ "stepfun": {APIKey: "old-key-ssss", Model: "step-3.5-flash"},
+ },
+ }
+ m := newProviderTUI(cfg, "")
+ m.activeTab = tabCustom
+ m.customIdx = 0
+ m.step = stepAPIKey
+ m.loadExistingAPIKey()
+ m.apiKeyInput.Focus()
+
+ for _, ch := range "abc" {
+ result, _ := m.Update(charKey(ch))
+ m = result.(providerTUIModel)
+ }
+ if m.apiKeyInput.Value() != "abc" {
+ t.Errorf("value = %q, want abc", m.apiKeyInput.Value())
+ }
+ // EchoPassword renders one '*' per character in the input view.
+ masked := stripANSI(m.apiKeyInput.View())
+ starCount := strings.Count(masked, "*")
+ if starCount != 3 {
+ t.Errorf("masked view has %d asterisks, want 3 (one * per char)", starCount)
+ }
+}
+
+func TestProviderTUI_ApiKeyEnterWithoutEditKeepsOriginal(t *testing.T) {
+ cfg := &Config{
+ Provider: "stepfun",
+ CustomProviders: map[string]ProviderEntry{
+ "stepfun": {APIKey: "keep-me"},
+ },
+ }
+ m := newProviderTUI(cfg, "")
+ m.activeTab = tabCustom
+ m.customIdx = 0
+ m.step = stepAPIKey
+ m.loadExistingAPIKey()
+ m.apiKeyInput.Focus()
+
+ if r := m.result(); r.apiKey != "keep-me" {
+ t.Fatalf("before edit result().apiKey = %q", r.apiKey)
+ }
+
+ result, cmd := m.Update(enterKey())
+ m2 := result.(providerTUIModel)
+ if !m2.confirmed {
+ t.Error("Enter without edit should confirm")
+ }
+ if cmd == nil {
+ t.Error("Enter without edit should quit")
+ }
+ if r := m2.result(); r.apiKey != "keep-me" {
+ t.Errorf("after Enter result().apiKey = %q, want keep-me", r.apiKey)
+ }
+}
+
+func TestProviderTUI_ApiKeyClearSavedKeyReturnsEmpty(t *testing.T) {
+ cfg := &Config{
+ Provider: "deepseek",
+ Model: "deepseek-v4-flash",
+ Providers: map[string]ProviderEntry{
+ "deepseek": {APIKey: "old-saved-key", Model: "deepseek-v4-flash"},
+ },
+ }
+ m := newProviderTUI(cfg, "")
+ m.activeTab = tabOfficial
+ for i, p := range m.providers {
+ if p.Name == "deepseek" {
+ m.officialIdx = i
+ break
+ }
+ }
+ m.step = stepAPIKey
+ m.loadExistingAPIKey()
+ m.beginAPIKeyReplace()
+
+ if r := m.result(); r.apiKey != "" {
+ t.Errorf("result().apiKey = %q, want empty after clearing saved key", r.apiKey)
+ }
+}
+
+func TestProviderTUI_ApiKeyResultTrimSpace(t *testing.T) {
+ cfg := &Config{
+ Provider: "deepseek",
+ Model: "deepseek-v4-flash",
+ Providers: map[string]ProviderEntry{
+ "deepseek": {Model: "deepseek-v4-flash"},
+ },
+ }
+ m := newProviderTUI(cfg, "")
+ m.activeTab = tabOfficial
+ for i, p := range m.providers {
+ if p.Name == "deepseek" {
+ m.officialIdx = i
+ break
+ }
+ }
+ m.step = stepAPIKey
+ m.apiKeyInput.SetValue(" ")
+
+ if r := m.result(); r.apiKey != "" {
+ t.Errorf("result().apiKey = %q, want empty for whitespace-only input", r.apiKey)
+ }
+}
+
+func TestProviderTUI_OfficialApiKeyEmptyWithoutEnvBlocksEnter(t *testing.T) {
+ cfg := &Config{
+ Provider: "dashscope",
+ Model: "qwen3.7-max",
+ Providers: map[string]ProviderEntry{
+ "dashscope": {},
+ },
+ }
+ m := newProviderTUI(cfg, "")
+ m.activeTab = tabOfficial
+ for i, p := range m.providers {
+ if p.Name == "dashscope" {
+ m.officialIdx = i
+ break
+ }
+ }
+ m.step = stepAPIKey
+ m.loadExistingAPIKey()
+ m.apiKeyInput.Focus()
+
+ t.Setenv("DASHSCOPE_API_KEY", "")
+
+ result, cmd := m.Update(enterKey())
+ m2 := result.(providerTUIModel)
+ if m2.step != stepAPIKey {
+ t.Errorf("step = %d, want stepAPIKey", m2.step)
+ }
+ if m2.formError != "API key is required (or set $DASHSCOPE_API_KEY)" {
+ t.Errorf("formError = %q", m2.formError)
+ }
+ if cmd != nil {
+ t.Error("Enter without key or env should not quit")
+ }
+}
+
+func TestProviderTUI_OfficialApiKeyEmptyWithEnvAllowsEnter(t *testing.T) {
+ cfg := &Config{
+ Provider: "dashscope",
+ Model: "qwen3.7-max",
+ Providers: map[string]ProviderEntry{
+ "dashscope": {},
+ },
+ }
+ m := newProviderTUI(cfg, "")
+ m.activeTab = tabOfficial
+ for i, p := range m.providers {
+ if p.Name == "dashscope" {
+ m.officialIdx = i
+ break
+ }
+ }
+ m.step = stepAPIKey
+ m.loadExistingAPIKey()
+ m.apiKeyInput.Focus()
+
+ t.Setenv("DASHSCOPE_API_KEY", "sk-from-env")
+
+ result, cmd := m.Update(enterKey())
+ m2 := result.(providerTUIModel)
+ if !m2.confirmed {
+ t.Error("Enter with env set should confirm")
+ }
+ if cmd == nil {
+ t.Error("Enter with env set should quit")
+ }
+ if m2.formError != "" {
+ t.Errorf("formError = %q, want empty", m2.formError)
+ }
+}
+
+func TestProviderTUI_CustomExistingApiKeyEmptyBlocksEnter(t *testing.T) {
+ cfg := &Config{
+ Provider: "stepfun",
+ CustomProviders: map[string]ProviderEntry{
+ "stepfun": {APIKey: "old-key"},
+ },
+ }
+ m := newProviderTUI(cfg, "")
+ m.activeTab = tabCustom
+ m.customIdx = 0
+ m.step = stepAPIKey
+ m.loadExistingAPIKey()
+ m.apiKeyInput.Focus()
+ m.beginAPIKeyReplace()
+
+ result, cmd := m.Update(enterKey())
+ m2 := result.(providerTUIModel)
+ if m2.step != stepAPIKey {
+ t.Errorf("step = %d, want stepAPIKey", m2.step)
+ }
+ if m2.formError != "API key is required" {
+ t.Errorf("formError = %q, want %q", m2.formError, "API key is required")
+ }
+ if cmd != nil {
+ t.Error("Enter with cleared key should not quit")
+ }
+}
+
+func TestProviderTUI_CustomCreateApiKeyOptional(t *testing.T) {
+ m := newProviderTUI(&Config{}, "")
+ m.activeTab = tabCustom
+ m.creatingCustom = true
+ m.cpStep = cpStepAPIKey
+ m.apiKeyInput.SetValue("")
+ m.apiKeyInput.Focus()
+
+ result, _ := m.Update(enterKey())
+ m2 := result.(providerTUIModel)
+ if m2.cpStep != cpStepAuthHeader {
+ t.Errorf("cpStep = %d, want cpStepAuthHeader", m2.cpStep)
+ }
+ if m2.formError != "" {
+ t.Errorf("formError = %q, want empty for optional API key", m2.formError)
+ }
+}
+
+func TestProviderTUI_ViewAPIKey_ShowsFormError(t *testing.T) {
+ cfg := &Config{
+ Provider: "dashscope",
+ Model: "qwen3.7-max",
+ Providers: map[string]ProviderEntry{
+ "dashscope": {},
+ },
+ }
+ m := newProviderTUI(cfg, "")
+ m.activeTab = tabOfficial
+ for i, p := range m.providers {
+ if p.Name == "dashscope" {
+ m.officialIdx = i
+ break
+ }
+ }
+ m.step = stepAPIKey
+ m.loadExistingAPIKey()
+ m.formError = "API key is required (or set $DASHSCOPE_API_KEY)"
+ m.apiKeyInput.Focus()
+
+ got := stripANSI(m.View().Content)
+ if !strings.Contains(got, "API key is required (or set $DASHSCOPE_API_KEY)") {
+ t.Errorf("view missing formError; got:\n%s", got)
+ }
+}
+
+func TestProviderTUI_CancelIncompleteOfficialProviderSwitch_NoPersistedChanges(t *testing.T) {
+ dir := t.TempDir()
+ configPath := filepath.Join(dir, "config.json")
+ cfg := &Config{
+ Provider: "deepseek",
+ Model: "deepseek-v4-flash",
+ Providers: map[string]ProviderEntry{
+ "deepseek": {Model: "deepseek-v4-flash"},
+ },
+ }
+ m := newProviderTUI(cfg, configPath)
+ m.activeTab = tabOfficial
+ for i, p := range m.providers {
+ if p.Name == "baidu-qianfan" {
+ m.officialIdx = i
+ break
+ }
+ }
+ m.step = stepModel
+ m.modelIdx = modelIdxForName(t, m, "glm-5")
+
+ result, _ := m.Update(enterKey())
+ m2 := result.(providerTUIModel)
+ if m2.savedInSession {
+ t.Error("savedInSession should be false for cross-provider navigation")
+ }
+ if m2.step != stepAPIKey {
+ t.Fatalf("step = %d, want stepAPIKey", m2.step)
+ }
+
+ result, _ = m2.Update(escKey())
+ m3 := result.(providerTUIModel)
+ result, _ = m3.Update(escKey())
+ m4 := result.(providerTUIModel)
+ result, cmd := m4.Update(escKey())
+ m5 := result.(providerTUIModel)
+ if !m5.cancelled {
+ t.Error("expected cancelled = true")
+ }
+ if m5.savedInSession {
+ t.Error("savedInSession should remain false after cancel")
+ }
+ if cmd == nil {
+ t.Error("expected tea.Quit on final Esc")
+ }
+ if _, err := os.Stat(configPath); err == nil {
+ diskCfg, err := loadOrCreateConfig(configPath)
+ if err != nil {
+ t.Fatalf("load config: %v", err)
+ }
+ if diskCfg.Provider != "deepseek" {
+ t.Errorf("Provider = %q, want deepseek", diskCfg.Provider)
+ }
+ if entry, ok := diskCfg.Providers["baidu-qianfan"]; ok && entry.Model != "" {
+ t.Errorf("baidu-qianfan model = %q, want no cross-provider draft persisted", entry.Model)
+ }
+ }
+}
+
+func TestProviderTUI_SameOfficialProviderModelChange_DefersPersistUntilConfirm(t *testing.T) {
+ dir := t.TempDir()
+ configPath := filepath.Join(dir, "config.json")
+ cfg := &Config{
+ Provider: "deepseek",
+ Model: "deepseek-v4-flash",
+ Providers: map[string]ProviderEntry{
+ "deepseek": {Model: "deepseek-v4-flash"},
+ },
+ }
+ m := newProviderTUI(cfg, configPath)
+ m.activeTab = tabOfficial
+ m.step = stepModel
+ m.modelIdx = modelIdxForName(t, m, "deepseek-v4-pro")
+
+ result, _ := m.Update(enterKey())
+ m2 := result.(providerTUIModel)
+ if m2.savedInSession {
+ t.Error("savedInSession should be false before API key confirm")
+ }
+ if m2.step != stepAPIKey {
+ t.Fatalf("step = %d, want stepAPIKey", m2.step)
+ }
+ if _, err := os.Stat(configPath); err == nil {
+ t.Fatal("config should not be written before wizard confirm")
+ }
+ if cfg.Model != "deepseek-v4-flash" {
+ t.Errorf("cfg.Model = %q, want deepseek-v4-flash", cfg.Model)
+ }
+}
+
+func TestProviderTUI_OfficialModelChangeBlockedAtAPIKey_KeepsGlobalModel(t *testing.T) {
+ t.Setenv("ANTHROPIC_API_KEY", "")
+ dir := t.TempDir()
+ configPath := filepath.Join(dir, "config.json")
+ cfg := &Config{
+ Provider: "anthropic",
+ Model: "claude-opus-4-8",
+ Providers: map[string]ProviderEntry{
+ "anthropic": {
+ Model: "claude-opus-4-8",
+ APIKey: "sk-test-key",
+ },
+ },
+ }
+ m := newProviderTUI(cfg, configPath)
+ m.activeTab = tabOfficial
+ for i, p := range m.providers {
+ if p.Name == "anthropic" {
+ m.officialIdx = i
+ break
+ }
+ }
+ m.step = stepModel
+ m.modelIdx = modelIdxForName(t, m, "claude-opus-4-7")
+
+ result, _ := m.Update(enterKey())
+ m2 := result.(providerTUIModel)
+ m2.beginAPIKeyReplace()
+
+ result, cmd := m2.Update(enterKey())
+ m3 := result.(providerTUIModel)
+ if cmd != nil {
+ t.Error("Enter without key or env should not quit")
+ }
+ if m3.step != stepAPIKey {
+ t.Fatalf("step = %d, want stepAPIKey", m3.step)
+ }
+ if cfg.Model != "claude-opus-4-8" {
+ t.Errorf("cfg.Model = %q, want claude-opus-4-8", cfg.Model)
+ }
+ if got := cfg.Providers["anthropic"].Model; got != "claude-opus-4-8" {
+ t.Errorf("providers.anthropic.Model = %q, want claude-opus-4-8", got)
+ }
+ if _, err := os.Stat(configPath); err == nil {
+ t.Fatal("config should not be written when API key validation fails")
+ }
+}
+
+// stripANSI removes ANSI escape sequences from a string so tests can assert
+// against plain text content.
+func stripANSI(s string) string {
+ var b strings.Builder
+ inEscape := false
+ for _, r := range s {
+ if r == 0x1b {
+ inEscape = true
+ continue
+ }
+ if inEscape {
+ if r == 'm' {
+ inEscape = false
+ }
+ continue
+ }
+ b.WriteRune(r)
+ }
+ return b.String()
+}
+
func TestProviderTUI_DeleteModelPreservesActiveModel(t *testing.T) {
cfg := &Config{
Provider: "stepfun",
diff --git a/cmd/opencodereview/review_cmd.go b/cmd/opencodereview/review_cmd.go
index d76951b..8379156 100644
--- a/cmd/opencodereview/review_cmd.go
+++ b/cmd/opencodereview/review_cmd.go
@@ -11,8 +11,11 @@ import (
"github.com/open-code-review/open-code-review/internal/agent"
"github.com/open-code-review/open-code-review/internal/mcp"
+ "github.com/open-code-review/open-code-review/internal/session"
"github.com/open-code-review/open-code-review/internal/telemetry"
"github.com/open-code-review/open-code-review/internal/tool"
+
+ "go.opentelemetry.io/otel/codes"
)
func runReview(args []string) error {
@@ -44,10 +47,26 @@ func runReview(args []string) error {
}
}
+ // Only touch the background when --background-file is set, so the existing
+ // --background behaviour (raw, unsanitised) is preserved for users who do
+ // not opt into the file-based context.
+ if opts.backgroundFile != "" {
+ fileBackground, err := loadBackgroundFile(opts.backgroundFile)
+ if err != nil {
+ return err
+ }
+ opts.background = mergeBackground(opts.background, fileBackground)
+ }
+
if opts.preview {
return runPreview(cc, opts)
}
+ resumeState, err := loadReviewResumeState(cc.RepoDir, opts)
+ if err != nil {
+ return err
+ }
+
rt, err := loadLLMRuntime(cc.Template, opts.toolConfigPath, opts.model)
if err != nil {
return err
@@ -81,6 +100,7 @@ func runReview(args []string) error {
From: opts.from,
To: opts.to,
Commit: opts.commit,
+ ReviewMode: reviewModeFromOptions(opts),
Template: *cc.Template,
SystemRule: cc.Resolver,
FileFilter: cc.FileFilter,
@@ -95,6 +115,7 @@ func runReview(args []string) error {
Model: rt.Model,
Background: opts.background,
GitRunner: cc.GitRunner,
+ Resume: resumeState,
})
// Silence progress output during execution; restored before the trace
@@ -104,34 +125,75 @@ func runReview(args []string) error {
ctx, span := telemetry.StartSpan(context.Background(), "review.run")
defer span.End()
+ telemetry.SetAttr(span, "review.repo", cc.RepoDir)
+ telemetry.SetAttr(span, "review.from", opts.from)
+ telemetry.SetAttr(span, "review.to", opts.to)
+ telemetry.SetAttr(span, "review.model", rt.Model)
+ var traceID string
+ if telemetry.IsEnabled() {
+ traceID = telemetry.TraceIDFromContext(ctx)
+ if opts.outputFormat != "json" {
+ fmt.Fprintf(os.Stderr, "[ocr] TraceID: %s\n", traceID)
+ }
+ }
startTime := time.Now()
comments, err := ag.Run(ctx)
if err != nil {
- telemetry.SetAttr(span, "error", err.Error())
+ span.SetStatus(codes.Error, err.Error())
+ span.RecordError(err)
+ if id := ag.SessionID(); id != "" {
+ fmt.Fprintf(os.Stderr, "[ocr] Session: %s (retry with: --resume %s)\n", id, id)
+ }
return fmt.Errorf("review failed: %w", err)
}
return emitRunResult(ctx, ag, comments, startTime, opts.outputFormat, opts.audience, q)
}
-func resolveRepoDir(input string) (string, error) {
- if input == "" {
- var err error
- input, err = os.Getwd()
- if err != nil {
- return "", fmt.Errorf("get working directory: %w", err)
- }
+func loadReviewResumeState(repoDir string, opts reviewOptions) (*session.ResumeState, error) {
+ if opts.resume == "" {
+ return nil, nil
}
- absPath, err := filepath.Abs(input)
+ current := session.SessionOptions{
+ ReviewMode: reviewModeFromOptions(opts),
+ DiffFrom: opts.from,
+ DiffTo: opts.to,
+ DiffCommit: opts.commit,
+ }
+ if current.ReviewMode == session.ReviewModeWorkspace {
+ return nil, fmt.Errorf("resume requires --from/--to or --commit; workspace resume is not supported")
+ }
+ state, err := session.LoadResumeState(repoDir, opts.resume)
if err != nil {
- return "", fmt.Errorf("resolve absolute path: %w", err)
+ return nil, fmt.Errorf("load resume session: %w (run 'ocr session list' to see available sessions)", err)
}
- out, err := runGitCmd(absPath, "rev-parse", "--git-dir")
- if err != nil || len(out) == 0 {
- return "", fmt.Errorf("%s is not a git repository", absPath)
+ if err := state.ValidateOptions(current); err != nil {
+ return nil, fmt.Errorf("%w (run 'ocr session list' to see available sessions)", err)
}
- return absPath, nil
+ if state.CompletedCount() == 0 {
+ return nil, fmt.Errorf("resume session %q has no completed review items (run 'ocr session list' to see available sessions)", opts.resume)
+ }
+ return state, nil
+}
+
+func reviewModeFromOptions(opts reviewOptions) string {
+ if opts.commit != "" {
+ return session.ReviewModeCommit
+ }
+ if opts.from != "" && opts.to != "" {
+ return session.ReviewModeRange
+ }
+ return session.ReviewModeWorkspace
+}
+
+// resolveRepoDir resolves the repo dir for `ocr rules check`. It delegates to
+// resolveWorkingDir(requireGit=true) so it anchors at the git top-level just
+// like the review path — keeping rule resolution consistent when run from a
+// monorepo subdirectory (#287).
+func resolveRepoDir(input string) (string, error) {
+ absPath, _, err := resolveWorkingDir(input, true)
+ return absPath, err
}
// requireGitRepo validates that the given directory is part of a git repository.
diff --git a/cmd/opencodereview/scan_cmd.go b/cmd/opencodereview/scan_cmd.go
index 3757e98..46353ab 100644
--- a/cmd/opencodereview/scan_cmd.go
+++ b/cmd/opencodereview/scan_cmd.go
@@ -3,6 +3,7 @@ package main
import (
"context"
"fmt"
+ "os"
"strings"
"time"
@@ -11,6 +12,8 @@ import (
"github.com/open-code-review/open-code-review/internal/scan"
"github.com/open-code-review/open-code-review/internal/telemetry"
"github.com/open-code-review/open-code-review/internal/tool"
+
+ "go.opentelemetry.io/otel/codes"
)
// scanOptions mirrors reviewOptions for the full-scan subcommand. The two
@@ -207,11 +210,22 @@ func runScan(args []string) error {
ctx, span := telemetry.StartSpan(context.Background(), "scan.run")
defer span.End()
+ var traceID string
+ if telemetry.IsEnabled() {
+ traceID = telemetry.TraceIDFromContext(ctx)
+ if opts.outputFormat != "json" {
+ fmt.Fprintf(os.Stderr, "[ocr] TraceID: %s\n", traceID)
+ }
+ }
startTime := time.Now()
comments, err := ag.Run(ctx)
if err != nil {
- telemetry.SetAttr(span, "error", err.Error())
+ span.SetStatus(codes.Error, err.Error())
+ span.RecordError(err)
+ if id := ag.SessionID(); id != "" {
+ fmt.Fprintf(os.Stderr, "[ocr] Session: %s\n", id)
+ }
return fmt.Errorf("scan failed: %w", err)
}
diff --git a/cmd/opencodereview/session_cmd.go b/cmd/opencodereview/session_cmd.go
new file mode 100644
index 0000000..805b8a5
--- /dev/null
+++ b/cmd/opencodereview/session_cmd.go
@@ -0,0 +1,299 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "os"
+ "strings"
+ "text/tabwriter"
+ "time"
+
+ "github.com/open-code-review/open-code-review/internal/session"
+)
+
+func runSession(args []string) error {
+ if len(args) == 0 {
+ printSessionUsage()
+ return nil
+ }
+ switch args[0] {
+ case "list", "ls":
+ return runSessionList(args[1:])
+ case "show":
+ return runSessionShow(args[1:])
+ case "-h", "--help":
+ printSessionUsage()
+ return nil
+ default:
+ return fmt.Errorf("unknown session sub-command: %s\nRun 'ocr session -h' for usage", args[0])
+ }
+}
+
+func runSessionList(args []string) error {
+ a := newOcrFlagSet("ocr session list")
+ var repoDir string
+ var asJSON bool
+ var limit int
+ a.StringVar(&repoDir, "repo", "", "root directory of the git repository (default: current dir)")
+ a.BoolVar(&asJSON, "json", false, "emit JSON instead of a table")
+ a.IntVar(&limit, "limit", 20, "cap the number of listed sessions (0 = unlimited)")
+ if err := a.Parse(args); err != nil {
+ return err
+ }
+ if a.showHelp {
+ printSessionListUsage()
+ return nil
+ }
+
+ resolvedRepo, err := resolveWorkingDirForSession(repoDir)
+ if err != nil {
+ return err
+ }
+ summaries, err := session.ListSessions(resolvedRepo)
+ if err != nil {
+ return fmt.Errorf("list sessions: %w", err)
+ }
+ if limit > 0 && len(summaries) > limit {
+ summaries = summaries[:limit]
+ }
+
+ if asJSON {
+ enc := json.NewEncoder(os.Stdout)
+ enc.SetIndent("", " ")
+ return enc.Encode(summaries)
+ }
+
+ if len(summaries) == 0 {
+ fmt.Printf("No sessions found for %s\n", resolvedRepo)
+ return nil
+ }
+ printSessionTable(os.Stdout, summaries)
+ return nil
+}
+
+func runSessionShow(args []string) error {
+ a := newOcrFlagSet("ocr session show")
+ var repoDir string
+ var asJSON bool
+ a.StringVar(&repoDir, "repo", "", "root directory of the git repository (default: current dir)")
+ a.BoolVar(&asJSON, "json", false, "emit JSON instead of a table")
+ if err := a.Parse(args); err != nil {
+ return err
+ }
+ if a.showHelp {
+ printSessionShowUsage()
+ return nil
+ }
+
+ rest := a.fs.Args()
+ if len(rest) == 0 {
+ printSessionShowUsage()
+ return fmt.Errorf("session show requires a session ID")
+ }
+ sessionID := rest[0]
+
+ resolvedRepo, err := resolveWorkingDirForSession(repoDir)
+ if err != nil {
+ return err
+ }
+ summary, items, err := session.LoadDetail(resolvedRepo, sessionID)
+ if err != nil {
+ return fmt.Errorf("load session %q: %w", sessionID, err)
+ }
+
+ if asJSON {
+ payload := struct {
+ Summary *session.Summary `json:"summary"`
+ Items []session.ItemDetail `json:"items"`
+ }{Summary: summary, Items: items}
+ enc := json.NewEncoder(os.Stdout)
+ enc.SetIndent("", " ")
+ return enc.Encode(payload)
+ }
+
+ printSessionDetail(os.Stdout, summary, items)
+ return nil
+}
+
+// resolveWorkingDirForSession accepts an explicit --repo flag value and falls
+// back to the current working directory. Unlike resolveRepoDir it does not
+// require the target to be a git repository, so users can inspect sessions
+// even after archiving a checkout.
+func resolveWorkingDirForSession(input string) (string, error) {
+ dir, _, err := resolveWorkingDir(input, false)
+ if err != nil {
+ return "", err
+ }
+ return dir, nil
+}
+
+func printSessionTable(w io.Writer, summaries []session.Summary) {
+ tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
+ fmt.Fprintln(tw, "SESSION ID\tMODE\tRANGE\tFILES\tCOMMENTS\tSTATUS\tSTARTED")
+ for _, s := range summaries {
+ fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%d\t%s\t%s\n",
+ s.SessionID,
+ displayMode(s.ReviewMode),
+ describeRange(s),
+ describeFiles(s),
+ s.TotalComments,
+ describeStatus(s),
+ describeStart(s),
+ )
+ }
+ tw.Flush()
+}
+
+func printSessionDetail(w io.Writer, s *session.Summary, items []session.ItemDetail) {
+ fmt.Fprintf(w, "Session: %s\n", s.SessionID)
+ fmt.Fprintf(w, " File: %s\n", s.FilePath)
+ fmt.Fprintf(w, " Repo: %s\n", s.RepoDir)
+ if s.GitBranch != "" {
+ fmt.Fprintf(w, " Branch: %s\n", s.GitBranch)
+ }
+ if s.Model != "" {
+ fmt.Fprintf(w, " Model: %s\n", s.Model)
+ }
+ fmt.Fprintf(w, " Mode: %s\n", displayMode(s.ReviewMode))
+ if r := describeRange(*s); r != "" && r != "-" {
+ fmt.Fprintf(w, " Range: %s\n", r)
+ }
+ if s.ResumedFrom != "" {
+ fmt.Fprintf(w, " Resumed: from session %s\n", s.ResumedFrom)
+ }
+ fmt.Fprintf(w, " Started: %s\n", describeStart(*s))
+ if !s.EndTime.IsZero() {
+ fmt.Fprintf(w, " Ended: %s\n", s.EndTime.Local().Format("2006-01-02 15:04:05"))
+ }
+ if s.Duration > 0 {
+ fmt.Fprintf(w, " Duration: %s\n", s.Duration.Round(time.Second))
+ }
+ fmt.Fprintf(w, " Status: %s\n", describeStatus(*s))
+ fmt.Fprintf(w, " Files: %d completed, %d reused, %d failed\n",
+ s.CompletedFiles, s.ReusedFiles, s.FailedFiles)
+ fmt.Fprintf(w, " Comments: %d\n", s.TotalComments)
+ if s.LLMFailures > 0 {
+ fmt.Fprintf(w, " LLM err: %d\n", s.LLMFailures)
+ }
+
+ if len(items) == 0 {
+ return
+ }
+ fmt.Fprintln(w)
+ fmt.Fprintln(w, "Files:")
+ tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
+ fmt.Fprintln(tw, " TYPE\tFILE\tCOMMENTS\tNOTE")
+ for _, it := range items {
+ note := ""
+ switch it.Type {
+ case "reused":
+ note = "from " + shortSessionID(it.SourceSessionID)
+ case "failed":
+ note = truncate(it.Error, 60)
+ }
+ fmt.Fprintf(tw, " %s\t%s\t%d\t%s\n", it.Type, it.FilePath, it.Comments, note)
+ }
+ tw.Flush()
+}
+
+func displayMode(m string) string {
+ if m == "" {
+ return "-"
+ }
+ return m
+}
+
+func describeRange(s session.Summary) string {
+ switch s.ReviewMode {
+ case session.ReviewModeRange:
+ if s.DiffFrom != "" || s.DiffTo != "" {
+ return fmt.Sprintf("%s..%s", s.DiffFrom, s.DiffTo)
+ }
+ case session.ReviewModeCommit:
+ if s.DiffCommit != "" {
+ return s.DiffCommit
+ }
+ }
+ return "-"
+}
+
+func describeFiles(s session.Summary) string {
+ total := s.CompletedFiles + s.ReusedFiles
+ if s.ReusedFiles > 0 {
+ return fmt.Sprintf("%d (reused %d)", total, s.ReusedFiles)
+ }
+ return fmt.Sprintf("%d", total)
+}
+
+func describeStatus(s session.Summary) string {
+ if s.Aborted {
+ return "aborted"
+ }
+ if s.FailedFiles > 0 {
+ return fmt.Sprintf("completed (%d fail)", s.FailedFiles)
+ }
+ return "completed"
+}
+
+func describeStart(s session.Summary) string {
+ if s.StartTime.IsZero() {
+ return "-"
+ }
+ return s.StartTime.Local().Format("2006-01-02 15:04:05")
+}
+
+func shortSessionID(id string) string {
+ if len(id) > 8 {
+ return id[:8]
+ }
+ return id
+}
+
+func truncate(s string, n int) string {
+ s = strings.ReplaceAll(strings.ReplaceAll(s, "\n", " "), "\t", " ")
+ runes := []rune(s)
+ if len(runes) <= n {
+ return s
+ }
+ if n <= 1 {
+ return "…"
+ }
+ return string(runes[:n-1]) + "…"
+}
+
+func printSessionUsage() {
+ fmt.Println(`Usage:
+ ocr session
+
+Sub-commands:
+ list, ls List recent review sessions for the current repo
+ show Show one session's metadata and per-file items
+
+Use "ocr session list -h" or "ocr session show -h" for details.`)
+}
+
+func printSessionListUsage() {
+ fmt.Println(`Usage:
+ ocr session list [flags]
+ ocr session ls [flags]
+
+List review sessions previously persisted to ~/.opencodereview/sessions/. The
+session id printed here can be passed to 'ocr review --resume '.
+
+Flags:
+ --repo string Root directory of the git repository (default: current dir)
+ --json Emit JSON instead of a table
+ --limit int Cap the number of listed sessions (default 20; 0 = unlimited)`)
+}
+
+func printSessionShowUsage() {
+ fmt.Println(`Usage:
+ ocr session show [flags]
+
+Show metadata and per-file items for a single session.
+
+Flags:
+ --repo string Root directory of the git repository (default: current dir)
+ --json Emit JSON instead of a table`)
+}
diff --git a/cmd/opencodereview/session_cmd_test.go b/cmd/opencodereview/session_cmd_test.go
new file mode 100644
index 0000000..1c32a71
--- /dev/null
+++ b/cmd/opencodereview/session_cmd_test.go
@@ -0,0 +1,170 @@
+package main
+
+import (
+ "encoding/json"
+ "strings"
+ "testing"
+
+ "github.com/open-code-review/open-code-review/internal/model"
+ "github.com/open-code-review/open-code-review/internal/session"
+)
+
+func TestRunSessionList_TextIncludesSessionID(t *testing.T) {
+ tmpHome := t.TempDir()
+ t.Setenv("HOME", tmpHome)
+ repoDir := t.TempDir()
+
+ sh := session.New(repoDir, "main", "test-model", session.SessionOptions{
+ ReviewMode: session.ReviewModeCommit,
+ DiffCommit: "abc123",
+ })
+ sh.RecordReviewItemDone("a.go", "a.go", "a.go", "fp-a", []model.LlmComment{{Path: "a.go", Content: "note"}})
+ sh.Finalize()
+
+ got := captureStdout(t, func() {
+ if err := runSessionList([]string{"--repo", repoDir}); err != nil {
+ t.Fatalf("runSessionList: %v", err)
+ }
+ })
+
+ if !strings.Contains(got, sh.SessionID) {
+ t.Errorf("expected list output to contain session id %s, got %q", sh.SessionID, got)
+ }
+ if !strings.Contains(got, "abc123") {
+ t.Errorf("expected list output to contain commit range, got %q", got)
+ }
+ if !strings.Contains(got, "SESSION ID") {
+ t.Errorf("expected header, got %q", got)
+ }
+}
+
+func TestRunSessionList_JSON(t *testing.T) {
+ tmpHome := t.TempDir()
+ t.Setenv("HOME", tmpHome)
+ repoDir := t.TempDir()
+
+ sh := session.New(repoDir, "main", "test-model", session.SessionOptions{
+ ReviewMode: session.ReviewModeCommit,
+ DiffCommit: "abc123",
+ })
+ sh.RecordReviewItemDone("a.go", "a.go", "a.go", "fp-a", nil)
+ sh.Finalize()
+
+ got := captureStdout(t, func() {
+ if err := runSessionList([]string{"--repo", repoDir, "--json"}); err != nil {
+ t.Fatalf("runSessionList: %v", err)
+ }
+ })
+
+ var decoded []session.Summary
+ if err := json.Unmarshal([]byte(got), &decoded); err != nil {
+ t.Fatalf("unmarshal: %v (out=%q)", err, got)
+ }
+ if len(decoded) != 1 || decoded[0].SessionID != sh.SessionID {
+ t.Fatalf("decoded = %+v", decoded)
+ }
+}
+
+func TestRunSessionList_EmptyRepo(t *testing.T) {
+ tmpHome := t.TempDir()
+ t.Setenv("HOME", tmpHome)
+ repoDir := t.TempDir()
+
+ got := captureStdout(t, func() {
+ if err := runSessionList([]string{"--repo", repoDir}); err != nil {
+ t.Fatalf("runSessionList: %v", err)
+ }
+ })
+ if !strings.Contains(got, "No sessions found") {
+ t.Errorf("expected empty message, got %q", got)
+ }
+}
+
+func TestRunSessionShow_Text(t *testing.T) {
+ tmpHome := t.TempDir()
+ t.Setenv("HOME", tmpHome)
+ repoDir := t.TempDir()
+
+ sh := session.New(repoDir, "main", "test-model", session.SessionOptions{
+ ReviewMode: session.ReviewModeCommit,
+ DiffCommit: "abc123",
+ })
+ sh.RecordReviewItemDone("a.go", "a.go", "a.go", "fp-a", []model.LlmComment{{Path: "a.go", Content: "note"}})
+ sh.RecordReviewItemFailed("bad.go", "bad.go", "bad.go", "fp-bad", "boom")
+ sh.Finalize()
+
+ got := captureStdout(t, func() {
+ if err := runSessionShow([]string{"--repo", repoDir, sh.SessionID}); err != nil {
+ t.Fatalf("runSessionShow: %v", err)
+ }
+ })
+
+ for _, want := range []string{sh.SessionID, "abc123", "a.go", "bad.go", "boom", "Files:"} {
+ if !strings.Contains(got, want) {
+ t.Errorf("expected output to contain %q, got %q", want, got)
+ }
+ }
+}
+
+func TestRunSessionShow_JSON(t *testing.T) {
+ tmpHome := t.TempDir()
+ t.Setenv("HOME", tmpHome)
+ repoDir := t.TempDir()
+
+ sh := session.New(repoDir, "main", "test-model", session.SessionOptions{
+ ReviewMode: session.ReviewModeCommit,
+ DiffCommit: "abc123",
+ })
+ sh.RecordReviewItemDone("a.go", "a.go", "a.go", "fp-a", nil)
+ sh.Finalize()
+
+ got := captureStdout(t, func() {
+ if err := runSessionShow([]string{"--repo", repoDir, "--json", sh.SessionID}); err != nil {
+ t.Fatalf("runSessionShow: %v", err)
+ }
+ })
+
+ var payload struct {
+ Summary *session.Summary `json:"summary"`
+ Items []session.ItemDetail `json:"items"`
+ }
+ if err := json.Unmarshal([]byte(got), &payload); err != nil {
+ t.Fatalf("unmarshal: %v (out=%q)", err, got)
+ }
+ if payload.Summary == nil || payload.Summary.SessionID != sh.SessionID {
+ t.Fatalf("summary mismatch: %+v", payload.Summary)
+ }
+ if len(payload.Items) != 1 || payload.Items[0].FilePath != "a.go" {
+ t.Fatalf("items = %+v", payload.Items)
+ }
+}
+
+func TestRunSessionShow_MissingID(t *testing.T) {
+ tmpHome := t.TempDir()
+ t.Setenv("HOME", tmpHome)
+ got := captureStdout(t, func() {
+ if err := runSessionShow([]string{}); err == nil {
+ t.Fatal("expected error for missing session id")
+ }
+ })
+ if !strings.Contains(got, "session show") {
+ t.Errorf("expected usage output, got %q", got)
+ }
+}
+
+func TestTruncateUnicode(t *testing.T) {
+ got := truncate("错误原因:超过限制", 6)
+ if !strings.HasSuffix(got, "…") {
+ t.Fatalf("expected ellipsis suffix, got %q", got)
+ }
+ if !strings.Contains(got, "错误") {
+ t.Fatalf("expected valid truncated unicode text, got %q", got)
+ }
+}
+
+func TestRunSession_UnknownSubcommand(t *testing.T) {
+ err := runSession([]string{"bogus"})
+ if err == nil {
+ t.Fatal("expected error for unknown sub-command")
+ }
+}
diff --git a/cmd/opencodereview/shared.go b/cmd/opencodereview/shared.go
index 8d898a1..c99c1b2 100644
--- a/cmd/opencodereview/shared.go
+++ b/cmd/opencodereview/shared.go
@@ -5,6 +5,7 @@ import (
"fmt"
"os"
"path/filepath"
+ "strings"
"time"
"github.com/open-code-review/open-code-review/internal/agent"
@@ -100,6 +101,25 @@ func resolveWorkingDir(input string, requireGit bool) (string, bool, error) {
if !isGit && requireGit {
return "", false, fmt.Errorf("%s is not a git repository", absPath)
}
+ // #287: git reports diff and `git show HEAD:` paths relative to the
+ // repository root, not the current directory. When `ocr review` runs from a
+ // subdirectory of a monorepo, anchor RepoDir at the git top-level so those
+ // root-relative paths resolve for both disk reads and git-show reads.
+ // requireGit is true only for the review path; scan (requireGit=false) keeps
+ // the CWD so its `git ls-files` walk stays scoped to the subdirectory.
+ if isGit && requireGit {
+ // runGitCmdStdout captures stdout only so git stderr notices can't
+ // pollute the resolved path. --show-toplevel fails (or is empty) when
+ // there is no work tree — e.g. a bare repo, where --git-dir succeeds so
+ // isGit is true. Fail loudly there instead of silently reusing the
+ // subdir, which would reproduce the #287 root-relative-path bug.
+ top, topErr := runGitCmdStdout(absPath, "rev-parse", "--show-toplevel")
+ t := strings.TrimSpace(string(top))
+ if topErr != nil || t == "" {
+ return "", false, fmt.Errorf("%s is a git repository without a work tree (bare repo?); cannot resolve its top level for review", absPath)
+ }
+ absPath = t
+ }
return absPath, isGit, nil
}
@@ -231,6 +251,14 @@ type ResultProvider interface {
// that skipped / failed the summary phase.
ProjectSummary() string
ToolCalls() map[string]int64
+ // SessionID returns the persisted session identifier so callers can show it
+ // in JSON output or failure diagnostics. Returns "" when no session was
+ // created.
+ SessionID() string
+}
+
+type resumeInfoProvider interface {
+ ResumeInfo() *agent.ResumeInfo
}
// emitRunResult is the post-LLM-run finalization shared by `ocr review` and
@@ -256,8 +284,10 @@ func emitRunResult(
telemetry.RecordCommentsGenerated(ctx, int64(len(comments)))
}
+ traceID := telemetry.TraceIDFromContext(ctx)
+
if outputFormat == "json" && len(comments) == 0 && ag.FilesReviewed() == 0 {
- return outputJSONNoFiles()
+ return outputJSONNoFiles(traceID)
}
// Agent-text audiences need stdout back before PrintTraceSummary so the
@@ -273,10 +303,14 @@ func emitRunResult(
}
if outputFormat == "json" {
+ var resumeInfo *agent.ResumeInfo
+ if p, ok := ag.(resumeInfoProvider); ok {
+ resumeInfo = p.ResumeInfo()
+ }
return outputJSONWithWarnings(comments, ag.Warnings(), ag.FilesReviewed(),
ag.TotalInputTokens(), ag.TotalOutputTokens(), ag.TotalTokensUsed(),
ag.TotalCacheReadTokens(), ag.TotalCacheWriteTokens(), duration,
- ag.ProjectSummary(), ag.ToolCalls())
+ ag.ProjectSummary(), ag.ToolCalls(), traceID, resumeInfo, ag.SessionID())
}
outputTextWithWarnings(comments, ag.Warnings())
if summary := ag.ProjectSummary(); summary != "" {
diff --git a/cmd/opencodereview/shared_test.go b/cmd/opencodereview/shared_test.go
index 8c9b819..417a65f 100644
--- a/cmd/opencodereview/shared_test.go
+++ b/cmd/opencodereview/shared_test.go
@@ -2,6 +2,7 @@ package main
import (
"os"
+ "os/exec"
"path/filepath"
"testing"
@@ -78,9 +79,18 @@ func TestQuietHandle_IdempotentRestore(t *testing.T) {
func TestResolveWorkingDir_CurrentDir(t *testing.T) {
dir := t.TempDir()
- origDir, _ := os.Getwd()
- defer os.Chdir(origDir)
- os.Chdir(dir)
+ origDir, err := os.Getwd()
+ if err != nil {
+ t.Fatalf("getwd: %v", err)
+ }
+ defer func() {
+ if err := os.Chdir(origDir); err != nil {
+ t.Errorf("restore chdir: %v", err)
+ }
+ }()
+ if err := os.Chdir(dir); err != nil {
+ t.Fatalf("chdir: %v", err)
+ }
absPath, isGit, err := resolveWorkingDir("", false)
if err != nil {
@@ -109,6 +119,89 @@ func TestResolveWorkingDir_NonExistent(t *testing.T) {
}
}
+// TestResolveWorkingDir_MonorepoSubdir reproduces #287: running `ocr review`
+// from a subdirectory of a git repo must anchor RepoDir at the git top-level
+// (git reports diff / `git show HEAD:` paths relative to the repo root),
+// while `ocr scan` (requireGit=false) must keep the subdirectory so its walk
+// stays scoped.
+func TestResolveWorkingDir_MonorepoSubdir(t *testing.T) {
+ root := t.TempDir()
+ git := func(args ...string) {
+ t.Helper()
+ cmd := exec.Command("git", args...)
+ cmd.Dir = root
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("git %v: %v\n%s", args, err, out)
+ }
+ }
+ git("init")
+ git("config", "user.email", "t@t.co")
+ git("config", "user.name", "t")
+
+ sub := filepath.Join(root, "subproject1", "src")
+ if err := os.MkdirAll(sub, 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ // macOS /var -> /private/var symlink means t.TempDir() differs from the
+ // canonicalized toplevel git returns; compare via EvalSymlinks.
+ wantRoot, err := filepath.EvalSymlinks(root)
+ if err != nil {
+ t.Fatalf("EvalSymlinks(%q): %v", root, err)
+ }
+
+ // review path: hoisted to the git top-level.
+ got, isGit, err := resolveWorkingDir(sub, true)
+ if err != nil {
+ t.Fatalf("resolveWorkingDir(sub, true) error: %v", err)
+ }
+ if !isGit {
+ t.Error("expected isGit=true for a git subdirectory")
+ }
+ gotResolved, err := filepath.EvalSymlinks(got)
+ if err != nil {
+ t.Fatalf("EvalSymlinks(%q): %v", got, err)
+ }
+ if gotResolved != wantRoot {
+ t.Errorf("review RepoDir = %q, want git top-level %q", gotResolved, wantRoot)
+ }
+
+ // scan path: keeps the subdirectory unchanged.
+ gotScan, _, err := resolveWorkingDir(sub, false)
+ if err != nil {
+ t.Fatalf("resolveWorkingDir(sub, false) error: %v", err)
+ }
+ gotScanResolved, err := filepath.EvalSymlinks(gotScan)
+ if err != nil {
+ t.Fatalf("EvalSymlinks(%q): %v", gotScan, err)
+ }
+ wantSub, err := filepath.EvalSymlinks(sub)
+ if err != nil {
+ t.Fatalf("EvalSymlinks(%q): %v", sub, err)
+ }
+ if gotScanResolved != wantSub {
+ t.Errorf("scan RepoDir = %q, want subdir %q (must stay scoped)", gotScanResolved, wantSub)
+ }
+}
+
+// TestResolveWorkingDir_BareRepoFailsLoudly guards the #287 fix: a bare repo has
+// no work tree, so `git rev-parse --git-dir` succeeds (isGit=true) but
+// `--show-toplevel` fails. The review path (requireGit=true) must return an
+// error rather than silently reusing the input dir, which would reproduce the
+// original root-relative-path bug.
+func TestResolveWorkingDir_BareRepoFailsLoudly(t *testing.T) {
+ bare := t.TempDir()
+ cmd := exec.Command("git", "init", "--bare", bare)
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("git init --bare: %v\n%s", err, out)
+ }
+
+ _, _, err := resolveWorkingDir(bare, true)
+ if err == nil {
+ t.Fatal("expected error for a bare repo (no work tree), got nil")
+ }
+}
+
func TestResolveWorkingDir_GitRepo(t *testing.T) {
dir := t.TempDir()
gitDir := filepath.Join(dir, ".git")
diff --git a/examples/README.md b/examples/README.md
index 8a4caea..2ffc3ff 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -6,5 +6,6 @@ This directory contains examples for integrating OpenCodeReview (OCR) into vario
- **[github_actions/](./github_actions/)** - GitHub Actions integration example
- **[gitlab_ci/](./gitlab_ci/)** - GitLab CI integration example
+- **[gitflic_ci/](./gitflic_ci/)** - GitFlic CI integration example
-Each subdirectory contains its own README with detailed setup instructions.
\ No newline at end of file
+Each subdirectory contains its own README with detailed setup instructions.
diff --git a/examples/gitflic_ci/README.md b/examples/gitflic_ci/README.md
new file mode 100644
index 0000000..9f36ce7
--- /dev/null
+++ b/examples/gitflic_ci/README.md
@@ -0,0 +1,80 @@
+# OpenCodeReview - GitFlic CI Demo
+
+This demo shows how to integrate OpenCodeReview into a [GitFlic](https://gitflic.ru) CI/CD pipeline to automatically review Merge Requests and post the findings as MR discussions — inline on the changed lines where possible.
+
+Like the GitHub Actions and GitLab CI examples, the posting glue lives in the CI layer rather than in the `ocr` binary. Here it is a small, dependency-free Python script — [`post_review.py`](post_review.py) — that reads `ocr review --format json` and posts to the GitFlic Discussions API. The only GitFlic-specific wrinkle it handles is the **old-side line**: GitFlic requires it even for a comment on the new side of the diff, and `ocr review` reports new-side positions only, so the script recomputes it from the same merge-base diff the review ran on.
+
+## How It Works
+
+```
+MR Created/Updated → Merge Request Pipeline → ocr review → post_review.py → Discussions on MR
+```
+
+1. A Merge Request Pipeline triggers the `code-review` job
+2. It installs OCR via npm in a `node:20` image (which also ships `python3` and `git`)
+3. Runs `ocr review --from origin/ --to $CI_COMMIT_SHA --format json --audience agent`
+4. Runs `python3 post_review.py`, which reads the JSON and posts:
+ - **Inline discussions** on the changed lines (`POST .../discussions/create` with `newLine`/`oldLine`/`newPath`/`oldPath`)
+ - **A fallback note** collecting comments that could not be placed inline
+ - **A summary note** with the totals
+
+The MR context (owner, project, MR id, branch refs) is picked up automatically from the predefined GitFlic CI variables (`CI_PROJECT_NAMESPACE`, `CI_PROJECT_NAME`, `CI_MERGE_REQUEST_LOCAL_ID`, `CI_MERGE_REQUEST_TARGET_BRANCH_NAME`, `CI_COMMIT_SHA`), so `post_review.py` needs no arguments in CI. Outside CI every value can be passed via flags — run `python3 post_review.py -h`.
+
+## Setup
+
+### 1. Enable Merge Request Pipelines
+
+Go to **Project Settings → CI/CD Settings** and enable **Merge Request Pipeline**. New merge requests will then trigger the pipeline automatically.
+
+### 2. Copy the pipeline files
+
+Copy **both** `gitflic-ci.yaml` (GitFlic expects this exact file name at the repository root) and `post_review.py` into your repository. If you keep `post_review.py` somewhere other than the repo root, adjust the `python3 post_review.py` path in `gitflic-ci.yaml` accordingly.
+
+### 3. Configure CI/CD Variables
+
+Go to **Settings → CI/CD → Variables** and add:
+
+| Variable | Required | Description |
+|----------|----------|-------------|
+| `OCR_LLM_URL` | Yes | LLM API endpoint URL |
+| `OCR_LLM_AUTH_TOKEN` | Yes | LLM API authentication token |
+| `GITFLIC_TOKEN` | Yes | GitFlic access token used to post discussions |
+| `OCR_LLM_MODEL` | No | Model name (e.g., `gpt-4o`) |
+| `GITFLIC_API_URL` | No | REST API base URL for self-hosted GitFlic (default: `https://api.gitflic.ru`) |
+
+> **Note:** GitFlic CI/CD does not accept variables with values shorter than 8 characters, so `use_anthropic` cannot be set as a CI variable. The pipeline sets it to `false`; to use Anthropic Claude models, edit `gitflic-ci.yaml` directly.
+
+### 4. Create a GitFlic Access Token
+
+Create a token in **User Settings → Access Tokens** (or a dedicated service account — its name becomes the bot name shown in discussions) and store it in the `GITFLIC_TOKEN` variable. The token owner must have access to the project sufficient for commenting on merge requests.
+
+## Notes & Limitations
+
+- **Inline positioning** — GitFlic requires all four of `newLine`/`oldLine`/`newPath`/`oldPath` for a code comment; if any is missing it silently creates a general comment. `post_review.py` computes the old-side position from the same merge-base diff the review ran on (`git diff merge-base(from, to)..to`), and anchors added lines to the closest preceding old line.
+- **Rate limit** — the GitFlic cloud API allows 500 requests/hour per token. One review posts `comments + 2` requests at most, which fits comfortably.
+- **Self-hosted GitFlic** — set `GITFLIC_API_URL` to your instance's REST API base URL.
+- **Re-reviews** — every push to the MR triggers a new pipeline and a new review. To skip already-reviewed MRs, check existing discussions for the `OpenCodeReview` marker before running the review step.
+
+## Tests
+
+`post_review.py` ships with [`post_review_test.py`](post_review_test.py) — standard-library `unittest`, no network or git required:
+
+```bash
+cd examples/gitflic_ci
+python3 post_review_test.py
+```
+
+The line-mapping cases are ported from the upstream Go tests so the script keeps proven parity with the binary.
+
+## Debugging
+
+Test the posting step locally without touching the MR:
+
+```bash
+ocr review --from origin/main --to HEAD --format json > /tmp/r.json
+python3 post_review.py /tmp/r.json \
+ --owner --project --mr \
+ --from origin/main --to HEAD --dry-run
+```
+
+`--dry-run` prints every discussion (with the computed positions) instead of posting, and does not require `GITFLIC_TOKEN`.
diff --git a/examples/gitflic_ci/gitflic-ci.yaml b/examples/gitflic_ci/gitflic-ci.yaml
new file mode 100644
index 0000000..2a2b9bd
--- /dev/null
+++ b/examples/gitflic_ci/gitflic-ci.yaml
@@ -0,0 +1,84 @@
+# OpenCodeReview - GitFlic CI Merge Request Auto-Review Demo
+#
+# Reviews Merge Requests with OpenCodeReview and posts the findings onto the
+# MR as discussions (inline where possible). The posting glue lives in the CI
+# layer, in post_review.py next to this file -- consistent with the GitHub and
+# GitLab examples, which keep platform-specific publishing out of the `ocr`
+# binary.
+#
+# Setup:
+# - Commit BOTH this file and post_review.py into your repository (adjust the
+# `python3 post_review.py` path below if you place the script elsewhere).
+# - Enable "Merge Request Pipeline" in Project Settings -> CI/CD Settings.
+# - Use a runner able to run the node:20 image (it ships node, python3 and git),
+# or any shell runner with node 20+, python3 and git available.
+#
+# Required CI/CD Variables (Settings -> CI/CD -> Variables):
+# OCR_LLM_URL - LLM API endpoint (e.g., https://api.openai.com/v1/chat/completions)
+# OCR_LLM_AUTH_TOKEN - Authentication token for the LLM API
+# GITFLIC_TOKEN - GitFlic access token used to post MR discussions
+#
+# Optional CI/CD Variables:
+# OCR_LLM_MODEL - Model name (e.g., gpt-4o)
+# GITFLIC_API_URL - GitFlic REST API base URL; only needed for self-hosted
+# instances (defaults to https://api.gitflic.ru)
+#
+# post_review.py picks up the MR context automatically from the predefined
+# GitFlic CI variables: CI_PROJECT_NAMESPACE, CI_PROJECT_NAME,
+# CI_MERGE_REQUEST_LOCAL_ID, CI_MERGE_REQUEST_TARGET_BRANCH_NAME, CI_COMMIT_SHA.
+
+stages:
+ - review
+
+code-review:
+ stage: review
+ image: node:20
+ script:
+ # Run only in merge request pipelines
+ - |
+ if [ -z "$CI_MERGE_REQUEST_LOCAL_ID" ]; then
+ echo "Not a merge request pipeline, skipping review."
+ exit 0
+ fi
+
+ # Install OpenCodeReview
+ - npm install -g @alibaba-group/open-code-review
+
+ # Configure OCR
+ - |
+ ocr config set llm.url $OCR_LLM_URL
+ ocr config set llm.auth_token $OCR_LLM_AUTH_TOKEN
+ if [ -n "$OCR_LLM_MODEL" ]; then
+ ocr config set llm.model "$OCR_LLM_MODEL"
+ fi
+ ocr config set llm.use_anthropic false
+ ocr config set llm.extra_body '{"thinking": {"type": "disabled"}}'
+
+ # Make sure the target branch and full history are available for merge-base diff
+ - git fetch --unshallow 2>/dev/null || true
+ - git fetch origin "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME"
+
+ # Run OCR review (CI_COMMIT_SHA as head supports forked MRs as well)
+ - |
+ ocr review \
+ --from "origin/${CI_MERGE_REQUEST_TARGET_BRANCH_NAME}" \
+ --to "${CI_COMMIT_SHA}" \
+ --format json \
+ --audience agent \
+ > /tmp/ocr-result.json || true
+ echo "OCR review completed."
+
+ # Post review comments onto the MR (inline discussions + summary note).
+ # post_review.py recomputes the old-side line for each comment from the
+ # merge-base diff, which the GitFlic Discussions API requires for inline
+ # (code) comments.
+ #
+ # The review step above ends with `|| true`, so a failed `ocr review` (bad
+ # token, network error) leaves an empty or partial file. Skip posting in
+ # that case instead of feeding invalid JSON to post_review.py.
+ - |
+ if [ ! -s /tmp/ocr-result.json ]; then
+ echo "OCR review produced no output, skipping post."
+ exit 0
+ fi
+ python3 post_review.py /tmp/ocr-result.json
diff --git a/examples/gitflic_ci/post_review.py b/examples/gitflic_ci/post_review.py
new file mode 100644
index 0000000..e8b0555
--- /dev/null
+++ b/examples/gitflic_ci/post_review.py
@@ -0,0 +1,480 @@
+#!/usr/bin/env python3
+"""Post an OpenCodeReview result onto a GitFlic merge request.
+
+This is the CI-layer "glue" for GitFlic, mirroring examples/gitlab_ci: it keeps
+platform-specific publishing out of the `ocr` binary and lives entirely in the
+pipeline. It reads the JSON emitted by `ocr review --format json` and posts it
+onto the merge request as discussions:
+
+ - one inline discussion per comment that maps onto the diff,
+ - a single fallback note collecting the comments that do not,
+ - a final summary note.
+
+GitFlic's Discussions API needs an *old-side* line even for a comment on the new
+side of the diff: an inline (code) discussion requires all four of
+newLine/oldLine/newPath/oldPath, otherwise GitFlic silently records a plain
+comment. `ocr review` only reports new-side positions, so this script computes
+the old-side line itself by parsing the same merge-base diff the review ran on
+(`git diff merge-base(from, to)..to`).
+
+Standard library only (json, urllib, subprocess) so it runs on the stock
+node:20 / python image used by the pipeline.
+"""
+
+import argparse
+import json
+import os
+import re
+import subprocess
+import sys
+import urllib.error
+import urllib.request
+from urllib.parse import quote
+
+# GitFlic SaaS REST API endpoint; override with --api-url / $GITFLIC_API_URL for
+# self-hosted instances (e.g. http://gitflic.example/rest-api).
+DEFAULT_API_URL = "https://api.gitflic.ru"
+
+# Context lines around each hunk; must match what `ocr review` diffs with so the
+# new-side line numbers in the comments align with the hunks parsed here.
+DIFF_CONTEXT_LINES = 3
+
+
+def log(msg):
+ print(msg, file=sys.stderr)
+
+
+# --------------------------------------------------------------------------- #
+# Diff parsing
+# --------------------------------------------------------------------------- #
+
+HUNK_CONTEXT, HUNK_ADDED, HUNK_DELETED = range(3)
+
+_HUNK_HEADER_RE = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@")
+_DIFF_HEADER_RE = re.compile(r"^diff --git a/(.+?) b/(.+)$")
+
+
+class Hunk:
+ """One @@ ... @@ block of a unified diff."""
+
+ __slots__ = ("old_start", "old_count", "new_start", "new_count", "lines")
+
+ def __init__(self, old_start, old_count, new_start, new_count):
+ self.old_start = old_start
+ self.old_count = old_count
+ self.new_start = new_start
+ self.new_count = new_count
+ self.lines = [] # list of (type, content)
+
+
+class FileDiff:
+ """A single file's section of a unified diff."""
+
+ __slots__ = ("old_path", "new_path", "is_new", "is_deleted", "is_binary", "text")
+
+ def __init__(self, old_path="", new_path=""):
+ self.old_path = old_path
+ self.new_path = new_path
+ self.is_new = False
+ self.is_deleted = False
+ self.is_binary = False
+ self.text = "" # raw diff body, fed to parse_hunks() on demand
+
+
+def parse_hunks(raw):
+ """Parse one file's unified diff text into a list of Hunks.
+
+ Lines before the first @@ header (diff --git, ---, +++) are ignored.
+ """
+ hunks = []
+ current = None
+ for line in raw.split("\n"):
+ m = _HUNK_HEADER_RE.match(line)
+ if m:
+ if current is not None:
+ hunks.append(current)
+ old_start = int(m.group(1))
+ old_count = int(m.group(2)) if m.group(2) else 1
+ new_start = int(m.group(3))
+ new_count = int(m.group(4)) if m.group(4) else 1
+ current = Hunk(old_start, old_count, new_start, new_count)
+ continue
+ if current is None:
+ continue
+ if line.startswith("\\ No newline at end of file"):
+ continue
+ if line.startswith("diff --git "):
+ break
+ if line.startswith("+"):
+ current.lines.append((HUNK_ADDED, line[1:]))
+ elif line.startswith("-"):
+ current.lines.append((HUNK_DELETED, line[1:]))
+ else:
+ content = line[1:] if line[:1] == " " else line
+ current.lines.append((HUNK_CONTEXT, content))
+ if current is not None:
+ hunks.append(current)
+ return hunks
+
+
+def parse_diff(diff_text):
+ """Split combined unified diff text into per-file FileDiff sections."""
+ files = []
+ current = None
+ buf = []
+
+ def flush():
+ if current is not None:
+ current.text = "\n".join(buf)
+ files.append(current)
+
+ for line in diff_text.split("\n"):
+ m = _DIFF_HEADER_RE.match(line)
+ if m:
+ flush()
+ buf = []
+ current = FileDiff(old_path=m.group(1), new_path=m.group(2))
+ if current is None:
+ continue
+ if line.startswith("Binary files ") or line.startswith("GIT binary patch"):
+ current.is_binary = True
+ elif line.startswith("new file mode"):
+ current.is_new = True
+ elif line.startswith("deleted file mode"):
+ current.is_deleted = True
+ elif line.startswith("--- "):
+ path = line[4:]
+ if path == "/dev/null":
+ current.is_new = True
+ current.old_path = "/dev/null"
+ elif path.startswith("a/"):
+ current.old_path = path[2:]
+ elif line.startswith("+++ "):
+ path = line[4:]
+ if path == "/dev/null":
+ current.is_deleted = True
+ current.new_path = "/dev/null"
+ elif path.startswith("b/"):
+ current.new_path = path[2:]
+ buf.append(line)
+
+ flush()
+ return files
+
+
+# --------------------------------------------------------------------------- #
+# Line mapping (new file side -> old file side)
+# --------------------------------------------------------------------------- #
+
+
+def clamp_line(n):
+ return 1 if n < 1 else n
+
+
+def old_line_for(hunks, new_line):
+ """Map a new-side line number to the corresponding old-side line.
+
+ Lines added by the diff have no old counterpart, so they are anchored to the
+ closest preceding old line -- GitFlic only needs a plausible old-side
+ position to render the code comment next to the insertion point. The result
+ is always >= 1.
+ """
+ delta = 0 # cumulative (new - old) line-count shift from preceding hunks
+ for h in hunks:
+ if new_line < h.new_start:
+ break
+ if new_line < h.new_start + h.new_count:
+ return _old_line_in_hunk(h, new_line)
+ delta += h.new_count - h.old_count
+ return clamp_line(new_line - delta)
+
+
+def _old_line_in_hunk(h, new_line):
+ """Walk a hunk's lines tracking both counters until reaching new_line."""
+ old_ln, new_ln = h.old_start, h.new_start
+ last_old = h.old_start - 1 # last old line seen before the current position
+ for line_type, _content in h.lines:
+ if line_type == HUNK_CONTEXT:
+ if new_ln == new_line:
+ return clamp_line(old_ln)
+ last_old = old_ln
+ old_ln += 1
+ new_ln += 1
+ elif line_type == HUNK_DELETED:
+ last_old = old_ln
+ old_ln += 1
+ elif line_type == HUNK_ADDED:
+ if new_ln == new_line:
+ return clamp_line(last_old)
+ new_ln += 1
+ return clamp_line(last_old)
+
+
+# --------------------------------------------------------------------------- #
+# Comment formatting
+# --------------------------------------------------------------------------- #
+
+
+def format_comment(c):
+ """Render an inline discussion body."""
+ body = c.get("content", "")
+ suggestion = c.get("suggestion_code", "")
+ existing = c.get("existing_code", "")
+ if suggestion and existing:
+ body += "\n\n**Suggestion:**\n```\n" + suggestion + "\n```"
+ return body
+
+
+def format_comment_fallback(c):
+ """Render a comment for the fallback (non-inline) note."""
+ md = "### 📄 `%s`" % c.get("path", "")
+ start_line = c.get("start_line", 0)
+ end_line = c.get("end_line", 0)
+ if start_line and end_line:
+ md += " (L%d-L%d)" % (start_line, end_line)
+ md += "\n\n" + c.get("content", "")
+ suggestion = c.get("suggestion_code", "")
+ existing = c.get("existing_code", "")
+ if suggestion and existing:
+ md += "\n\n**Before:**\n```\n" + existing + "\n```\n\n**After:**\n```\n" + suggestion + "\n```"
+ return md
+
+
+# --------------------------------------------------------------------------- #
+# Publishing (transport-agnostic; `post` does the actual API call)
+# --------------------------------------------------------------------------- #
+
+
+def publish(result, diffs_by_path, post):
+ """Post the review result via the `post(discussion)` callable.
+
+ `post` receives a discussion dict and must raise on failure. A general
+ comment carries only "message"; an inline comment also carries
+ newLine/oldLine/newPath/oldPath. Returns {"inline": int, "fallback": int}.
+ """
+ comments = result.get("comments") or []
+ if not comments:
+ message = result.get("message") or "No comments generated. Looks good to me."
+ post({"message": "✅ **OpenCodeReview**: " + message})
+ return {"inline": 0, "fallback": 0}
+
+ inline = 0
+ failed = []
+ hunks_cache = {}
+
+ for c in comments:
+ path = c.get("path", "")
+ end_line = c.get("end_line", 0) or 0
+ fd = diffs_by_path.get(path)
+ if fd is None:
+ log("no diff for %s; folding comment into the summary note" % path)
+ failed.append(c)
+ continue
+ if fd.is_binary or fd.is_deleted or end_line <= 0:
+ failed.append(c)
+ continue
+
+ old_path = fd.old_path
+ old_line = 1
+ if fd.is_new or old_path == "" or old_path == "/dev/null":
+ # GitFlic has no old side for a new file; anchor to the new path.
+ old_path = fd.new_path
+ else:
+ hunks = hunks_cache.get(path)
+ if hunks is None:
+ hunks = parse_hunks(fd.text)
+ hunks_cache[path] = hunks
+ old_line = old_line_for(hunks, end_line)
+
+ discussion = {
+ "message": format_comment(c),
+ "newLine": end_line,
+ "oldLine": old_line,
+ "newPath": path,
+ "oldPath": old_path,
+ }
+ try:
+ post(discussion)
+ except Exception as e: # noqa: BLE001 - any transport error falls back
+ log("inline comment failed for %s:%d: %s" % (path, end_line, e))
+ failed.append(c)
+ continue
+ inline += 1
+
+ if failed:
+ note = "🔍 **OpenCodeReview** found issues that could not be posted inline:\n\n---\n\n"
+ for c in failed:
+ note += format_comment_fallback(c) + "\n\n---\n\n"
+ post({"message": note})
+
+ summary = "🔍 **OpenCodeReview** found **%d** issue(s) in this MR." % len(comments)
+ summary += "\n- ✅ %d posted as inline comment(s)" % inline
+ summary += "\n- 📝 %d posted as summary (could not be placed inline)" % len(failed)
+ warnings = result.get("warnings") or []
+ if warnings:
+ summary += "\n\n⚠️ %d warning(s) occurred during review." % len(warnings)
+ post({"message": summary})
+
+ return {"inline": inline, "fallback": len(failed)}
+
+
+# --------------------------------------------------------------------------- #
+# GitFlic REST transport
+# --------------------------------------------------------------------------- #
+
+
+def make_poster(api_url, token, owner, project, mr):
+ """Return a post(discussion) that POSTs to the GitFlic Discussions API."""
+ endpoint = "%s/project/%s/%s/merge-request/%s/discussions/create" % (
+ api_url.rstrip("/"),
+ quote(owner, safe=""),
+ quote(project, safe=""),
+ quote(mr, safe=""),
+ )
+
+ def post(discussion):
+ body = json.dumps(discussion).encode("utf-8")
+ req = urllib.request.Request(endpoint, data=body, method="POST")
+ req.add_header("Authorization", "token " + token)
+ req.add_header("Content-Type", "application/json")
+ try:
+ with urllib.request.urlopen(req) as resp:
+ resp.read()
+ except urllib.error.HTTPError as e:
+ snippet = e.read(512).decode("utf-8", "replace").strip()
+ # Some APIs echo request details back in error bodies; never let the
+ # token reach the CI log if GitFlic does that.
+ if token:
+ snippet = snippet.replace(token, "***")
+ raise RuntimeError("gitflic API %s %s: %s" % (e.code, e.reason, snippet))
+
+ return post
+
+
+def make_dry_run_poster():
+ """Return a post(discussion) that prints instead of calling the API."""
+
+ def post(discussion):
+ if discussion.get("newPath") and "newLine" in discussion and "oldLine" in discussion:
+ position = "%s:%d (old %s:%d)" % (
+ discussion["newPath"],
+ discussion["newLine"],
+ discussion.get("oldPath", ""),
+ discussion["oldLine"],
+ )
+ else:
+ position = "general"
+ print("--- dry-run discussion [%s] ---\n%s\n" % (position, discussion["message"]))
+
+ return post
+
+
+# --------------------------------------------------------------------------- #
+# git / IO
+# --------------------------------------------------------------------------- #
+
+
+def _git(repo, *args):
+ return subprocess.run(
+ ["git", *args], cwd=repo, check=True, capture_output=True, text=True
+ ).stdout
+
+
+def load_diffs_by_path(repo, from_ref, to_ref):
+ """Build {new_path: FileDiff} for the merge-base diff `ocr review` ran on."""
+ base = _git(repo, "merge-base", from_ref, to_ref).strip()
+ out = _git(
+ repo, "diff", "--no-ext-diff", "--no-textconv",
+ "--src-prefix=a/", "--dst-prefix=b/", "--no-color",
+ "-U%d" % DIFF_CONTEXT_LINES, base, to_ref, "--",
+ )
+ return {fd.new_path: fd for fd in parse_diff(out)}
+
+
+def load_review_result(path):
+ """Read the JSON produced by `ocr review --format json` (path '-' = stdin)."""
+ if path == "-":
+ data = sys.stdin.read()
+ else:
+ with open(path, encoding="utf-8") as f:
+ data = f.read()
+ return json.loads(data)
+
+
+# --------------------------------------------------------------------------- #
+# CLI
+# --------------------------------------------------------------------------- #
+
+
+def parse_args(argv):
+ target = os.environ.get("CI_MERGE_REQUEST_TARGET_BRANCH_NAME", "")
+ default_from = "origin/" + target if target else ""
+
+ p = argparse.ArgumentParser(
+ description="Post `ocr review --format json` output onto a GitFlic merge request."
+ )
+ p.add_argument("file", nargs="?", default="-",
+ help="review result JSON ('-' = stdin, default)")
+ p.add_argument("--owner", default=os.environ.get("CI_PROJECT_NAMESPACE", ""),
+ help="project owner alias (default: $CI_PROJECT_NAMESPACE)")
+ p.add_argument("--project", default=os.environ.get("CI_PROJECT_NAME", ""),
+ help="project alias (default: $CI_PROJECT_NAME)")
+ p.add_argument("--mr", default=os.environ.get("CI_MERGE_REQUEST_LOCAL_ID", ""),
+ help="merge request local id (default: $CI_MERGE_REQUEST_LOCAL_ID)")
+ p.add_argument("--api-url", default=os.environ.get("GITFLIC_API_URL", "") or DEFAULT_API_URL,
+ help="GitFlic REST API base URL (default: $GITFLIC_API_URL or %s)" % DEFAULT_API_URL)
+ p.add_argument("--from", dest="from_ref", default=default_from,
+ help="base ref of the reviewed range (default: origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME)")
+ p.add_argument("--to", dest="to_ref", default=os.environ.get("CI_COMMIT_SHA", ""),
+ help="head ref of the reviewed range (default: $CI_COMMIT_SHA)")
+ p.add_argument("--repo", default=".", help="git repository root (default: .)")
+ p.add_argument("--dry-run", action="store_true",
+ help="print discussions instead of posting them")
+ return p.parse_args(argv)
+
+
+def main(argv=None):
+ args = parse_args(sys.argv[1:] if argv is None else argv)
+
+ missing = [name for name, value in (
+ ("--owner", args.owner), ("--project", args.project), ("--mr", args.mr),
+ ("--from", args.from_ref), ("--to", args.to_ref),
+ ) if not value]
+ if missing:
+ log("error: %s required (set via flag or CI environment)" % ", ".join(missing))
+ return 2
+
+ token = os.environ.get("GITFLIC_TOKEN", "")
+ if not token and not args.dry_run:
+ log("error: GITFLIC_TOKEN environment variable is required")
+ return 2
+
+ try:
+ result = load_review_result(args.file)
+ except (OSError, ValueError) as e:
+ log("error: cannot read review result %s: %s" % (args.file, e))
+ return 1
+
+ try:
+ diffs_by_path = load_diffs_by_path(args.repo, args.from_ref, args.to_ref)
+ except (subprocess.CalledProcessError, OSError) as e:
+ # Without the diff, inline positions cannot be computed; comments still
+ # go out via the fallback note.
+ log("warning: cannot read diff %s..%s, posting all comments as fallback: %s"
+ % (args.from_ref, args.to_ref, e))
+ diffs_by_path = {}
+
+ if args.dry_run:
+ post = make_dry_run_poster()
+ else:
+ post = make_poster(args.api_url, token, args.owner, args.project, args.mr)
+
+ stats = publish(result, diffs_by_path, post)
+ total = len(result.get("comments") or [])
+ print("Posted %d inline comment(s), %d via fallback note (%d total)."
+ % (stats["inline"], stats["fallback"], total))
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/examples/gitflic_ci/post_review_test.py b/examples/gitflic_ci/post_review_test.py
new file mode 100644
index 0000000..be43828
--- /dev/null
+++ b/examples/gitflic_ci/post_review_test.py
@@ -0,0 +1,240 @@
+#!/usr/bin/env python3
+"""Tests for post_review.py.
+
+Standard-library unittest only (no pytest, no network, no git): run with
+
+ python3 post_review_test.py # from examples/gitflic_ci/
+ python3 -m unittest discover examples/gitflic_ci
+
+The line-mapping cases are ported 1:1 from the upstream Go test
+internal/publish/gitflic/linemap_test.go and publisher_test.go, so the script
+keeps proven parity with the binary it replaces.
+"""
+
+import unittest
+
+import post_review as pr
+
+
+# old file lines 1..10; line 3 modified, a line inserted after old line 5,
+# old line 8 deleted. (from linemap_test.go)
+SAMPLE_DIFF = """diff --git a/main.go b/main.go
+--- a/main.go
++++ b/main.go
+@@ -1,10 +1,10 @@
+ line1
+ line2
+-line3 old
++line3 new
+ line4
+ line5
++inserted after5
+ line6
+ line7
+-line8
+ line9
+ line10
+"""
+
+NEW_FILE_DIFF = """diff --git a/added.go b/added.go
+new file mode 100644
+--- /dev/null
++++ b/added.go
+@@ -0,0 +1,3 @@
++package main
++
++func main() {}
+"""
+
+DELETED_FILE_DIFF = """diff --git a/gone.go b/gone.go
+deleted file mode 100644
+--- a/gone.go
++++ /dev/null
+@@ -1,2 +0,0 @@
+-package main
+-
+"""
+
+BINARY_DIFF = """diff --git a/logo.png b/logo.png
+index 1111111..2222222 100644
+Binary files a/logo.png and b/logo.png differ
+"""
+
+
+class OldLineForTest(unittest.TestCase):
+ def setUp(self):
+ self.hunks = pr.parse_hunks(SAMPLE_DIFF)
+
+ def test_single_hunk_positions(self):
+ self.assertEqual(len(self.hunks), 1)
+ cases = [
+ ("context before changes", 1, 1),
+ ("modified line maps to deleted position anchor", 3, 3),
+ ("context after modification", 4, 4),
+ ("added line anchors to preceding old line", 6, 5),
+ ("context shifted by insertion", 7, 6),
+ ("context after deletion", 9, 9),
+ ("last context line", 10, 10),
+ ]
+ for name, new_line, want in cases:
+ with self.subTest(name):
+ self.assertEqual(pr.old_line_for(self.hunks, new_line), want)
+
+ def test_outside_hunks(self):
+ # one line added and one deleted -> cumulative delta 0
+ self.assertEqual(pr.old_line_for(self.hunks, 42), 42)
+
+ def test_multiple_hunks(self):
+ multi = (
+ "@@ -1,2 +1,4 @@\n"
+ " line1\n"
+ "+added2\n"
+ "+added3\n"
+ " line2\n"
+ "@@ -10,3 +12,3 @@\n"
+ " line10\n"
+ "-line11 old\n"
+ "+line11 new\n"
+ " line12\n"
+ )
+ hunks = pr.parse_hunks(multi)
+ self.assertEqual(len(hunks), 2)
+ # between hunks: new 8 = old 6 (two lines added by hunk 1)
+ self.assertEqual(pr.old_line_for(hunks, 8), 6)
+ # inside second hunk: modified new 13 anchors to old 11
+ self.assertEqual(pr.old_line_for(hunks, 13), 11)
+
+ def test_pure_addition_at_top(self):
+ hunks = pr.parse_hunks("@@ -0,0 +1,2 @@\n+first\n+second\n")
+ self.assertEqual(pr.old_line_for(hunks, 1), 1)
+
+
+class ParseDiffTest(unittest.TestCase):
+ def test_modified_file(self):
+ fd = pr.parse_diff(SAMPLE_DIFF)[0]
+ self.assertEqual((fd.old_path, fd.new_path), ("main.go", "main.go"))
+ self.assertFalse(fd.is_new or fd.is_deleted or fd.is_binary)
+
+ def test_new_file(self):
+ fd = pr.parse_diff(NEW_FILE_DIFF)[0]
+ self.assertTrue(fd.is_new)
+ self.assertEqual(fd.new_path, "added.go")
+
+ def test_deleted_file(self):
+ fd = pr.parse_diff(DELETED_FILE_DIFF)[0]
+ self.assertTrue(fd.is_deleted)
+ self.assertEqual(fd.new_path, "/dev/null")
+
+ def test_binary_file(self):
+ fd = pr.parse_diff(BINARY_DIFF)[0]
+ self.assertTrue(fd.is_binary)
+
+ def test_multiple_files(self):
+ files = pr.parse_diff(SAMPLE_DIFF + NEW_FILE_DIFF)
+ self.assertEqual([f.new_path for f in files], ["main.go", "added.go"])
+
+
+class Recorder:
+ """A post() that records discussions; optionally fails the first inline."""
+
+ def __init__(self, fail_first_inline=False):
+ self.calls = []
+ self.fail_first_inline = fail_first_inline
+ self._inline_seen = 0
+
+ def __call__(self, discussion):
+ if self.fail_first_inline and "newPath" in discussion:
+ self._inline_seen += 1
+ if self._inline_seen == 1:
+ raise RuntimeError("simulated 403")
+ self.calls.append(discussion)
+
+
+def diffs_from(diff_text):
+ return {fd.new_path: fd for fd in pr.parse_diff(diff_text)}
+
+
+class PublishTest(unittest.TestCase):
+ def test_inline_and_summary(self):
+ result = {
+ "comments": [{
+ "path": "main.go", "content": "possible nil dereference",
+ "start_line": 6, "end_line": 6,
+ "existing_code": "x := y.Field",
+ "suggestion_code": "if y != nil { x = y.Field }",
+ }],
+ }
+ rec = Recorder()
+ stats = pr.publish(result, diffs_from(SAMPLE_DIFF), rec)
+
+ self.assertEqual(stats, {"inline": 1, "fallback": 0})
+ self.assertEqual(len(rec.calls), 2) # inline + summary
+
+ inline = rec.calls[0]
+ self.assertEqual(inline["newLine"], 6)
+ self.assertEqual(inline["oldLine"], 5)
+ self.assertEqual((inline["newPath"], inline["oldPath"]), ("main.go", "main.go"))
+ self.assertIn("possible nil dereference", inline["message"])
+ self.assertIn("**Suggestion:**", inline["message"])
+
+ summary = rec.calls[1]
+ self.assertNotIn("newPath", summary)
+ self.assertIn("**1** issue(s)", summary["message"])
+
+ def test_fallback_for_unmapped_comment(self):
+ result = {
+ "comments": [{
+ "path": "missing.go", "content": "issue in file absent from diff",
+ "start_line": 1, "end_line": 1,
+ }],
+ "warnings": [{"file": "a.go", "message": "skipped", "type": "subtask_error"}],
+ }
+ rec = Recorder()
+ stats = pr.publish(result, {}, rec)
+
+ self.assertEqual(stats, {"inline": 0, "fallback": 1})
+ self.assertEqual(len(rec.calls), 2) # fallback + summary
+ self.assertIn("could not be posted inline", rec.calls[0]["message"])
+ self.assertIn("`missing.go`", rec.calls[0]["message"])
+ self.assertIn("1 warning(s)", rec.calls[1]["message"])
+
+ def test_inline_error_falls_back(self):
+ result = {
+ "comments": [{
+ "path": "main.go", "content": "finding",
+ "start_line": 1, "end_line": 1,
+ }],
+ }
+ rec = Recorder(fail_first_inline=True)
+ stats = pr.publish(result, diffs_from(SAMPLE_DIFF), rec)
+
+ self.assertEqual(stats, {"inline": 0, "fallback": 1})
+ self.assertEqual(len(rec.calls), 2) # fallback + summary after inline failure
+
+ def test_no_comments(self):
+ rec = Recorder()
+ stats = pr.publish({"message": "No comments generated. Looks good to me."}, {}, rec)
+
+ self.assertEqual(stats, {"inline": 0, "fallback": 0})
+ self.assertEqual(len(rec.calls), 1)
+ self.assertIn("Looks good to me", rec.calls[0]["message"])
+
+ def test_new_file_anchors_to_new_path(self):
+ result = {
+ "comments": [{
+ "path": "added.go", "content": "empty main",
+ "start_line": 3, "end_line": 3,
+ }],
+ }
+ rec = Recorder()
+ stats = pr.publish(result, diffs_from(NEW_FILE_DIFF), rec)
+
+ self.assertEqual(stats["inline"], 1)
+ inline = rec.calls[0]
+ self.assertEqual(inline["oldPath"], "added.go")
+ self.assertEqual(inline["oldLine"], 1)
+ self.assertEqual(inline["newLine"], 3)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/examples/github_actions/README.md b/examples/github_actions/README.md
index dccf654..0236505 100644
--- a/examples/github_actions/README.md
+++ b/examples/github_actions/README.md
@@ -1,6 +1,40 @@
-# OpenCodeReview - GitHub Actions Demo
+# OpenCodeReview - GitHub Actions Workflow
-This demo shows how to integrate OpenCodeReview into your GitHub Actions workflow to automatically review Pull Requests and post review comments.
+This directory provides a ready-to-use GitHub Actions workflow demo that integrates OpenCodeReview into your repository to automatically review Pull Requests and post inline review comments. Copy it into `.github/workflows/` and configure the required secrets/vars.
+
+## Quick Start: `ocr-review.yml`
+
+The simplest adoption path: this demo delegates every step — checkout, OCR install, review, comment posting, artifact upload — to the official reusable composite action at [`action.yml`](../../action.yml) via a single `uses: alibaba/open-code-review@main` step. It covers both automatic PR review (`pull_request_target: opened/synchronize/reopened`) and on-demand re-review via comments (`/open-code-review` or `@open-code-review`). No inline scripts to maintain — `@main` always runs the latest action; pin to a version tag or commit SHA when reproducibility matters.
+
+```bash
+mkdir -p .github/workflows
+cp ocr-review.yml .github/workflows/ocr-review.yml
+```
+
+The core of the demo is a single action step:
+
+```yaml
+- uses: alibaba/open-code-review@main
+ with:
+ llm_url: ${{ secrets.OCR_LLM_URL }}
+ llm_auth_token: ${{ secrets.OCR_LLM_AUTH_TOKEN }}
+ llm_model: ${{ vars.OCR_LLM_MODEL }}
+ llm_use_anthropic: ${{ vars.OCR_LLM_USE_ANTHROPIC }}
+```
+
+See [`action.yml`](../../action.yml) for the full list of inputs, outputs, security guidance, and the four comment-posting modes (sticky summary + incremental).
+
+## Running on a self-hosted runner
+
+The demo above runs on GitHub-hosted runners (`runs-on: ubuntu-latest`) and pulls the action from `alibaba/open-code-review@main`. If you prefer to run OCR on your own self-hosted runner — to reach private network resources, keep LLM traffic on-prem, or avoid runner-minute costs — the OCR project itself does exactly this in its own CI.
+
+See [`.github/workflows/ocr-review.yml`](../../.github/workflows/ocr-review.yml) for that workflow. It runs on `runs-on: self-hosted` inside a `node:24` container. One important caveat: it invokes the action with `uses: ./` only because `action.yml` lives in that same repository — that is an internal shortcut and will not resolve in your repo. As an external user, keep `uses: alibaba/open-code-review@main` (the runner fetches the action automatically); only the runner environment needs to change. What is worth borrowing from it:
+
+- `runs-on: self-hosted`, optionally with a `container:` image such as `node:24` (the action needs Node.js; git is installed automatically if missing).
+- Marking the workspace as a trusted git `safe.directory` when running inside a container (e.g. `git config --global --replace-all safe.directory '*'`) to avoid "dubious ownership" errors. Use `--replace-all` (not `--add`) so repeated runs across multiple self-hosted actions replace rather than accumulate entries in the global git config.
+- Pinning action inputs explicitly (`sticky_summary`, `incremental`, `upload_artifacts`, `llm_extra_body`, etc.).
+
+The action performs its own full `fetch-depth: 0` checkout of the PR internally, so no extra checkout step is needed for the review diff. Adapt the runner settings to your environment and secret layout.
## How It Works
@@ -10,40 +44,39 @@ PR Created/Updated → GitHub Actions Triggered → OCR Reviews Diff → Comment
Comment with trigger keyword ↗
```
-1. When a PR is opened, the workflow triggers (uses `pull_request_target` for fork secret access)
-2. Alternatively, when a comment containing `/open-code-review` or `@open-code-review` is posted on a PR, the workflow triggers
-3. It installs OCR via `npm install -g @alibaba-group/open-code-review`
-4. Runs `ocr review --from origin/ --to --format json` to analyze the diff (uses commit SHA to support fork PRs)
-5. Parses the JSON output and posts inline review comments on the PR using GitHub's Pull Request Review API
+1. When a PR is opened, the workflow triggers (uses `pull_request_target` for fork secret access).
+2. Alternatively, when a comment containing `/open-code-review` or `@open-code-review` is posted on a PR, the workflow triggers.
+3. The reusable action installs OCR, fetches the PR head blobs, computes `git merge-base`, and runs `ocr review --from --to --format json`.
+4. It parses the JSON output and posts inline review comments on the PR via the Pull Request Review API, plus a summary comment (an issue comment on the PR).
## Setup
-### 1. Copy the workflow file
+### Configure secrets and variables
-Copy `ocr-review.yml` to your repository's `.github/workflows/` directory:
+Go to your repository's **Settings → Secrets and variables → Actions**.
-```bash
-mkdir -p .github/workflows
-cp ocr-review.yml .github/workflows/ocr-review.yml
-```
-
-### 2. Configure secrets
-
-Go to your repository's **Settings → Secrets and variables → Actions** and add:
+**Secrets:**
| Secret | Required | Description |
|--------|----------|-------------|
| `OCR_LLM_URL` | Yes | LLM API endpoint URL (e.g., `https://api.openai.com/v1/chat/completions`) |
-| `OCR_LLM_AUTH_TOKEN` | Yes | API authentication token |
-| `OCR_LLM_MODEL` | No | Model name (defaults to `gpt-4o`) |
-| `OCR_LLM_USE_ANTHROPIC` | No | Set to `true` if using Anthropic Claude models |
+| `OCR_LLM_AUTH_TOKEN` | Yes | API authentication token (mapped to env `OCR_LLM_TOKEN` internally) |
-> **Note:** `GITHUB_TOKEN` is automatically provided by GitHub Actions with the required `pull-requests: write` permission.
->
-> The workflow also configures `llm.extra_body` to disable thinking mode for compatibility with various LLM providers.
+**Variables:**
+
+| Variable | Required | Description |
+|----------|----------|-------------|
+| `OCR_LLM_MODEL` | Yes | Model name |
+| `OCR_LLM_USE_ANTHROPIC` | Yes | `true` for Anthropic Claude, `false` for OpenAI-compatible |
+
+> **Note:** `GITHUB_TOKEN` is automatically provided by GitHub Actions with the required `pull-requests: write` permission. The action also sets `llm.extra_body` to disable thinking mode for compatibility with various LLM providers.
## Customization
+> These knobs are action inputs — they apply to the demo workflow and any workflow calling `alibaba/open-code-review@main`.
+
+See [`action.yml`](../../action.yml) for the full input list. Workflow-level settings (triggers, keywords) are edited in the workflow file itself.
+
### Change the trigger events
Modify the `on.pull_request_target.types` array in the workflow file:
@@ -56,76 +89,152 @@ on:
### Customize comment trigger keywords
-By default, the workflow triggers when a PR comment starts with `/open-code-review` or `@open-code-review`. You can customize these keywords by modifying the `if` condition in the workflow:
+By default the workflow also re-reviews on demand when a PR comment starts with `/open-code-review` or `@open-code-review`. The `if` condition is more defensive than a bare keyword check — it gates comment triggers so only authorized humans can spend LLM quota:
```yaml
if: |
- github.event_name == 'pull_request_target' ||
- (github.event_name == 'issue_comment' && github.event.issue.pull_request && startsWith(github.event.comment.body, '/review')) ||
- (github.event_name == 'issue_comment' && github.event.issue.pull_request && startsWith(github.event.comment.body, '@mybot'))
+ github.event_name == 'pull_request_target'
+ || (
+ github.event_name == 'issue_comment'
+ && github.event.issue.pull_request
+ && github.event.comment.user.type != 'Bot'
+ && (
+ github.event.comment.author_association == 'MEMBER'
+ || github.event.comment.author_association == 'OWNER'
+ || github.event.comment.author_association == 'COLLABORATOR'
+ )
+ && (
+ startsWith(github.event.comment.body, '/open-code-review')
+ || startsWith(github.event.comment.body, '@open-code-review')
+ )
+ )
```
-Or use a more flexible pattern with `contains` to trigger on any comment containing the keyword:
+Each clause guards against a different abuse vector:
-```yaml
-if: |
- github.event_name == 'pull_request_target' ||
- (github.event_name == 'issue_comment' && github.event.issue.pull_request && contains(github.event.comment.body, '/review'))
-```
+- `github.event.issue.pull_request` — the comment must be on a PR, not a regular issue.
+- `github.event.comment.user.type != 'Bot'` — ignore bot comments. `GITHUB_TOKEN` already suppresses events from comments it posted, but a PAT or GitHub App token would not, so this is a safety net against self-triggering loops.
+- `author_association == 'MEMBER' | 'OWNER' | 'COLLABORATOR'` — only repository collaborators can trigger a (billable) re-review, preventing arbitrary commenters from draining LLM quota.
+- The `startsWith(...)` pair — the actual trigger keywords.
-> **Note:** The condition `github.event.issue.pull_request` ensures the comment is on a PR, not a regular issue.
+To change the keywords, edit only that final pair (e.g. `/review` and `@mybot`), or swap `startsWith` for `contains` to match a substring anywhere in the comment body. Keep the preceding guards intact.
+
+The same predicate is mirrored in the workflow's `concurrency.group`: matching events share a per-PR group (`ocr-`) so a new review cancels any stale one, while non-matching comments land in a unique `noop-` group and are skipped instantly without disrupting a running review. If you change the keywords in `if`, mirror the change in `concurrency.group` too.
### Use a specific OCR version
```yaml
-- name: Install OpenCodeReview
- run: npm install -g @alibaba-group/open-code-review@1.0.0
+- uses: alibaba/open-code-review@main
+ with:
+ ocr_version: 1.0.0
```
### Add custom review rules
-Use the `--rule` flag to pass a custom rules JSON file:
+```yaml
+- uses: alibaba/open-code-review@main
+ with:
+ rule: ./my-rules.json
+```
+
+> Security: do not point `rule` at a file sourced from the PR branch when secrets are in scope; use a trusted rules file from your base branch.
+
+### Control comment posting (sticky summary & incremental)
+
+The action posts a summary issue comment plus inline review comments. Two inputs select the posting mode (combined, they give the four modes referenced above); a third tunes the incremental overlap test:
+
+| Input | Default | Description |
+|-------|---------|-------------|
+| `sticky_summary` | `'true'` | Update an existing summary comment in place instead of posting a new one each run. |
+| `incremental` | `'false'` | Only append inline comments whose `(path, line range)` does not overlap an existing bot review comment. History is never deleted (non-destructive). |
+| `incremental_overlap_threshold` | `'0.6'` | IoU threshold `incremental` uses to decide whether a multi-line comment overlaps an existing one. Two single-line comments match on the same line; single- vs multi-line never match. Ignored unless `incremental` is `'true'`. |
```yaml
-- name: Run OCR review
- run: ocr review --rule ./my-rules.json --from origin/${{ github.base_ref }} --to origin/${{ github.head_ref }}
+- uses: alibaba/open-code-review@main
+ with:
+ sticky_summary: 'true'
+ incremental: 'true'
+ incremental_overlap_threshold: '0.75'
```
+> `sticky_summary` and `incremental` must be quoted strings (`'true'`/`'false'`); the action compares them as strings, so an unquoted YAML boolean will not match.
+
### Adjust retry and delay settings
-When posting review comments individually (fallback mode), the workflow includes rate-limit handling with exponential backoff. The retry strategy follows GitHub's documented guidance for REST API rate limits — see [Rate limits for the REST API](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api?apiVersion=2026-03-10) for details on primary/secondary rate limits and recommended retry behavior. You can configure the retry and delay behavior via **repository variables** (Settings → Secrets and variables → Actions → Variables):
+When posting review comments individually (fallback mode), the action honors GitHub rate-limit headers (`retry-after`, `x-ratelimit-*`) with exponential backoff. The retry strategy follows GitHub's documented guidance for REST API rate limits — see [Rate limits for the REST API](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api?apiVersion=2026-03-10) for details on primary/secondary rate limits and recommended retry behavior:
+
+- **Primary rate limit exhausted** (`x-ratelimit-remaining=0`): wait until `x-ratelimit-reset`.
+- **Secondary rate limit with a `retry-after` header**: wait exactly that long.
+- **Secondary rate limit with no header**: wait at least one minute, then use exponential backoff on continued failures.
+
+These are environment variables read by the posting module with sensible defaults; set them at the **job `env:` level** to tune (they propagate into the action):
| Variable | Default | Description |
|----------|---------|-------------|
-| `OCR_RETRY_BASE_DELAY` | `60000` | Base delay (ms) for exponential backoff when no retry header is present (per GitHub's "at least one minute" recommendation for secondary limits) |
-| `OCR_RETRY_MAX_DELAY` | `300000` | Maximum delay (ms) cap applied to every computed wait, including retry-after and x-ratelimit-reset, so a far-future reset cannot stall the job past its timeout |
+| `OCR_RETRY_BASE_DELAY` | `60000` | Base delay (ms) for exponential backoff when no retry header is present |
+| `OCR_RETRY_MAX_DELAY` | `300000` | Maximum delay (ms) cap applied to every computed wait |
| `OCR_MAX_RETRIES` | `3` | Maximum retry attempts per comment when rate-limited |
-| `OCR_SUCCESS_DELAY` | `2000` | Delay (ms) after a successful comment post to pace subsequent requests |
-| `OCR_FAILURE_DELAY` | `1000` | Delay (ms) after a non-rate-limit failure to pace subsequent requests |
-| `OCR_LOW_REMAINING_THRESHOLD` | `3` | When x-ratelimit-remaining is at or below this value, proactively increase request spacing to avoid hitting the limit |
+| `OCR_SUCCESS_DELAY` | `2000` | Delay (ms) after a successful comment post |
+| `OCR_FAILURE_DELAY` | `1000` | Delay (ms) after a non-retryable failure |
+| `OCR_LOW_REMAINING_THRESHOLD` | `3` | When x-ratelimit-remaining is at or below this value, proactively increase request spacing |
| `OCR_LOW_REMAINING_SPACING` | `10000` | Request spacing (ms) used when remaining quota is low |
+| `OCR_READ_SUCCESS_DELAY` | `500` | Delay (ms) after a successful read API call (`listReviews` / `listReviewComments` / `listIssueComments`) used for the idempotency check. Reads are cheaper than writes, so the default is shorter |
+| `OCR_READ_LOW_REMAINING_SPACING` | `5000` | Request spacing (ms) for read calls when remaining quota is low |
-These variables are optional — if not configured, sensible defaults are used. Consider increasing delays for repositories with many concurrent workflows or large PRs that generate numerous review comments.
-
-### Limit concurrency
-
-Adjust the `--concurrency` flag for large PRs to control the number of concurrent LLM requests:
+For example, to raise the per-comment retry count to 5, set `OCR_MAX_RETRIES` on the **job's** `env:` — not on the `uses:` step. A composite action does not forward the caller's step-level `env:` into its internal steps' process environment, so a step-level value would be silently ignored; the job-level value is inherited by the action's comment-posting step and read via `process.env`:
```yaml
-- name: Run OCR review
- run: ocr review --concurrency 5 --from origin/${{ github.base_ref }} --to origin/${{ github.head_ref }}
+jobs:
+ code-review:
+ runs-on: ubuntu-latest
+ env:
+ OCR_MAX_RETRIES: 5
+ steps:
+ - uses: alibaba/open-code-review@main
+ with:
+ llm_url: ${{ secrets.OCR_LLM_URL }}
+ # ...other inputs
+```
+
+These variables are optional. See GitHub's [Rate limits for the REST API](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api).
+
+#### Idempotency: avoiding duplicate review comments
+
+When the batch `createReview` call fails with a `5xx` error, the request may still have landed on the GitHub server (the response was simply lost). Before retrying per-comment, the action queries existing reviews and review comments — each tagged with a per-run HTML comment (e.g. ``) — and only retries the comments that are actually missing. This prevents duplicate review posts.
+
+The summary comment is deduplicated too: in sticky mode (the default) the action finds the existing summary by its persistent marker and updates it in place rather than posting a new one; in non-sticky mode it reuses this run's summary if it already exists. If the read API is unavailable, it skips posting the summary rather than risking a duplicate.
+
+If the read API itself is unavailable (rate-limited or `5xx`), the check returns *unknown* rather than assuming the comment was not posted. In that case the action **skips retrying** to avoid risking a duplicate, and surfaces the uncertainty in the summary instead of silently producing duplicates.
+
+### Limit LLM concurrency
+
+```yaml
+- uses: alibaba/open-code-review@main
+ with:
+ review_concurrency: 5
```
### Provide background context
-Use the `--background` flag to pass additional context that helps OCR better understand the purpose of the changes:
-
```yaml
-- name: Run OCR review
- run: ocr review --background "${{ github.event.pull_request.title }}" --from origin/${{ github.base_ref }} --to origin/${{ github.head_ref }}
+- uses: alibaba/open-code-review@main
+ with:
+ background: ${{ github.event.pull_request.title }}
```
-This is particularly useful when your PR titles follow semantic conventions (e.g., `feat(auth): add OAuth2 support`) that clearly summarize what the PR implements. The background information helps OCR provide more relevant and context-aware review comments.
+Particularly useful when PR titles follow semantic conventions (e.g., `feat(auth): add OAuth2 support`).
+
+> Note: `github.event.pull_request.title` is only present on `pull_request_target` events, so it is empty for comment-triggered re-reviews. To cover both trigger types, have the pr-context step also output the title and fall back to it:
+>
+> ```yaml
+> # inside the pr-context script (which only runs for issue_comment):
+> core.setOutput('title', pullRequest.title);
+> ```
+> ```yaml
+> - uses: alibaba/open-code-review@main
+> with:
+> background: ${{ steps.pr-context.outputs.title || github.event.pull_request.title }}
+> ```
### Customize the review comment author with GitHub App
@@ -168,40 +277,53 @@ Add the following secrets to your repository (**Settings → Secrets and variabl
|--------|-------------|
| `GITHUB_APP_ID` | Your GitHub App's ID |
| `GITHUB_APP_PRIVATE_KEY` | Contents of the `.pem` file (including `-----BEGIN RSA PRIVATE KEY-----` and `-----END RSA PRIVATE KEY-----`) |
-| `GITHUB_APP_INSTALLATION_ID` | The Installation ID from Step 3 |
+| `GITHUB_APP_INSTALLATION_ID` | (Optional) The Installation ID from Step 3 — only needed for apps with multiple installations |
-#### Step 5: Update the Workflow
+#### Step 5: Pass the App token to the action
-Add a step to obtain a token from the GitHub App, then use it in the "Post review comments to PR" step:
+Mint a token with `actions/create-github-app-token` and pass it via the `github_token` input:
```yaml
- name: Get GitHub App Token
id: app-token
- uses: actions/create-github-app-token@v1
+ uses: actions/create-github-app-token@main
with:
app-id: ${{ secrets.GITHUB_APP_ID }}
private-key: ${{ secrets.GITHUB_APP_PRIVATE_KEY }}
-- name: Post review comments to PR
- uses: actions/github-script@v7
+- uses: alibaba/open-code-review@main
with:
- github-token: ${{ steps.app-token.outputs.token }}
- script: |
- # ... existing script
+ github_token: ${{ steps.app-token.outputs.token }}
+ llm_url: ${{ secrets.OCR_LLM_URL }}
+ llm_auth_token: ${{ secrets.OCR_LLM_AUTH_TOKEN }}
+ llm_model: ${{ vars.OCR_LLM_MODEL }}
+ llm_use_anthropic: ${{ vars.OCR_LLM_USE_ANTHROPIC }}
```
Now review comments will be posted with your custom GitHub App identity (e.g., `OpenCodeReview Bot`), providing a more professional and distinguishable appearance in your PRs.
## Example Output
-When a PR is reviewed, comments appear directly in the PR's "Files changed" tab:
+The action posts two kinds of output on the PR: a **summary issue comment** (in the PR conversation) and **inline review comments** (in the "Files changed" tab).
-- ✅ If no issues found: A comment saying "No comments generated. Looks good to me."
-- 🔍 If issues found: Inline review comments with suggestions using GitHub's native suggestion syntax
+### Summary comment
-### Inline Comment Example
+A single comment — updated in place on each run when `sticky_summary` is `'true'` (the default) — carries the review outcome and posting statistics.
-The workflow uses GitHub's `suggestion` code block syntax, so reviewers can apply fixes with one click:
+- ✅ No issues: `✅ **OpenCodeReview**: No comments generated. Looks good to me.`
+- 🔍 Issues found: a header line plus per-outcome counts, for example:
+
+```markdown
+🔍 **OpenCodeReview** found **3** issue(s) in this PR.
+- ✅ Successfully posted inline: 2 comment(s)
+- 📝 In summary (no line info): 1 comment(s)
+```
+
+The counts are mutually exclusive and sum to the total: `inline` (landed as review inline comments), `summary` (no line info, rendered in the summary body), `skipped` (suppressed by incremental overlap filtering), and `failed` (had line info but could not be posted). Any warnings are appended as a bulleted list.
+
+### Inline comments
+
+Comments with valid line info are posted as PR review comments in "Files changed". Each carries the review content plus, when a fix is available, a GitHub-native `suggestion` block so reviewers can apply it with one click:
````markdown
**Suggestion:**
@@ -210,6 +332,8 @@ The workflow uses GitHub's `suggestion` code block syntax, so reviewers can appl
```
````
+Comments that have no line info, or that could not be posted inline (e.g. their line fell outside the current diff), are rendered in the summary body instead — each under a `### 📄 ` heading, with a collapsible `` "💡 Suggested Change" (Before/After) when a fix is available.
+
## Supported LLM Providers
OCR supports both OpenAI and Anthropic API formats:
@@ -218,22 +342,25 @@ OCR supports both OpenAI and Anthropic API formats:
- OpenAI (GPT-4o, GPT-4, etc.)
- Azure OpenAI
- Self-hosted models (vLLM, Ollama, etc.)
-- **Anthropic APIs** (set `OCR_LLM_USE_ANTHROPIC: true`):
+- **Anthropic APIs** (set variable `OCR_LLM_USE_ANTHROPIC=true`, i.e. `llm_use_anthropic: true`):
- Anthropic Claude models
## Troubleshooting
### Common Issues
-1. **"Failed to parse OCR output"**: Check that `OCR_LLM_URL` and `OCR_LLM_AUTH_TOKEN` secrets are correctly set
-2. **"Cannot find merge-base"**: Ensure `fetch-depth: 0` is set in the checkout step
-3. **Review comments not appearing on correct lines**: This can happen when the diff has changed since the review started; the workflow handles this gracefully with a fallback to issue comments
+1. **Job fails / "Failed to parse OCR output"**: When `ocr review` exits non-zero the action fails the job with that exit code (the comment-posting step is skipped); a zero exit with malformed JSON surfaces as a parse error in the summary. In both cases, check that `OCR_LLM_URL` and `OCR_LLM_AUTH_TOKEN` are set correctly, then inspect the uploaded `ocr-stderr.log` artifact (also printed in the "Run OpenCodeReview" step log) for the underlying error.
+2. **"Cannot find merge-base"**: The action fetches full history (`fetch-depth: 0`) and the PR head (`git fetch origin pull//head`); if this still fails, ensure `permissions: contents: read` is set and the base branch is accessible (e.g., not deleted).
+3. **Review comments not on the expected lines**: Comments are attached to the PR head commit. If a comment's line falls outside the current diff (the PR was force-pushed or updated mid-review), GitHub rejects the inline post and the comment is rendered in the summary instead. The workflow's concurrency group cancels stale runs on new pushes.
+4. **No summary or comments at all**: Confirm the job's `permissions` include `pull-requests: write`, and that `github_token` (defaults to `${{ github.token }}`) is not overridden with a token lacking those scopes.
### Debugging
-Enable debug logging by adding to the OCR review step:
+The action does not use an `OCR_DEBUG` flag. To diagnose a run:
-```yaml
-env:
- OCR_DEBUG: "1"
-```
+- **Artifacts**: with `upload_artifacts: 'true'` (the default), the raw `ocr-result.json` and `ocr-stderr.log` are uploaded as workflow artifacts named `ocr-review-result--`. Download them from the run's **Artifacts** section.
+- **Step log**: the "Run OpenCodeReview" step prints both the JSON result and stderr to the workflow log.
+- **Action outputs**: the step exposes `comments_total`, `comments_inline`, `comments_skipped`, `comments_failed`, and `summary_comment_url` outputs — inspect them in the job's step outputs.
+- **GitHub step debug**: for verbose Actions runner diagnostics, enable the repository secret `ACTIONS_STEP_DEBUG=true` (standard GitHub Actions mechanism).
+
+To stop uploading the raw artifacts, set `upload_artifacts: 'false'`.
diff --git a/examples/github_actions/ocr-review.yml b/examples/github_actions/ocr-review.yml
index 360eb2e..11a57a3 100644
--- a/examples/github_actions/ocr-review.yml
+++ b/examples/github_actions/ocr-review.yml
@@ -1,59 +1,64 @@
# OpenCodeReview - GitHub Actions PR Auto-Review Demo
#
-# This workflow automatically reviews pull requests using OpenCodeReview
-# and posts review comments directly on the PR.
+# Demonstrates invoking the reusable action for both automatic PR review
+# (pull_request_target: opened/synchronize/reopened) and on-demand re-review
+# via comments starting with '/open-code-review' or '@open-code-review'.
#
-# Triggers:
-# - PR opened (uses pull_request_target for fork secret access)
-# - Comment on PR containing '/open-code-review' or '@open-code-review'
+# Required secrets/vars (Settings -> Secrets and variables -> Actions):
+# secret OCR_LLM_URL LLM API endpoint
+# secret OCR_LLM_AUTH_TOKEN LLM auth token (mapped to OCR_LLM_TOKEN)
+# variable OCR_LLM_MODEL model name
+# variable OCR_LLM_USE_ANTHROPIC 'true' for Anthropic, 'false' for OpenAI-compatible
#
-# Required secrets:
-# OCR_LLM_URL - LLM API endpoint (e.g., https://api.openai.com/v1/chat/completions)
-# OCR_LLM_AUTH_TOKEN - Authentication token for the LLM API
-#
-# Optional secrets:
-# OCR_LLM_MODEL - Model name (default: gpt-4o)
-# OCR_LLM_USE_ANTHROPIC - Set to 'true' if using Anthropic Claude models
-#
-# Optional variables (for retry/delay tuning):
-# The retry strategy follows GitHub's documented guidance for REST API rate limits:
-# https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api
-# - Primary rate limit exhausted (x-ratelimit-remaining=0): wait until x-ratelimit-reset.
-# - Secondary rate limit with a retry-after header: wait exactly that long.
-# - Secondary rate limit with NO header: wait at least one minute, then use
-# exponential backoff on continued failures.
-#
-# OCR_RETRY_BASE_DELAY - Base delay (ms) for exponential backoff when no retry
-# header is present (default: 60000, per GitHub's
-# "at least one minute" recommendation for secondary limits).
-# OCR_RETRY_MAX_DELAY - Maximum delay (ms) cap applied to EVERY computed wait,
-# including retry-after and x-ratelimit-reset, so a far-future
-# reset cannot stall the job past its timeout (default: 300000 = 5 min).
-# OCR_MAX_RETRIES - Max retry attempts per comment when rate-limited (default: 3).
-# OCR_SUCCESS_DELAY - Delay (ms) between successful comment posts to pace requests (default: 2000).
-# OCR_FAILURE_DELAY - Delay (ms) after a non-retryable failure to pace subsequent requests (default: 1000).
-# OCR_LOW_REMAINING_THRESHOLD - When x-ratelimit-remaining is at or below this value,
-# proactively increase request spacing to avoid hitting the limit
-# (default: 3; GitHub best practice is to watch the header and slow down).
-# OCR_LOW_REMAINING_SPACING - Request spacing (ms) used when remaining quota is low
-# (default: 10000 = 10s).
-#
-# Note: GITHUB_TOKEN is automatically provided by GitHub Actions.
-# Note: The workflow also configures llm.extra_body to '{"thinking": {"type": "disabled"}}'
-# to disable thinking mode for compatibility with various LLM providers.
+# For the full list of action inputs/outputs and the four comment-posting modes
+# (sticky / incremental), see action.yml at the repo root.
name: OpenCodeReview PR Review
+# Conditional concurrency group.
+#
+# GitHub Actions evaluates concurrency BEFORE job-level if-conditions. With a
+# flat group (ocr-), every comment on the PR — even an unrelated
+# conversation reply that will be skipped — enters the same group and, because
+# cancel-in-progress is true, cancels any in-progress review. The result: a
+# single normal comment kills a running review, and you see "two runs, one
+# cancelled" in the Actions tab.
+#
+# Fix: matching events (PR events + /open-code-review comments) share a per-PR
+# group so a new review cancels any stale one for the same PR. Non-matching
+# comments land in a unique noop- group that can never collide with a
+# real review, so they are skipped instantly without disrupting anything.
concurrency:
- group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+ group: >-
+ ${{
+ (
+ github.event_name == 'pull_request_target'
+ || (
+ github.event_name == 'issue_comment'
+ && github.event.issue.pull_request
+ && github.event.comment.user.type != 'Bot'
+ && (
+ github.event.comment.author_association == 'MEMBER'
+ || github.event.comment.author_association == 'OWNER'
+ || github.event.comment.author_association == 'COLLABORATOR'
+ )
+ && (
+ startsWith(github.event.comment.body, '/open-code-review')
+ || startsWith(github.event.comment.body, '@open-code-review')
+ )
+ )
+ )
+ && format('ocr-{0}', github.event.pull_request.number || github.event.issue.number)
+ || format('noop-{0}', github.run_id)
+ }}
cancel-in-progress: true
on:
# Use pull_request_target instead of pull_request so that secrets are
- # available even for PRs from forks. This is safe because OCR only reads
- # the diff and does not execute any code from the PR.
+ # available even for PRs from forks. This is safe because the reusable
+ # action only reads the diff and does not execute any code from the PR.
pull_request_target:
- types: [opened]
+ types: [opened, synchronize, reopened]
issue_comment:
types: [created]
@@ -64,19 +69,38 @@ permissions:
jobs:
code-review:
runs-on: ubuntu-latest
- # Run on PR events, or on comments starting with trigger keywords
+ timeout-minutes: 30
+ # Run on PR events, or on human-authored comments starting with trigger
+ # keywords. Bot comments are excluded as a safety net: GITHUB_TOKEN already
+ # suppresses events from bot-posted comments, but a PAT/App token would not.
+ # issue_comment triggers are further gated on author_association so only
+ # MEMBER/OWNER/COLLABORATOR users can spend LLM quota via re-review.
if: |
- github.event_name == 'pull_request_target' ||
- (github.event_name == 'issue_comment' && github.event.issue.pull_request && startsWith(github.event.comment.body, '/open-code-review')) ||
- (github.event_name == 'issue_comment' && github.event.issue.pull_request && startsWith(github.event.comment.body, '@open-code-review'))
+ github.event_name == 'pull_request_target'
+ || (
+ github.event_name == 'issue_comment'
+ && github.event.issue.pull_request
+ && github.event.comment.user.type != 'Bot'
+ && (
+ github.event.comment.author_association == 'MEMBER'
+ || github.event.comment.author_association == 'OWNER'
+ || github.event.comment.author_association == 'COLLABORATOR'
+ )
+ && (
+ startsWith(github.event.comment.body, '/open-code-review')
+ || startsWith(github.event.comment.body, '@open-code-review')
+ )
+ )
steps:
- name: Get PR context
id: pr-context
- if: github.event_name != 'pull_request_target'
+ if: github.event_name == 'issue_comment'
uses: actions/github-script@v7
with:
script: |
- // For issue_comment events, get PR info
+ // For issue_comment events, resolve PR base/head so the action
+ // can review the right diff (issue_comment has no top-level
+ // pull_request payload fields).
const prNumber = context.issue.number;
const { data: pullRequest } = await github.rest.pulls.get({
owner: context.repo.owner,
@@ -84,469 +108,16 @@ jobs:
pull_number: prNumber
});
core.setOutput('base_ref', pullRequest.base.ref);
- core.setOutput('head_ref', pullRequest.head.ref);
core.setOutput('head_sha', pullRequest.head.sha);
- - name: Checkout repository
- uses: actions/checkout@v4
- with:
- fetch-depth: 0 # Full history needed for merge-base diff
- ref: ${{ github.event.pull_request.head.sha || steps.pr-context.outputs.head_sha }}
-
- - name: Fetch PR head ref (ensures fork commits are available)
- run: git fetch origin pull/${{ github.event.pull_request.number || github.event.issue.number }}/head
-
- - name: Setup Node.js
- uses: actions/setup-node@v4
- with:
- node-version: '24'
-
- - name: Install OpenCodeReview
- run: |
- npm install -g @alibaba-group/open-code-review
- echo "OpenCodeReview installed with version:"
- ocr version || true
-
- - name: Configure OCR
- run: |
- ocr config set llm.url ${{ secrets.OCR_LLM_URL }}
- ocr config set llm.auth_token ${{ secrets.OCR_LLM_AUTH_TOKEN }}
- ocr config set llm.model ${{ secrets.OCR_LLM_MODEL }}
- ocr config set llm.use_anthropic ${{ secrets.OCR_LLM_USE_ANTHROPIC }}
- ocr config set llm.extra_body '{"thinking": {"type": "disabled"}}'
-
- name: Run OpenCodeReview
- id: review
- run: |
- # Get base ref and head SHA from PR context (different for comment triggers)
- # Note: We use HEAD_SHA instead of origin/${HEAD_REF} to support fork PRs,
- # because fork branches don't exist on the origin remote.
- if [ "${{ github.event_name }}" = "pull_request_target" ]; then
- BASE_REF="${{ github.event.pull_request.base.ref }}"
- HEAD_SHA="${{ github.event.pull_request.head.sha }}"
- else
- BASE_REF="${{ steps.pr-context.outputs.base_ref }}"
- HEAD_SHA="${{ steps.pr-context.outputs.head_sha }}"
- fi
-
- echo "Reviewing PR: ${HEAD_SHA} against origin/${BASE_REF}"
-
- # Run OCR in range mode with JSON output
- ocr review \
- --from "origin/${BASE_REF}" \
- --to "${HEAD_SHA}" \
- --format json \
- > /tmp/ocr-result.json 2>/tmp/ocr-stderr.log || true
-
- echo "OCR review completed. Output:"
- cat /tmp/ocr-result.json
- echo "OCR review completed. Error log:"
- cat /tmp/ocr-stderr.log
-
- - name: Post review comments to PR
- uses: actions/github-script@v7
+ uses: alibaba/open-code-review@main
with:
- github-token: ${{ secrets.GITHUB_TOKEN }}
- script: |
- const fs = require('fs');
- const path = '/tmp/ocr-result.json';
-
- // Read OCR output
- let result;
- try {
- const raw = fs.readFileSync(path, 'utf8');
- result = JSON.parse(raw);
- } catch (e) {
- console.log('Failed to parse OCR output:', e.message);
- // Post a simple comment if parsing fails
- const stderr = fs.readFileSync('/tmp/ocr-stderr.log', 'utf8').trim();
- if (stderr) {
- await github.rest.issues.createComment({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: context.issue.number,
- body: `⚠️ **OpenCodeReview** encountered an error:\n${fencedBlock(stderr)}`
- });
- }
- return;
- }
-
- const comments = result.comments || [];
- const warnings = result.warnings || [];
-
- // If no comments, post a summary
- if (comments.length === 0) {
- const message = result.message || 'No comments generated. Looks good to me.';
- await github.rest.issues.createComment({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: context.issue.number,
- body: `✅ **OpenCodeReview**: ${message}`
- });
- return;
- }
-
- // Prepare PR review with inline comments
- const prNumber = context.issue.number;
- let commitSha;
-
- // Get commit SHA from event context
- if (context.eventName === 'pull_request_target') {
- commitSha = context.payload.pull_request.head.sha;
- } else {
- // For comment events, we need to fetch the PR
- const { data: pullRequest } = await github.rest.pulls.get({
- owner: context.repo.owner,
- repo: context.repo.repo,
- pull_number: prNumber
- });
- commitSha = pullRequest.head.sha;
- }
-
- // Build review comments array for the PR review API
- // Only inline comments with line info can be posted via createReview
- const reviewComments = [];
- const commentsWithoutLine = [];
-
- for (const comment of comments) {
- const body = formatComment(comment);
-
- // Check if comment has valid line information for inline comment (line >= 1)
- const hasValidLine = (comment.start_line >= 1) || (comment.end_line >= 1);
- if (!hasValidLine) {
- commentsWithoutLine.push({ comment, body });
- continue;
- }
-
- const reviewComment = {
- path: comment.path,
- body: body
- };
-
- // Use line range if available
- if (comment.start_line >= 1 && comment.end_line >= 1 && comment.start_line !== comment.end_line) {
- reviewComment.start_line = comment.start_line;
- reviewComment.line = comment.end_line;
- reviewComment.start_side = 'RIGHT';
- reviewComment.side = 'RIGHT';
- } else if (comment.end_line >= 1) {
- reviewComment.line = comment.end_line;
- reviewComment.side = 'RIGHT';
- } else if (comment.start_line >= 1) {
- reviewComment.line = comment.start_line;
- reviewComment.side = 'RIGHT';
- }
-
- reviewComments.push({ comment, reviewComment });
- }
-
- // Submit as a single PR review with all comments
- const totalCount = comments.length;
- const inlineCount = reviewComments.length;
- const summaryCount = commentsWithoutLine.length;
- let summaryBody = buildSummaryBody(totalCount, inlineCount, summaryCount, warnings);
-
- // Add comments without line info to summary body
- summaryBody += formatSummaryComments(commentsWithoutLine);
-
- // Statistics tracking
- let successCount = 0;
- let failedCount = 0;
- const failedComments = [];
-
- try {
- const batchRes = await github.rest.pulls.createReview({
- owner: context.repo.owner,
- repo: context.repo.repo,
- pull_number: prNumber,
- commit_id: commitSha,
- body: summaryBody,
- event: 'COMMENT',
- comments: reviewComments.map(({ reviewComment }) => reviewComment)
- });
- successCount = reviewComments.length;
- console.log(`Successfully posted review with ${successCount} inline comments (${commentsWithoutLine.length} in summary)`);
- logRateLimitQuota(batchRes, 'after batch createReview');
- } catch (e) {
- console.log('Failed to post review with inline comments:', e.message);
- console.log('Falling back to posting comments individually with rate-limit-aware retry...');
-
- // Fallback: post comments one by one with delay to avoid secondary rate limits.
- // GitHub enforces ~80 content-generating requests per minute; spacing calls
- // helps stay under that threshold. Retry/wait durations are derived from the
- // rate-limit response headers per GitHub's documented strategy.
- const MAX_RETRIES = parseInt(process.env.OCR_MAX_RETRIES, 10) || 3;
- const SUCCESS_DELAY = parseInt(process.env.OCR_SUCCESS_DELAY, 10) || 2000; // delay after successful post
- const FAILURE_DELAY = parseInt(process.env.OCR_FAILURE_DELAY, 10) || 1000; // delay after non-retryable failure
- const LOW_REMAINING_THRESHOLD = parseInt(process.env.OCR_LOW_REMAINING_THRESHOLD, 10) || 3;
- const LOW_REMAINING_SPACING = parseInt(process.env.OCR_LOW_REMAINING_SPACING, 10) || 10000;
-
- // If the batch itself was rate-limited, honor its rate-limit headers
- // (retry-after / x-ratelimit-reset) before retrying per-comment,
- // otherwise the first per-comment call re-hits the same wall immediately.
- const batchRetry = computeRetryDelayMs(e, 0);
- if (batchRetry != null) {
- const secs = (batchRetry.delayMs / 1000).toFixed(1);
- console.log(
- `Batch createReview was rate-limited (HTTP ${e.status}). ` +
- `Cooling down ${secs}s via '${batchRetry.source}' (${batchRetry.detail}) before per-comment retry.`
- );
- await sleep(batchRetry.delayMs);
- }
-
- for (const { comment, reviewComment } of reviewComments) {
- let posted = false;
- for (let attempt = 0; attempt <= MAX_RETRIES && !posted; attempt++) {
- try {
- const res = await github.rest.pulls.createReview({
- owner: context.repo.owner,
- repo: context.repo.repo,
- pull_number: prNumber,
- commit_id: commitSha,
- body: '',
- event: 'COMMENT',
- comments: [reviewComment]
- });
- successCount++;
- posted = true;
- console.log(`Successfully posted comment for ${reviewComment.path}`);
- // Proactive throttle: if remaining quota is low, slow down to
- // avoid hitting the limit (GitHub best practice: watch the header).
- const remaining = logRateLimitQuota(res, `after ${reviewComment.path}`);
- const lowQuota = remaining != null && remaining <= LOW_REMAINING_THRESHOLD;
- if (lowQuota) {
- console.log(`[rate-limit] quota low (remaining=${remaining} <= ${LOW_REMAINING_THRESHOLD}); increasing spacing to ${LOW_REMAINING_SPACING}ms.`);
- await sleep(LOW_REMAINING_SPACING);
- } else {
- await sleep(SUCCESS_DELAY);
- }
- } catch (innerE) {
- // Decide whether to retry and how long to wait, based on GitHub's
- // rate-limit documentation (retry-after / x-ratelimit-* headers).
- const retryInfo = computeRetryDelayMs(innerE, attempt);
- const willRetry = retryInfo != null && attempt < MAX_RETRIES;
- if (willRetry) {
- const secs = (retryInfo.delayMs / 1000).toFixed(1);
- console.log(
- `Rate-limited/transient error on ${reviewComment.path} ` +
- `(HTTP ${innerE.status}, attempt ${attempt + 1}/${MAX_RETRIES}). ` +
- `Waiting ${secs}s via '${retryInfo.source}' (${retryInfo.detail}). ` +
- `Error: ${innerE.message}`
- );
- await sleep(retryInfo.delayMs);
- } else {
- failedCount++;
- failedComments.push({ comment, error: innerE.message });
- const reason = retryInfo == null ? 'non-retryable error' : 'rate-limit retries exhausted';
- console.log(`Failed to post comment for ${reviewComment.path} (${reason}, HTTP ${innerE.status || 'n/a'}): ${innerE.message}`);
- // After exhausting retries use the success-style pace delay;
- // for other errors use the shorter failure pace delay.
- await sleep(retryInfo == null ? FAILURE_DELAY : SUCCESS_DELAY);
- break;
- }
- }
- }
- }
-
- // Post summary comment with statistics
- let finalBody = buildSummaryBody(totalCount, successCount, commentsWithoutLine.length + failedComments.length, warnings);
- finalBody += formatSummaryComments(commentsWithoutLine);
- finalBody += `\n\n---\n\n📊 **Posting Statistics:**`;
- finalBody += `\n- ✅ Successfully posted: ${successCount} comment(s)`;
- if (failedCount > 0) {
- finalBody += `\n- ❌ Failed to post: ${failedCount} comment(s)`;
- }
-
- // Add failed comments as summary content so review feedback is not lost.
- if (failedComments.length > 0) {
- finalBody += '\n\n---\n\n### ⚠️ Inline comments shown in summary';
- for (const { comment, error } of failedComments) {
- finalBody += '\n\n---\n\n';
- finalBody += formatCommentMarkdown(comment, error);
- }
- }
-
- await github.rest.issues.createComment({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: prNumber,
- body: finalBody
- });
- }
-
- function sleep(ms) {
- return new Promise(resolve => setTimeout(resolve, ms));
- }
-
- // Case-insensitive header lookup. Octokit normalizes response headers to
- // lowercase, but this defensive check also handles original casing so that
- // quota logging and retry delay computation never silently miss a header.
- function getHeader(headers, name) {
- const v = headers[name] != null ? headers[name] : headers[name.toLowerCase()];
- return v != null ? String(v).trim() : undefined;
- }
-
- // Decide whether an error is worth retrying and, if so, how long to wait.
- // Implements GitHub's documented rate-limit retry strategy using the
- // response headers (retry-after, x-ratelimit-remaining, x-ratelimit-reset).
- // Returns { delayMs, source, detail } when retryable, or null otherwise.
- // See: https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api
- function computeRetryDelayMs(error, attempt) {
- if (!error) return null;
- const status = error.status;
- const message = String(error.message || '');
- const isRateLimit = status === 429 || (status === 403 && /rate limit|abuse|secondary/i.test(message));
- const isTransient = (status >= 500 && status < 600) || status === 408;
- if (!isRateLimit && !isTransient) return null;
-
- const headers = ((error.response || {}).headers) || {};
- const header = (name) => getHeader(headers, name);
- const nowSec = Math.floor(Date.now() / 1000);
-
- // The absolute maximum wait for any single retry. Header-derived waits
- // (retry-after / x-ratelimit-reset) are GitHub's recommended durations,
- // but capping them prevents a far-future reset from stalling the CI job
- // past its timeout. When we cap, the next retry may re-hit the limit.
- const cap = parseInt(process.env.OCR_RETRY_MAX_DELAY, 10) || 300000;
- const base = parseInt(process.env.OCR_RETRY_BASE_DELAY, 10) || 60000;
-
- // { rawMs, source, detail } describing the recommended wait before cap.
- let info = null;
-
- if (isRateLimit) {
- // (1) Honor "retry-after" when present (seconds, or an HTTP-date).
- const retryAfter = header('retry-after');
- if (retryAfter) {
- const secs = Number(retryAfter);
- if (!isNaN(secs) && secs >= 0) {
- info = { rawMs: secs * 1000, source: 'retry-after', detail: `${secs}s (from header)` };
- } else {
- const dateMs = Date.parse(retryAfter);
- if (!isNaN(dateMs)) {
- info = { rawMs: Math.max(0, dateMs - Date.now()), source: 'retry-after (HTTP-date)', detail: retryAfter };
- }
- }
- }
-
- // (2) Primary limit exhausted (x-ratelimit-remaining=0): wait until reset.
- if (!info) {
- const remaining = header('x-ratelimit-remaining');
- const reset = header('x-ratelimit-reset');
- if (reset != null && Number(remaining) === 0) {
- const rawMs = Math.max(0, Number(reset) - nowSec) * 1000;
- info = { rawMs, source: 'x-ratelimit-reset', detail: `remaining=0, reset epoch=${reset} (in ${Math.ceil(rawMs / 1000)}s)` };
- }
- }
-
- // (3) Secondary limit with no retry hint: docs say wait at least one
- // minute, then increase exponentially between retries.
- if (!info) {
- const backoff = Math.min(base * Math.pow(2, attempt), cap);
- const jitter = Math.floor(Math.random() * 1000);
- info = { rawMs: backoff + jitter, source: 'exponential-backoff', detail: `base=${base}ms*2^${attempt} (cap ${cap}ms) +${jitter}ms jitter` };
- }
- } else {
- // Transient server error (5xx / 408): back off without the 60s floor.
- // Use a shorter base than the rate-limit path: server hiccups are
- // typically short-lived, so a 2s initial wait (doubling per retry)
- // is sufficient and avoids stalling the CI job unnecessarily.
- const transientBase = 2000;
- const backoff = Math.min(transientBase * Math.pow(2, attempt), cap);
- const jitter = Math.floor(Math.random() * 1000);
- info = { rawMs: backoff + jitter, source: 'transient-backoff', detail: `base=${transientBase}ms*2^${attempt} (cap ${cap}ms) +${jitter}ms jitter (HTTP ${status})` };
- }
-
- // Apply the universal cap to header-derived waits too.
- const delayMs = Math.min(info.rawMs, cap);
- if (delayMs < info.rawMs) {
- info.detail += ` [CAPPED to ${cap}ms; GitHub recommended ${Math.ceil(info.rawMs / 1000)}s]`;
- }
- return { delayMs, source: info.source, detail: info.detail };
- }
-
- // Best-effort logging of remaining rate-limit quota from a successful response.
- // Returns the parsed x-ratelimit-remaining value (or null) for proactive throttling.
- function logRateLimitQuota(response, tag) {
- try {
- const h = (response && response.headers) || {};
- const header = (name) => getHeader(h, name);
- const remaining = header('x-ratelimit-remaining');
- const limit = header('x-ratelimit-limit');
- const reset = header('x-ratelimit-reset');
- if (remaining != null) {
- console.log(
- `[rate-limit] ${tag}: remaining=${remaining}/${limit != null ? limit : '?'}` +
- (reset != null ? `, reset epoch=${reset}` : '')
- );
- }
- return remaining != null ? Number(remaining) : null;
- } catch (_) { return null; }
- }
-
- function formatComment(comment) {
- let body = comment.content || '';
-
- // Add code suggestion if available
- if (comment.suggestion_code && comment.existing_code) {
- body += '\n\n**Suggestion:**\n';
- body += fencedBlock(comment.suggestion_code, 'suggestion');
- }
-
- return body;
- }
-
- function formatCommentMarkdown(comment, error) {
- let md = `### 📄 \`${comment.path}\``;
- if (comment.start_line && comment.end_line) {
- md += ` (L${comment.start_line}-L${comment.end_line})`;
- }
- md += '\n\n';
- if (error) {
- md += `⚠️ GitHub could not post this as an inline comment: ${error}\n\n`;
- }
- md += comment.content || '';
-
- if (comment.suggestion_code && comment.existing_code) {
- md += '\n\n💡 Suggested Change\n\n';
- md += '**Before:**\n' + fencedBlock(comment.existing_code) + '\n\n';
- md += '**After:**\n' + fencedBlock(comment.suggestion_code) + '\n\n';
- md += '';
- }
-
- return md;
- }
-
- function buildSummaryBody(totalCount, inlineCount, summaryCount, warnings) {
- let body = `🔍 **OpenCodeReview** found **${totalCount}** issue(s) in this PR.`;
- if (totalCount > 0) {
- body += `\n- ✅ ${inlineCount} posted as inline comment(s)`;
- body += `\n- 📝 ${summaryCount} posted as summary`;
- }
- if (warnings.length > 0) {
- body += `\n\n⚠️ ${warnings.length} warning(s) occurred during review.`;
- }
- return body;
- }
-
- function formatSummaryComments(summaryComments) {
- let body = '';
- for (const { comment } of summaryComments) {
- body += '\n\n---\n\n';
- body += formatCommentMarkdown(comment);
- }
- return body;
- }
-
- function fencedBlock(content, language = '') {
- const text = String(content || '');
- const fence = safeFence(text);
- let block = fence + language + '\n' + text;
- if (!text.endsWith('\n')) block += '\n';
- return block + fence;
- }
-
- function safeFence(content) {
- const matches = String(content || '').match(/`+/g) || [];
- const maxTicks = matches.reduce((max, ticks) => Math.max(max, ticks.length), 0);
- return '`'.repeat(Math.max(3, maxTicks + 1));
- }
+ llm_url: ${{ secrets.OCR_LLM_URL }}
+ llm_auth_token: ${{ secrets.OCR_LLM_AUTH_TOKEN }}
+ llm_model: ${{ vars.OCR_LLM_MODEL }}
+ llm_use_anthropic: ${{ vars.OCR_LLM_USE_ANTHROPIC }}
+ # For issue_comment triggers, pass the resolved refs; for
+ # pull_request_target the action resolves them from the event.
+ base_ref: ${{ steps.pr-context.outputs.base_ref }}
+ head_sha: ${{ steps.pr-context.outputs.head_sha }}
diff --git a/extensions/vscode/.gitignore b/extensions/vscode/.gitignore
index ae7fae2..e7377a7 100644
--- a/extensions/vscode/.gitignore
+++ b/extensions/vscode/.gitignore
@@ -2,4 +2,5 @@
.superpowers/
node_modules/
reference
-out/
\ No newline at end of file
+out/
+*.vsix
\ No newline at end of file
diff --git a/extensions/vscode/.vscodeignore b/extensions/vscode/.vscodeignore
index 213ba03..d64358b 100644
--- a/extensions/vscode/.vscodeignore
+++ b/extensions/vscode/.vscodeignore
@@ -13,3 +13,4 @@ jest.config.js
.superpowers/**
prototype.html
resources/icon-old.svg
+__mocks__/**
diff --git a/extensions/vscode/LICENSE b/extensions/vscode/LICENSE
new file mode 100644
index 0000000..a4f1fec
--- /dev/null
+++ b/extensions/vscode/LICENSE
@@ -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.
diff --git a/extensions/vscode/open-code-review-vscode-0.1.0.vsix b/extensions/vscode/open-code-review-vscode-0.1.0.vsix
deleted file mode 100644
index 428b077..0000000
Binary files a/extensions/vscode/open-code-review-vscode-0.1.0.vsix and /dev/null differ
diff --git a/extensions/vscode/package.json b/extensions/vscode/package.json
index 7644112..53d9678 100644
--- a/extensions/vscode/package.json
+++ b/extensions/vscode/package.json
@@ -2,12 +2,12 @@
"name": "open-code-review-vscode",
"displayName": "Open Code Review",
"description": "%ocr.description%",
- "version": "0.1.0",
+ "version": "0.1.1",
"publisher": "open-code-review",
"license": "Apache-2.0",
"repository": {
"type": "git",
- "url": "git+https://github.com/nigulasikk/open-code-review.git",
+ "url": "git+https://github.com/alibaba/open-code-review.git",
"directory": "extensions/vscode"
},
"engines": {
@@ -16,6 +16,7 @@
"categories": [
"Other"
],
+ "icon": "resources/icon.png",
"main": "./out/extension.js",
"activationEvents": [
"onStartupFinished"
diff --git a/extensions/vscode/resources/icon-old.svg b/extensions/vscode/resources/icon-old.svg
deleted file mode 100644
index 3ce9a0e..0000000
--- a/extensions/vscode/resources/icon-old.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/extensions/vscode/resources/icon.png b/extensions/vscode/resources/icon.png
new file mode 100644
index 0000000..0c2add0
Binary files /dev/null and b/extensions/vscode/resources/icon.png differ
diff --git a/extensions/vscode/resources/icon.svg b/extensions/vscode/resources/icon.svg
index 7a0e418..c4a054b 100644
--- a/extensions/vscode/resources/icon.svg
+++ b/extensions/vscode/resources/icon.svg
@@ -1,21 +1 @@
-
+
\ No newline at end of file
diff --git a/extensions/vscode/src/extension/extension.ts b/extensions/vscode/src/extension/extension.ts
index c398e6b..5bc0c56 100644
--- a/extensions/vscode/src/extension/extension.ts
+++ b/extensions/vscode/src/extension/extension.ts
@@ -16,7 +16,7 @@ export function activate(context: vscode.ExtensionContext): void {
const cli = new CliService('ocr');
const config = new ConfigService(cli);
const git = new GitService(output);
- const comments = new CommentProvider(extensionUri);
+ const comments = new CommentProvider(extensionUri, git);
const sidebar = new SidebarProvider(extensionUri, cli, config, git, comments);
const configPanel = new ConfigPanelProvider(extensionUri, cli, config, (cfg) => sidebar.pushConfig(cfg));
diff --git a/extensions/vscode/src/extension/providers/CommentProvider.ts b/extensions/vscode/src/extension/providers/CommentProvider.ts
index 02d1dba..84742f7 100644
--- a/extensions/vscode/src/extension/providers/CommentProvider.ts
+++ b/extensions/vscode/src/extension/providers/CommentProvider.ts
@@ -1,22 +1,34 @@
import { t, resolveLocale, SupportedLocale } from '../../shared/i18n';
import * as vscode from 'vscode';
-import { ReviewComment, CommentStatus, CommentSyncState } from '../../shared/types';
+import { ReviewComment, CommentStatus, CommentSyncState, ReviewContext, ReviewMode } from '../../shared/types';
import { COMMENT_CONTROLLER_ID } from '../../shared/constants';
import { LineOffsetTracker } from './lineOffset';
+import { GitService } from '../services/GitService';
+import {
+ MountableCommentAnchor,
+ resolveCommentAnchor,
+ CommentAnchorDeps,
+ SidebarOnlyReason,
+} from './commentAnchor';
export class CommentProvider {
private controller: vscode.CommentController;
- // 以 comment 在 result.comments 中的原始下标为 key,与 webview 共用同一索引空间。
- // 打不开的文件(如目录)没有 thread,但下标依旧保留,避免错位。
private threads = new Map();
+ private mounts = new Map();
+ private jumpable = new Set();
+ private jumpBlockReasons = new Map();
private comments: ReviewComment[] = [];
private status = new Map();
private offsets = new LineOffsetTracker();
private syncListeners: Array<(s: CommentSyncState[]) => void> = [];
+ private reviewContext: ReviewContext = { mode: ReviewMode.Workspace };
private locale: SupportedLocale;
- constructor(private extensionUri: vscode.Uri) {
+ constructor(
+ private extensionUri: vscode.Uri,
+ private git: GitService,
+ ) {
this.locale = resolveLocale(vscode.env.language);
this.controller = vscode.comments.createCommentController(COMMENT_CONTROLLER_ID, t(this.locale, 'ext.commentController'));
}
@@ -27,62 +39,92 @@ export class CommentProvider {
private emitSync(): void {
const states: CommentSyncState[] = this.comments.map((_, i) => ({
- index: i, status: this.status.get(i) ?? 'pending',
+ index: i,
+ status: this.status.get(i) ?? 'pending',
+ jumpable: this.jumpable.has(i),
}));
this.syncListeners.forEach((fn) => fn(states));
}
- /**
- * 展示审查评论。
- * @param inEditor 是否在编辑器内创建 CommentThread。仅工作区模式为 true;
- * 分支对比/单次提交模式下被审查代码不在当前工作区,行号会错位,故只在侧边栏展示。
- */
- async show(comments: ReviewComment[], inEditor = true): Promise {
+ /** 展示审查评论:能解析到 git/工作区快照的挂 thread,否则仅侧边栏。 */
+ async show(comments: ReviewComment[], ctx: ReviewContext): Promise {
this.clear();
- // 不重排:保持与 webview(result.comments)相同的顺序与下标
+ this.reviewContext = ctx;
this.comments = comments;
+
const root = vscode.workspace.workspaceFolders?.[0].uri.fsPath;
if (!root) return;
- // 非工作区模式:只登记评论与状态供侧边栏同步,不在编辑器内放置 thread。
- if (!inEditor) {
- for (let i = 0; i < this.comments.length; i++) this.status.set(i, 'pending');
- this.emitSync();
- return;
- }
+ await this.git.prepareReviewFileStatus(ctx);
+ const deps = this.buildAnchorDeps(root, ctx);
let firstShown = -1;
for (let i = 0; i < this.comments.length; i++) {
const c = this.comments[i];
this.status.set(i, 'pending');
+
+ const anchor = await resolveCommentAnchor(c, ctx, deps);
+ if (anchor.kind === 'sidebar') {
+ this.jumpBlockReasons.set(i, anchor.reason);
+ continue;
+ }
+
try {
- const uri = vscode.Uri.file(`${root}/${c.path}`);
- const doc = await vscode.workspace.openTextDocument(uri);
- const range = new vscode.Range(Math.max(0, c.startLine - 1), 0, Math.max(0, c.endLine - 1), 0);
- const body = this.renderBody(c, i, 'pending');
- const thread = this.controller.createCommentThread(doc.uri, range, [{
- body, mode: vscode.CommentMode.Preview,
+ await vscode.workspace.openTextDocument(anchor.uri);
+ const body = this.renderBody(c, i, 'pending', anchor.locateNote);
+ const thread = this.controller.createCommentThread(anchor.uri, anchor.range, [{
+ body,
+ mode: vscode.CommentMode.Preview,
author: { name: t(this.locale, 'ext.comment.pending') },
}]);
thread.canReply = false;
thread.label = `${t(this.locale, 'ext.comment.threadLabel')} (${i + 1} / ${this.comments.length})`;
- // 有代码建议 → 'pending'(显示应用+忽略);无建议 → 'pendingNoSuggestion'(仅忽略)
- thread.contextValue = this.hasSuggestion(c) ? 'pending' : 'pendingNoSuggestion';
+ thread.contextValue = this.threadContextValue(c, ctx);
thread.collapsibleState = vscode.CommentThreadCollapsibleState.Expanded;
this.threads.set(i, thread);
+ this.mounts.set(i, anchor);
+ this.jumpable.add(i);
if (firstShown < 0) firstShown = i;
- } catch { /* 文件打不开(如目录)则无 thread,但保留下标 */ }
+ } catch {
+ this.jumpBlockReasons.set(i, 'mount-failed');
+ }
}
+
if (firstShown >= 0) await this.jumpTo(firstShown);
this.emitSync();
}
+ private buildAnchorDeps(root: string, ctx: ReviewContext): CommentAnchorDeps {
+ return {
+ repoRoot: root,
+ fileStatus: (path) => this.git.getReviewFileStatus(path),
+ readAtRef: (ref, path) => this.git.readFileAtRef(ref, path),
+ readWorkspace: (path) => this.git.readWorkspaceFile(path),
+ buildDiffUris: (path, status) => this.git.buildCommentDiffUris(path, status, ctx),
+ toGitUri: async (path, ref) => {
+ const uri = await this.git.createGitFileUri(path, ref);
+ if (!uri) throw new Error('git uri unavailable');
+ return uri;
+ },
+ };
+ }
+
+ private threadContextValue(c: ReviewComment, ctx: ReviewContext): string {
+ if (ctx.mode !== ReviewMode.Workspace) return 'pendingNoSuggestion';
+ return this.hasSuggestion(c) ? 'pending' : 'pendingNoSuggestion';
+ }
+
private hasSuggestion(c: ReviewComment): boolean {
return !!(c.suggestionCode && c.suggestionCode.trim());
}
- private renderBody(c: ReviewComment, _index: number, _status: CommentStatus): vscode.MarkdownString {
- let md = c.content;
+ private renderBody(
+ c: ReviewComment,
+ _index: number,
+ _status: CommentStatus,
+ locateNote?: string,
+ ): vscode.MarkdownString {
+ let md = locateNote ? `${locateNote}\n\n${c.content}` : c.content;
if (this.hasSuggestion(c)) {
md += `\n***\n\`\`\`diff\n${c.suggestionCode}\n\`\`\``;
} else {
@@ -94,6 +136,10 @@ export class CommentProvider {
}
async apply(index: number): Promise {
+ if (this.reviewContext.mode !== ReviewMode.Workspace) {
+ vscode.window.showWarningMessage(t(this.locale, 'ext.comment.applyWorkspaceOnly'));
+ return;
+ }
const c = this.comments[index];
if (!c) return;
const root = vscode.workspace.workspaceFolders?.[0].uri.fsPath;
@@ -110,8 +156,6 @@ export class CommentProvider {
const range = new vscode.Range(start, 0, end, doc.lineAt(end).text.length);
const hasSuggestion = !!(c.suggestionCode && c.suggestionCode.trim());
- // 用 WorkspaceEdit 而非 editor.edit:后者要求目标编辑器为活动编辑器,
- // 从评论标题栏按钮触发时焦点在评论控件上,会静默返回 false 导致“点不动”。
const edit = new vscode.WorkspaceEdit();
if (hasSuggestion) edit.replace(uri, range, c.suggestionCode!);
else edit.delete(uri, range);
@@ -132,6 +176,7 @@ export class CommentProvider {
private setStatus(index: number, status: CommentStatus): void {
this.status.set(index, status);
const thread = this.threads.get(index);
+ const mount = this.mounts.get(index);
if (thread) {
const label = {
applied: t(this.locale, 'ext.comment.statusApplied'),
@@ -139,7 +184,11 @@ export class CommentProvider {
falsePositive: t(this.locale, 'ext.comment.statusFalsePositive'),
pending: t(this.locale, 'ext.comment.pending'),
}[status];
- thread.comments = [{ ...thread.comments[0], author: { name: label }, body: this.renderBody(this.comments[index], index, status) }] as any;
+ thread.comments = [{
+ ...thread.comments[0],
+ author: { name: label },
+ body: this.renderBody(this.comments[index], index, status, mount?.locateNote),
+ }] as any;
thread.contextValue = status;
thread.collapsibleState = vscode.CommentThreadCollapsibleState.Collapsed;
}
@@ -147,27 +196,128 @@ export class CommentProvider {
}
async jumpTo(index: number): Promise {
+ const mount = this.mounts.get(index);
const thread = this.threads.get(index);
- if (!thread) {
- const c = this.comments[index];
- if (c) vscode.window.showWarningMessage(`${t(this.locale, 'ext.comment.jumpFailed')}${c.path}${t(this.locale, 'ext.comment.jumpNotAFile')}`);
+ if (!mount || !thread) {
+ this.showJumpFailed(index);
return;
}
- await vscode.window.showTextDocument(thread.uri, { selection: thread.range, preview: false });
+
+ if (mount.diff) {
+ const opened = await this.openDiffEditor(mount);
+ if (opened) {
+ await this.revealInDiffSide(mount.uri, mount.range, mount.side);
+ } else {
+ await vscode.window.showTextDocument(mount.uri, { selection: mount.range, preview: false });
+ }
+ } else {
+ await vscode.window.showTextDocument(mount.uri, { selection: mount.range, preview: false });
+ }
thread.collapsibleState = vscode.CommentThreadCollapsibleState.Expanded;
}
+ private showJumpFailed(index: number): void {
+ const c = this.comments[index];
+ if (!c) return;
+
+ const reason = this.jumpBlockReasons.get(index) ?? this.inferJumpBlockReason(c);
+ const key = reason === 'missing-file' || reason === 'mount-failed'
+ ? 'ext.comment.jumpFileMissing'
+ : 'ext.comment.jumpLineUnresolved';
+ vscode.window.showWarningMessage(
+ t(this.locale, key).replace('{path}', c.path),
+ );
+ }
+
+ /** 未记录原因时根据评论元数据推断(如 L0 多为行号未解析)。 */
+ private inferJumpBlockReason(c: ReviewComment): SidebarOnlyReason | 'mount-failed' {
+ if (c.startLine <= 0 && c.endLine <= 0) return 'unresolved';
+ return 'missing-file';
+ }
+
+ private async openDiffEditor(mount: MountableCommentAnchor): Promise {
+ if (!mount.diff) return false;
+ try {
+ await vscode.commands.executeCommand(
+ 'vscode.diff',
+ mount.diff.left,
+ mount.diff.right,
+ mount.diff.title,
+ { preview: false },
+ );
+ return true;
+ } catch {
+ return false;
+ }
+ }
+
+ /** 在已打开的 diff 编辑器中定位到挂载侧行,不再额外打开单文件 tab。 */
+ private async revealInDiffSide(
+ uri: vscode.Uri,
+ range: vscode.Range,
+ side: MountableCommentAnchor['side'],
+ ): Promise {
+ for (let attempt = 0; attempt < 8; attempt++) {
+ const editor = this.findEditorForUri(uri, side);
+ if (editor) {
+ editor.selection = new vscode.Selection(range.start, range.end);
+ editor.revealRange(range, vscode.TextEditorRevealType.InCenter);
+ return;
+ }
+ await new Promise((r) => setTimeout(r, 40));
+ }
+ }
+
+ private findEditorForUri(
+ uri: vscode.Uri,
+ side?: MountableCommentAnchor['side'],
+ ): vscode.TextEditor | undefined {
+ for (const editor of vscode.window.visibleTextEditors) {
+ if (this.urisMatch(editor.document.uri, uri)) return editor;
+ }
+
+ const tab = vscode.window.tabGroups.activeTabGroup.activeTab;
+ if (tab?.input instanceof vscode.TabInputTextDiff) {
+ const { original, modified } = tab.input;
+ const prefer = side === 'left' ? original : side === 'right' ? modified : undefined;
+ const candidates = prefer ? [prefer, original, modified] : [original, modified];
+ for (const candidate of candidates) {
+ if (!this.urisMatch(candidate, uri)) continue;
+ const editor = vscode.window.visibleTextEditors.find(
+ (ed) => this.urisMatch(ed.document.uri, candidate),
+ );
+ if (editor) return editor;
+ }
+ }
+ return undefined;
+ }
+
+ private urisMatch(a: vscode.Uri, b: vscode.Uri): boolean {
+ if (a.toString() === b.toString()) return true;
+ if (a.scheme !== b.scheme) return false;
+ if (a.scheme === 'git') {
+ const refA = new URLSearchParams(a.query).get('ref');
+ const refB = new URLSearchParams(b.query).get('ref');
+ return a.path === b.path && refA === refB;
+ }
+ return a.fsPath === b.fsPath;
+ }
+
indexOfThread(thread: vscode.CommentThread): number {
- for (const [i, t] of this.threads) if (t === thread) return i;
+ for (const [i, th] of this.threads) if (th === thread) return i;
return -1;
}
clear(): void {
- this.threads.forEach((t) => t.dispose());
+ this.threads.forEach((th) => th.dispose());
this.threads.clear();
+ this.mounts.clear();
+ this.jumpable.clear();
+ this.jumpBlockReasons.clear();
this.comments = [];
this.status.clear();
this.offsets.clear();
+ this.reviewContext = { mode: ReviewMode.Workspace };
}
dispose(): void {
diff --git a/extensions/vscode/src/extension/providers/SidebarProvider.ts b/extensions/vscode/src/extension/providers/SidebarProvider.ts
index f63ff58..f3beebd 100644
--- a/extensions/vscode/src/extension/providers/SidebarProvider.ts
+++ b/extensions/vscode/src/extension/providers/SidebarProvider.ts
@@ -2,7 +2,7 @@ import { resolveLocale, toHtmlLang } from '../../shared/i18n';
import * as vscode from 'vscode';
import { ConfigPanelFocus } from '../../shared/configUtils';
import { HostToWebview, WebviewToHost } from '../../shared/messages';
-import { FileChange } from '../../shared/types';
+import { FileChange, ReviewMode } from '../../shared/types';
import { CliService } from '../services/CliService';
import { ConfigService } from '../services/ConfigService';
import { GitService } from '../services/GitService';
@@ -13,6 +13,7 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
private view?: vscode.WebviewView;
private session?: ReviewSession;
private openConfigPanel?: (focus?: ConfigPanelFocus) => void;
+ private gitWatchDisposable?: vscode.Disposable;
constructor(
private extensionUri: vscode.Uri,
@@ -37,6 +38,16 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
view.webview.options = { enableScripts: true, localResourceRoots: [this.extensionUri] };
view.webview.html = this.html(view.webview);
view.webview.onDidReceiveMessage((msg: WebviewToHost) => this.handle(msg));
+
+ this.gitWatchDisposable?.dispose();
+ this.gitWatchDisposable = this.git.watchWorkspaceChanges((gitState) => {
+ this.post({ type: 'gitState', gitState });
+ });
+ view.onDidDispose(() => {
+ this.gitWatchDisposable?.dispose();
+ this.gitWatchDisposable = undefined;
+ this.view = undefined;
+ });
}
private post(msg: HostToWebview): void {
@@ -48,7 +59,7 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
switch (msg.type) {
case 'ready': {
const config = this.config.read();
- const gitState = await this.git.getState('workspace');
+ const gitState = await this.git.getState(ReviewMode.Workspace);
const locale = resolveLocale(vscode.env.language);
this.post({ type: 'init', config, gitState, locale });
break;
@@ -59,9 +70,9 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
}
case 'getModeFiles': {
let files: FileChange[] = [];
- if (msg.mode === 'branch' && msg.from && msg.to) {
+ if (msg.mode === ReviewMode.Branch && msg.from && msg.to) {
files = await this.git.getBranchDiff(msg.from, msg.to);
- } else if (msg.mode === 'commit' && msg.commit) {
+ } else if (msg.mode === ReviewMode.Commit && msg.commit) {
files = await this.git.getCommitFiles(msg.commit);
}
this.post({ type: 'modeFiles', mode: msg.mode, files });
@@ -75,14 +86,21 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
break;
case 'startReview': {
this.session = new ReviewSession(this.cli, cwd);
- // 仅工作区模式在编辑器内放置评论 thread;分支/提交模式代码不在工作区,会错位。
- const inEditor = msg.options.mode === 'workspace';
await this.session.run(msg.options, {
onState: (state, error) => this.post({ type: 'stateChange', state, error }),
onLog: (line) => this.post({ type: 'logLine', line }),
onDone: (result) => {
- this.post({ type: 'reviewDone', result });
- if (result.comments.length) this.comments.show(result.comments, inEditor);
+ void (async () => {
+ if (result.comments.length) {
+ await this.comments.show(result.comments, {
+ mode: msg.options.mode,
+ from: msg.options.from,
+ to: msg.options.to,
+ commit: msg.options.commit,
+ });
+ }
+ this.post({ type: 'reviewDone', result });
+ })();
},
});
break;
diff --git a/extensions/vscode/src/extension/providers/__tests__/commentAnchor.test.ts b/extensions/vscode/src/extension/providers/__tests__/commentAnchor.test.ts
new file mode 100644
index 0000000..22a14dd
--- /dev/null
+++ b/extensions/vscode/src/extension/providers/__tests__/commentAnchor.test.ts
@@ -0,0 +1,39 @@
+import {
+ findLinesByExistingCode,
+ normalizeLine,
+ resolveLinesInContent,
+ splitAndNormalize,
+} from '../commentAnchor';
+
+describe('commentAnchor line resolution', () => {
+ const content = ['line1', 'for (let i = 0; i <= 30, i++) {', ' console.log(i);', '}', 'line5'].join('\n');
+
+ it('normalizeLine strips diff markers', () => {
+ expect(normalizeLine('+added')).toBe('added');
+ expect(normalizeLine('-removed')).toBe('removed');
+ });
+
+ it('splitAndNormalize skips blank lines', () => {
+ expect(splitAndNormalize('a\n\n b ')).toEqual(['a', 'b']);
+ });
+
+ it('resolveLinesInContent uses explicit line numbers when in range', () => {
+ expect(resolveLinesInContent(content, 2, 2)).toEqual({ start: 2, end: 2, relocated: false });
+ });
+
+ it('resolveLinesInContent falls back to existingCode when line out of range', () => {
+ const code = 'for (let i = 0; i <= 30, i++) {';
+ const result = resolveLinesInContent(content, 99, 99, code);
+ expect(result).toEqual({ start: 2, end: 2, relocated: true });
+ });
+
+ it('findLinesByExistingCode matches consecutive non-blank lines', () => {
+ const found = findLinesByExistingCode(content, 'console.log(i);');
+ expect(found).toEqual({ start: 3, end: 3 });
+ });
+
+ it('returns null when neither line nor existingCode resolves', () => {
+ expect(resolveLinesInContent(content, 99, 99)).toBeNull();
+ expect(resolveLinesInContent(content, 0, 0)).toBeNull();
+ });
+});
diff --git a/extensions/vscode/src/extension/providers/commentAnchor.ts b/extensions/vscode/src/extension/providers/commentAnchor.ts
new file mode 100644
index 0000000..b18bc2a
--- /dev/null
+++ b/extensions/vscode/src/extension/providers/commentAnchor.ts
@@ -0,0 +1,197 @@
+import * as vscode from 'vscode';
+import { ReviewComment, ReviewContext, ReviewMode } from '../../shared/types';
+
+export type CommentMountSide = 'left' | 'right' | 'workspace';
+
+/** 可挂载到编辑器 / diff 的定位结果。 */
+export interface MountableCommentAnchor {
+ kind: 'mountable';
+ uri: vscode.Uri;
+ range: vscode.Range;
+ side: CommentMountSide;
+ /** commit/branch 模式跳转时打开原生 diff。 */
+ diff?: {
+ left: vscode.Uri;
+ right: vscode.Uri;
+ title: string;
+ };
+ /** 行号经 existingCode 重新定位时附带说明。 */
+ locateNote?: string;
+}
+
+/** 无法在快照中定位,仅侧边栏展示。 */
+export interface SidebarOnlyCommentAnchor {
+ kind: 'sidebar';
+ reason: 'binary' | 'unresolved' | 'missing-file';
+}
+
+export type SidebarOnlyReason = SidebarOnlyCommentAnchor['reason'];
+
+export type CommentAnchorResult = MountableCommentAnchor | SidebarOnlyCommentAnchor;
+
+export interface CommentAnchorDeps {
+ repoRoot: string;
+ fileStatus: (path: string) => Promise<'added' | 'modified' | 'deleted' | 'renamed' | 'binary' | null>;
+ readAtRef: (ref: string, path: string) => Promise;
+ readWorkspace: (path: string) => Promise;
+ buildDiffUris: (
+ path: string,
+ status: 'added' | 'modified' | 'deleted' | 'renamed' | 'binary',
+ ) => Promise<{
+ left: vscode.Uri;
+ right: vscode.Uri;
+ title: string;
+ mountRef: string;
+ mountSide: 'left' | 'right';
+ leftRef: string | null;
+ rightRef: string | null;
+ } | null>;
+ toGitUri: (path: string, ref: string) => Promise;
+}
+
+export function normalizeLine(s: string): string {
+ let line = s.trim();
+ if (line.startsWith('+') || line.startsWith('-')) line = line.slice(1).trim();
+ return line;
+}
+
+export function splitAndNormalize(code: string): string[] {
+ const result: string[] = [];
+ for (const raw of code.split('\n')) {
+ const n = normalizeLine(raw);
+ if (n) result.push(n);
+ }
+ return result;
+}
+
+/** 在文件内容中滑动匹配 existingCode,返回 1-based 行号。 */
+export function findLinesByExistingCode(content: string, existingCode: string): { start: number; end: number } | null {
+ const targetLines = splitAndNormalize(existingCode);
+ if (targetLines.length === 0) return null;
+
+ const fileLines = content.split('\n');
+ const normalized: string[] = [];
+ const lineNums: number[] = [];
+ for (let i = 0; i < fileLines.length; i++) {
+ const n = normalizeLine(fileLines[i].replace(/\r$/, ''));
+ if (!n) continue;
+ normalized.push(n);
+ lineNums.push(i + 1);
+ }
+ if (normalized.length < targetLines.length) return null;
+
+ for (let i = 0; i <= normalized.length - targetLines.length; i++) {
+ let matched = true;
+ for (let j = 0; j < targetLines.length; j++) {
+ if (normalized[i + j] !== targetLines[j]) {
+ matched = false;
+ break;
+ }
+ }
+ if (matched) {
+ return { start: lineNums[i], end: lineNums[i + targetLines.length - 1] };
+ }
+ }
+ return null;
+}
+
+export function resolveLinesInContent(
+ content: string,
+ startLine: number,
+ endLine: number,
+ existingCode?: string,
+): { start: number; end: number; relocated: boolean } | null {
+ const lineCount = content.split('\n').length;
+ const start = startLine > 0 ? startLine : 0;
+ const end = endLine > 0 ? endLine : start;
+
+ if (start > 0 && end > 0 && start <= lineCount && end <= lineCount && start <= end) {
+ return { start, end, relocated: false };
+ }
+
+ if (existingCode?.trim()) {
+ const found = findLinesByExistingCode(content, existingCode);
+ if (found) return { ...found, relocated: true };
+ }
+ return null;
+}
+
+function toRange(start: number, end: number): vscode.Range {
+ return new vscode.Range(Math.max(0, start - 1), 0, Math.max(0, end - 1), 0);
+}
+
+export async function resolveCommentAnchor(
+ comment: ReviewComment,
+ ctx: ReviewContext,
+ deps: CommentAnchorDeps,
+): Promise {
+ const status = await deps.fileStatus(comment.path);
+ if (status === 'binary') return { kind: 'sidebar', reason: 'binary' };
+ if (status === null && ctx.mode !== ReviewMode.Workspace) {
+ return { kind: 'sidebar', reason: 'missing-file' };
+ }
+
+ const effectiveStatus = status ?? 'modified';
+
+ if (ctx.mode === ReviewMode.Workspace) {
+ const content = await deps.readWorkspace(comment.path);
+ if (content === null) return { kind: 'sidebar', reason: 'missing-file' };
+ const lines = resolveLinesInContent(content, comment.startLine, comment.endLine, comment.existingCode);
+ if (!lines) return { kind: 'sidebar', reason: 'unresolved' };
+ const uri = vscode.Uri.file(`${deps.repoRoot}/${comment.path}`);
+ return {
+ kind: 'mountable',
+ uri,
+ range: toRange(lines.start, lines.end),
+ side: 'workspace',
+ locateNote: lines.relocated ? formatLocateNote(comment.startLine, lines.start) : undefined,
+ };
+ }
+
+ const diff = await deps.buildDiffUris(comment.path, effectiveStatus);
+ if (!diff) return { kind: 'sidebar', reason: 'missing-file' };
+
+ const primaryRef = diff.mountRef;
+ const primaryContent = await deps.readAtRef(primaryRef, comment.path);
+ let lines = primaryContent
+ ? resolveLinesInContent(primaryContent, comment.startLine, comment.endLine, comment.existingCode)
+ : null;
+
+ let mountRef = primaryRef;
+ let mountSide = diff.mountSide;
+
+ if (!lines && effectiveStatus !== 'added') {
+ const altRef = diff.mountSide === 'right' ? diff.leftRef : diff.rightRef;
+ const altSide: CommentMountSide = diff.mountSide === 'right' ? 'left' : 'right';
+ if (altRef) {
+ const altContent = await deps.readAtRef(altRef, comment.path);
+ const altLines = altContent
+ ? resolveLinesInContent(altContent, comment.startLine, comment.endLine, comment.existingCode)
+ : null;
+ if (altLines) {
+ lines = altLines;
+ mountRef = altRef;
+ mountSide = altSide;
+ }
+ }
+ }
+
+ if (!lines) return { kind: 'sidebar', reason: 'unresolved' };
+
+ const uri = await deps.toGitUri(comment.path, mountRef);
+ return {
+ kind: 'mountable',
+ uri,
+ range: toRange(lines.start, lines.end),
+ side: mountSide,
+ diff: { left: diff.left, right: diff.right, title: diff.title },
+ locateNote: lines.relocated ? formatLocateNote(comment.startLine, lines.start) : undefined,
+ };
+}
+
+function formatLocateNote(originalLine: number, resolvedLine: number): string {
+ if (originalLine > 0 && originalLine !== resolvedLine) {
+ return `⚠ Original line L${originalLine} could not be matched; showing L${resolvedLine} instead.`;
+ }
+ return '⚠ Line number was re-located from code content.';
+}
diff --git a/extensions/vscode/src/extension/services/GitService.ts b/extensions/vscode/src/extension/services/GitService.ts
index a3b29f0..8d4eaef 100644
--- a/extensions/vscode/src/extension/services/GitService.ts
+++ b/extensions/vscode/src/extension/services/GitService.ts
@@ -1,11 +1,16 @@
import { t, resolveLocale } from '../../shared/i18n';
import * as vscode from 'vscode';
+import { readFile } from 'fs/promises';
import { execFile } from 'child_process';
-import { GitState, CommitInfo, FileChange, ReviewMode } from '../../shared/types';
-import { parsePorcelain, parseNameStatus, pickRepoRoot } from './gitMap';
+import { GitState, FileChange, ReviewMode, ReviewContext } from '../../shared/types';
+import { buildWorkspaceFiles, branchRefCandidates, parseNameStatus, pickRepoRoot } from './gitMap';
+
+const WORKSPACE_REFRESH_DEBOUNCE_MS = 300;
export class GitService {
private api: any | null = null;
+ private cache: GitState = { branches: [], currentBranch: '', recentCommits: [], workspaceFiles: [] };
+ private reviewFileStatus = new Map();
constructor(private log?: vscode.OutputChannel) {}
@@ -61,57 +66,155 @@ export class GitService {
async getState(mode: ReviewMode): Promise {
const empty: GitState = { branches: [], currentBranch: '', recentCommits: [], workspaceFiles: [] };
+
+ if (mode === ReviewMode.Workspace) {
+ await this.refreshWorkspaceFiles();
+ return { ...this.cache };
+ }
+
const repo = await this.waitForRepo();
if (!repo) {
this.trace(`getState(${mode}): no repo`);
return empty;
}
- let currentBranch = '';
try {
- currentBranch = repo.state.HEAD?.name || '';
+ this.cache.currentBranch = repo.state.HEAD?.name || '';
} catch { /* ignore */ }
- let branches: string[] = [];
+ if (mode === ReviewMode.Branch) {
+ await this.refreshBranches(repo);
+ } else if (mode === ReviewMode.Commit) {
+ await this.refreshRecentCommits(repo);
+ }
+
+ return { ...this.cache };
+ }
+
+ /**
+ * 订阅 VS Code Git 扩展的仓库状态变化,debounce 后刷新工作区文件列表。
+ * 用于侧边栏工作区模式实时反映暂存/工作区/未跟踪变更。
+ */
+ watchWorkspaceChanges(onUpdate: (state: GitState) => void): vscode.Disposable {
+ const cleanups: vscode.Disposable[] = [];
+ let debounceTimer: ReturnType | undefined;
+ let repoDisposable: vscode.Disposable | undefined;
+ let cancelled = false;
+
+ const scheduleRefresh = () => {
+ if (debounceTimer) clearTimeout(debounceTimer);
+ debounceTimer = setTimeout(() => {
+ debounceTimer = undefined;
+ void this.refreshWorkspaceFiles().then(() => {
+ if (!cancelled) onUpdate({ ...this.cache });
+ });
+ }, WORKSPACE_REFRESH_DEBOUNCE_MS);
+ };
+
+ const attachRepo = (repo: any) => {
+ repoDisposable?.dispose();
+ repoDisposable = repo?.state?.onDidChange?.(scheduleRefresh);
+ };
+
+ void this.ensureApi().then(async (api) => {
+ if (!api || cancelled) return;
+
+ const repo = await this.waitForRepo();
+ if (!cancelled && repo) attachRepo(repo);
+
+ if (api.onDidOpenRepository) {
+ cleanups.push(api.onDidOpenRepository(() => {
+ if (cancelled) return;
+ const selected = this.selectRepo(api);
+ if (selected) attachRepo(selected);
+ }));
+ }
+ });
+
+ return new vscode.Disposable(() => {
+ cancelled = true;
+ if (debounceTimer) clearTimeout(debounceTimer);
+ repoDisposable?.dispose();
+ for (const d of cleanups) d.dispose();
+ });
+ }
+
+ /** 工作区模式仅刷新变更文件,不等待 VS Code Git 扩展,也不拉分支/提交历史。 */
+ private async refreshWorkspaceFiles(): Promise {
+ const root = await this.repoRootFast();
+ if (!root) {
+ this.cache.workspaceFiles = [];
+ return;
+ }
+
+ try {
+ const [diffHeadOut, untrackedOut] = await Promise.all([
+ runGit(root, ['diff', '--name-status', 'HEAD']),
+ runGit(root, ['ls-files', '--others', '--exclude-standard']),
+ ]);
+ let diffCachedOut = '';
+ if (!diffHeadOut.trim()) {
+ diffCachedOut = await runGit(root, ['diff', '--cached', '--name-status']);
+ }
+ this.cache.workspaceFiles = buildWorkspaceFiles(diffHeadOut, diffCachedOut, untrackedOut);
+ this.trace(`refreshWorkspaceFiles: root=${root} files=${this.cache.workspaceFiles.length}`);
+ } catch (e) {
+ this.trace(`refreshWorkspaceFiles failed: ${e instanceof Error ? e.message : String(e)}`);
+ this.cache.workspaceFiles = [];
+ }
+ }
+
+ /** 通过 git rev-parse 解析仓库根,避免等待 VS Code Git 扩展初始化。 */
+ private async repoRootFast(): Promise {
+ const ws = vscode.workspace.workspaceFolders?.[0].uri.fsPath;
+ if (!ws) return null;
+ try {
+ const out = await runGit(ws, ['rev-parse', '--show-toplevel']);
+ const root = out.trim();
+ return root || null;
+ } catch {
+ return null;
+ }
+ }
+
+ private async refreshBranches(repo: any): Promise {
try {
const refs = await repo.getBranches({ remote: true });
- branches = refs.map((r: any) => r.name).filter(Boolean);
- } catch { /* ignore */ }
+ this.cache.branches = refs.map((r: any) => r.name).filter(Boolean);
+ } catch {
+ this.cache.branches = [];
+ }
+ }
- let recentCommits: CommitInfo[] = [];
+ private async refreshRecentCommits(repo: any): Promise {
try {
const commits = await repo.log({ maxEntries: 20 });
- recentCommits = commits.map((c: any) => ({
+ this.cache.recentCommits = commits.map((c: any) => ({
sha: c.hash.slice(0, 7),
message: c.message.split('\n')[0],
relativeTime: formatRelative(c.authorDate),
}));
- } catch { /* ignore */ }
-
- // 工作区变更直接走 git status --porcelain,避免依赖扩展懒填充的 state 数组。
- let workspaceFiles: FileChange[] = [];
- try {
- const root: string = repo.rootUri?.fsPath
- ?? vscode.workspace.workspaceFolders?.[0].uri.fsPath
- ?? process.cwd();
- const out = await runGit(root, ['status', '--porcelain']);
- workspaceFiles = parsePorcelain(out);
- this.trace(`getState(${mode}): root=${root} porcelainBytes=${out.length} files=${workspaceFiles.length}`);
- } catch (e) {
- this.trace(`getState(${mode}): status failed: ${e instanceof Error ? e.message : String(e)}`);
+ } catch {
+ this.cache.recentCommits = [];
}
-
- return { branches, currentBranch, recentCommits, workspaceFiles };
}
/** 分支对比:merge-base 三点 diff。 */
async getBranchDiff(from: string, to: string): Promise {
const root = await this.repoRoot();
if (!root || !from || !to) return [];
+
+ const resolvedFrom = await this.resolveGitRef(root, from);
+ const resolvedTo = await this.resolveGitRef(root, to);
+ if (!resolvedFrom || !resolvedTo) {
+ this.trace(`getBranchDiff: unresolved ref from=${from} to=${to}`);
+ return [];
+ }
+
try {
- const out = await runGit(root, ['diff', '--name-status', `${from}...${to}`]);
+ const out = await runGit(root, ['diff', '--name-status', `${resolvedFrom}...${resolvedTo}`]);
const files = parseNameStatus(out);
- this.trace(`getBranchDiff(${from}...${to}): files=${files.length}`);
+ this.trace(`getBranchDiff(${resolvedFrom}...${resolvedTo}): files=${files.length}`);
return files;
} catch (e) {
this.trace(`getBranchDiff failed: ${e instanceof Error ? e.message : String(e)}`);
@@ -119,12 +222,22 @@ export class GitService {
}
}
+ private async resolveGitRef(root: string, ref: string): Promise {
+ for (const candidate of branchRefCandidates(ref)) {
+ try {
+ const out = await runGit(root, ['rev-parse', '--verify', candidate]);
+ if (out.trim()) return candidate;
+ } catch { /* try next candidate */ }
+ }
+ return null;
+ }
+
/** 单次提交:该 commit 相对父提交的改动文件。 */
async getCommitFiles(sha: string): Promise {
const root = await this.repoRoot();
if (!root || !sha) return [];
try {
- const out = await runGit(root, ['show', '--name-status', '--format=', '--', sha]);
+ const out = await runGit(root, ['show', '--name-status', '--format=', sha]);
const files = parseNameStatus(out);
this.trace(`getCommitFiles(${sha}): files=${files.length}`);
return files;
@@ -136,7 +249,7 @@ export class GitService {
private async repoRoot(): Promise {
const repo = await this.waitForRepo();
- if (!repo) return null;
+ if (!repo) return this.repoRootFast();
return repo.rootUri?.fsPath
?? vscode.workspace.workspaceFolders?.[0].uri.fsPath
?? process.cwd();
@@ -153,41 +266,125 @@ export class GitService {
const fileUri = vscode.Uri.file(`${root}/${opts.path}`);
- // 二进制无法做文本 diff,直接打开文件本身。
if (opts.status === 'binary') {
- try { await vscode.window.showTextDocument(fileUri, { preview: true }); } catch { /* ignore */ }
+ if (opts.mode === ReviewMode.Workspace) {
+ try { await vscode.window.showTextDocument(fileUri, { preview: true }); } catch { /* ignore */ }
+ }
return;
}
- // toGitUri(uri, '') 返回空文档,用于新增/删除时缺失的一侧。
- const emptyRef = '';
- let left: vscode.Uri;
- let right: vscode.Uri;
- let label: string;
-
- if (opts.mode === 'workspace') {
- left = api.toGitUri(fileUri, opts.status === 'added' ? emptyRef : 'HEAD');
- right = opts.status === 'deleted' ? api.toGitUri(fileUri, emptyRef) : fileUri;
- label = t(resolveLocale(vscode.env.language), 'ext.git.workspaceVsHead');
- } else if (opts.mode === 'commit' && opts.commit) {
- left = api.toGitUri(fileUri, opts.status === 'added' ? emptyRef : `${opts.commit}^`);
- right = opts.status === 'deleted' ? api.toGitUri(fileUri, emptyRef) : api.toGitUri(fileUri, opts.commit);
- label = `${opts.commit}^ ↔ ${opts.commit}`;
- } else if (opts.mode === 'branch' && opts.from && opts.to) {
- // 文件列表用三点 diff(merge-base),逐文件 diff 也应以 merge-base 为基准。
- const base = (await this.mergeBase(root, opts.from, opts.to)) || opts.from;
- left = api.toGitUri(fileUri, opts.status === 'added' ? emptyRef : base);
- right = opts.status === 'deleted' ? api.toGitUri(fileUri, emptyRef) : api.toGitUri(fileUri, opts.to);
- label = `${opts.from}...${opts.to}`;
- } else {
+ if (opts.mode === ReviewMode.Workspace) {
+ const emptyRef = '';
+ const left = api.toGitUri(fileUri, opts.status === 'added' ? emptyRef : 'HEAD');
+ const right = opts.status === 'deleted' ? api.toGitUri(fileUri, emptyRef) : fileUri;
+ const label = t(resolveLocale(vscode.env.language), 'ext.git.workspaceVsHead');
+ await this.presentDiff(left, right, `${opts.path} (${label})`);
return;
}
- const title = `${opts.path} (${label})`;
+ if (opts.mode === ReviewMode.Commit && opts.commit) {
+ const parent = `${opts.commit}^`;
+ const label = `${opts.commit}^ ↔ ${opts.commit}`;
+ const leftRef = opts.status === 'added' ? null : parent;
+ const rightRef = opts.status === 'deleted' ? null : opts.commit;
+ await this.presentRefDiff(api, root, opts.path, opts.status, leftRef, rightRef, `${opts.path} (${label})`, {
+ fallbackRange: `${parent}..${opts.commit}`,
+ });
+ return;
+ }
+
+ if (opts.mode === ReviewMode.Branch && opts.from && opts.to) {
+ const resolvedFrom = await this.resolveGitRef(root, opts.from);
+ const resolvedTo = await this.resolveGitRef(root, opts.to);
+ if (!resolvedFrom || !resolvedTo) return;
+ const base = (await this.mergeBase(root, resolvedFrom, resolvedTo)) || resolvedFrom;
+ const label = `${resolvedFrom}...${resolvedTo}`;
+ const leftRef = opts.status === 'added' ? null : base;
+ const rightRef = opts.status === 'deleted' ? null : resolvedTo;
+ await this.presentRefDiff(api, root, opts.path, opts.status, leftRef, rightRef, `${opts.path} (${label})`, {
+ fallbackRange: `${resolvedFrom}...${resolvedTo}`,
+ });
+ }
+ }
+
+ /** 分支/提交模式:用 git ref 或空文件构造两侧,走 VS Code 原生 diff 编辑器。 */
+ private async presentRefDiff(
+ api: any,
+ root: string,
+ relPath: string,
+ status: FileChange['status'],
+ leftRef: string | null,
+ rightRef: string | null,
+ title: string,
+ opts: { fallbackRange: string },
+ ): Promise {
+ const left = await this.resolveDiffSide(api, root, relPath, leftRef, status, 'left');
+ const right = await this.resolveDiffSide(api, root, relPath, rightRef, status, 'right');
+ const opened = await this.presentDiff(left, right, title);
+ if (!opened) {
+ await this.presentPatchDiff(root, opts.fallbackRange, relPath, title);
+ }
+ }
+
+ private async resolveDiffSide(
+ api: any,
+ root: string,
+ relPath: string,
+ ref: string | null,
+ status: FileChange['status'],
+ side: 'left' | 'right',
+ ): Promise {
+ if (side === 'left' && (status === 'added' || !ref)) return this.emptySideUri();
+ if (side === 'right' && (status === 'deleted' || !ref)) return this.emptySideUri();
+ if (!ref || !(await this.pathExistsAtRef(root, ref, relPath))) return this.emptySideUri();
+ return api.toGitUri(vscode.Uri.file(`${root}/${relPath}`), ref);
+ }
+
+ /** 空侧占位(等同 /dev/null),用于新增/删除文件的单侧 diff。 */
+ private emptySideUri(): vscode.Uri {
+ return vscode.Uri.file(process.platform === 'win32' ? '\\\\.\\NUL' : '/dev/null');
+ }
+
+ private async presentDiff(
+ left: vscode.Uri,
+ right: vscode.Uri,
+ title: string,
+ ): Promise {
try {
await vscode.commands.executeCommand('vscode.diff', left, right, title, { preview: true });
+ return true;
} catch (e) {
- this.trace(`openDiff failed: ${e instanceof Error ? e.message : String(e)}`);
+ this.trace(`presentDiff failed: ${e instanceof Error ? e.message : String(e)}`);
+ return false;
+ }
+ }
+
+ /** 最后兜底:原生 diff 失败时才展示补丁文本。 */
+ private async presentPatchDiff(
+ root: string,
+ range: string,
+ relPath: string,
+ title: string,
+ ): Promise {
+ let patch = '';
+ try {
+ patch = await runGit(root, ['diff', range, '--', relPath]);
+ } catch (e) {
+ this.trace(`presentPatchDiff failed: ${e instanceof Error ? e.message : String(e)}`);
+ }
+ const doc = await vscode.workspace.openTextDocument({
+ content: patch || `# ${title}\n\n(no changes)`,
+ language: 'diff',
+ });
+ await vscode.window.showTextDocument(doc, { preview: true });
+ }
+
+ private async pathExistsAtRef(root: string, ref: string, relPath: string): Promise {
+ try {
+ await runGit(root, ['cat-file', '-e', `${ref}:${relPath}`]);
+ return true;
+ } catch {
+ return false;
}
}
@@ -199,11 +396,102 @@ export class GitService {
return null;
}
}
+
+ /** 审查开始前缓存 commit/branch 模式下的文件状态,供评论挂载查询。 */
+ async prepareReviewFileStatus(ctx: ReviewContext): Promise {
+ this.reviewFileStatus.clear();
+ if (ctx.mode === ReviewMode.Commit && ctx.commit) {
+ const files = await this.getCommitFiles(ctx.commit);
+ for (const f of files) this.reviewFileStatus.set(f.path, f.status);
+ } else if (ctx.mode === ReviewMode.Branch && ctx.from && ctx.to) {
+ const files = await this.getBranchDiff(ctx.from, ctx.to);
+ for (const f of files) this.reviewFileStatus.set(f.path, f.status);
+ }
+ }
+
+ async getReviewFileStatus(path: string): Promise {
+ return this.reviewFileStatus.get(path) ?? null;
+ }
+
+ async readFileAtRef(ref: string, relPath: string): Promise {
+ const root = await this.repoRoot();
+ if (!root) return null;
+ try {
+ return await runGit(root, ['show', `${ref}:${relPath}`]);
+ } catch {
+ return null;
+ }
+ }
+
+ async readWorkspaceFile(relPath: string): Promise {
+ const root = await this.repoRoot();
+ if (!root) return null;
+ try {
+ return await readFile(`${root}/${relPath}`, 'utf8');
+ } catch {
+ return null;
+ }
+ }
+
+ /** 构造评论挂载用的 diff 两侧 URI 与挂载 ref。 */
+ async buildCommentDiffUris(
+ relPath: string,
+ status: FileChange['status'],
+ ctx: ReviewContext,
+ ): Promise<{
+ left: vscode.Uri;
+ right: vscode.Uri;
+ title: string;
+ mountRef: string;
+ mountSide: 'left' | 'right';
+ leftRef: string | null;
+ rightRef: string | null;
+ } | null> {
+ const api = await this.ensureApi();
+ const root = await this.repoRoot();
+ if (!api || !root) return null;
+
+ if (ctx.mode === ReviewMode.Commit && ctx.commit) {
+ const parent = `${ctx.commit}^`;
+ const label = `${ctx.commit}^ ↔ ${ctx.commit}`;
+ const leftRef = status === 'added' ? null : parent;
+ const rightRef = status === 'deleted' ? null : ctx.commit;
+ const mountRef = status === 'deleted' ? parent : ctx.commit;
+ const mountSide: 'left' | 'right' = status === 'deleted' ? 'left' : 'right';
+ const left = await this.resolveDiffSide(api, root, relPath, leftRef, status, 'left');
+ const right = await this.resolveDiffSide(api, root, relPath, rightRef, status, 'right');
+ return { left, right, title: `${relPath} (${label})`, mountRef, mountSide, leftRef, rightRef };
+ }
+
+ if (ctx.mode === ReviewMode.Branch && ctx.from && ctx.to) {
+ const resolvedFrom = await this.resolveGitRef(root, ctx.from);
+ const resolvedTo = await this.resolveGitRef(root, ctx.to);
+ if (!resolvedFrom || !resolvedTo) return null;
+ const base = (await this.mergeBase(root, resolvedFrom, resolvedTo)) || resolvedFrom;
+ const label = `${resolvedFrom}...${resolvedTo}`;
+ const leftRef = status === 'added' ? null : base;
+ const rightRef = status === 'deleted' ? null : resolvedTo;
+ const mountRef = status === 'deleted' ? base : resolvedTo;
+ const mountSide: 'left' | 'right' = status === 'deleted' ? 'left' : 'right';
+ const left = await this.resolveDiffSide(api, root, relPath, leftRef, status, 'left');
+ const right = await this.resolveDiffSide(api, root, relPath, rightRef, status, 'right');
+ return { left, right, title: `${relPath} (${label})`, mountRef, mountSide, leftRef, rightRef };
+ }
+
+ return null;
+ }
+
+ async createGitFileUri(relPath: string, ref: string): Promise {
+ const api = await this.ensureApi();
+ const root = await this.repoRoot();
+ if (!api || !root) return null;
+ return api.toGitUri(vscode.Uri.file(`${root}/${relPath}`), ref);
+ }
}
function runGit(cwd: string, args: string[]): Promise {
return new Promise((resolve, reject) => {
- execFile('git', args, { cwd, maxBuffer: 10 * 1024 * 1024 }, (err, stdout) => {
+ execFile('git', ['-c', 'core.quotepath=false', ...args], { cwd, maxBuffer: 10 * 1024 * 1024 }, (err, stdout) => {
if (err) reject(err);
else resolve(stdout);
});
diff --git a/extensions/vscode/src/extension/services/__tests__/cliParse.test.ts b/extensions/vscode/src/extension/services/__tests__/cliParse.test.ts
index 25145a7..ca8fea1 100644
--- a/extensions/vscode/src/extension/services/__tests__/cliParse.test.ts
+++ b/extensions/vscode/src/extension/services/__tests__/cliParse.test.ts
@@ -1,28 +1,29 @@
import { buildReviewArgs, extractCliError, parseCliResult, parseLogLine } from '../cliParse';
+import { ReviewMode } from '../../../shared/types';
describe('buildReviewArgs', () => {
it('workspace 模式加 --format json', () => {
- expect(buildReviewArgs({ mode: 'workspace' }))
+ expect(buildReviewArgs({ mode: ReviewMode.Workspace }))
.toEqual(['review', '--format', 'json']);
});
it('branch 模式加 --from/--to', () => {
- expect(buildReviewArgs({ mode: 'branch', from: 'main', to: 'dev' }))
+ expect(buildReviewArgs({ mode: ReviewMode.Branch, from: 'main', to: 'dev' }))
.toEqual(['review', '--from', 'main', '--to', 'dev', '--format', 'json']);
});
it('commit 模式加 --commit', () => {
- expect(buildReviewArgs({ mode: 'commit', commit: 'abc123' }))
+ expect(buildReviewArgs({ mode: ReviewMode.Commit, commit: 'abc123' }))
.toEqual(['review', '--commit', 'abc123', '--format', 'json']);
});
it('customPrompt 追加 --background', () => {
- expect(buildReviewArgs({ mode: 'workspace', customPrompt: '关注安全' }))
+ expect(buildReviewArgs({ mode: ReviewMode.Workspace, customPrompt: '关注安全' }))
.toEqual(['review', '--format', 'json', '--background', '关注安全']);
});
it('concurrency 追加 --concurrency', () => {
- expect(buildReviewArgs({ mode: 'workspace', concurrency: 4 }))
+ expect(buildReviewArgs({ mode: ReviewMode.Workspace, concurrency: 4 }))
.toEqual(['review', '--format', 'json', '--concurrency', '4']);
});
});
diff --git a/extensions/vscode/src/extension/services/__tests__/gitMap.test.ts b/extensions/vscode/src/extension/services/__tests__/gitMap.test.ts
index 3cf15a1..b8eb991 100644
--- a/extensions/vscode/src/extension/services/__tests__/gitMap.test.ts
+++ b/extensions/vscode/src/extension/services/__tests__/gitMap.test.ts
@@ -1,5 +1,18 @@
// src/extension/services/__tests__/gitMap.test.ts
-import { mapStatusCode, parsePorcelain, parseNameStatus, pickRepoRoot } from '../gitMap';
+import { execFile } from 'child_process';
+import path from 'path';
+import { promisify } from 'util';
+import {
+ buildWorkspaceFiles,
+ branchRefCandidates,
+ mapStatusCode,
+ mergeWorkspaceFiles,
+ parsePorcelain,
+ parseNameStatus,
+ parseUntrackedList,
+ pickRepoRoot,
+ unquoteGitPath,
+} from '../gitMap';
describe('mapStatusCode', () => {
it('VSCode git Status 枚举映射到 FileChange.status', () => {
@@ -81,6 +94,82 @@ describe('parseNameStatus', () => {
});
});
+describe('buildWorkspaceFiles', () => {
+ it('合并 diff HEAD 与未跟踪文件', () => {
+ const files = buildWorkspaceFiles(
+ 'M\tsrc/a.ts\nA\tsrc/b.ts',
+ '',
+ 'src/c.ts\n',
+ );
+ expect(files).toEqual([
+ { path: 'src/a.ts', status: 'modified' },
+ { path: 'src/b.ts', status: 'added' },
+ { path: 'src/c.ts', status: 'added' },
+ ]);
+ });
+
+ it('diff HEAD 为空时回退 staged', () => {
+ const files = buildWorkspaceFiles('', 'M\tsrc/staged.ts', '');
+ expect(files).toEqual([{ path: 'src/staged.ts', status: 'modified' }]);
+ });
+
+ it('按路径去重,已跟踪优先于未跟踪', () => {
+ const files = buildWorkspaceFiles('M\tsrc/a.ts', '', 'src/a.ts');
+ expect(files).toEqual([{ path: 'src/a.ts', status: 'modified' }]);
+ });
+});
+
+describe('parseUntrackedList', () => {
+ it('解析未跟踪路径并忽略空行', () => {
+ expect(parseUntrackedList('src/a.ts\n\n src/b.ts \n')).toEqual(['src/a.ts', 'src/b.ts']);
+ });
+});
+
+describe('mergeWorkspaceFiles', () => {
+ it('合并并去重', () => {
+ expect(mergeWorkspaceFiles(
+ [{ path: 'a.ts', status: 'modified' }],
+ ['b.ts', 'a.ts'],
+ )).toEqual([
+ { path: 'a.ts', status: 'modified' },
+ { path: 'b.ts', status: 'added' },
+ ]);
+ });
+});
+
+describe('unquoteGitPath', () => {
+ it('解码 Git quotepath 八进制转义的中文路径', () => {
+ const quoted = '"\\344\\273\\243\\347\\240\\201\\344\\277\\256\\346\\224\\271\\346\\234\\200\\345\\260\\217\\345\\271\\262\\351\\242\\204\\350\\247\\204\\345\\210\\231.md"';
+ expect(unquoteGitPath(quoted)).toBe('代码修改最小干预规则.md');
+ });
+
+ it('普通路径原样返回', () => {
+ expect(unquoteGitPath('src/a.ts')).toBe('src/a.ts');
+ });
+});
+
+describe('parseNameStatus', () => {
+ it('解析 quotepath 转义路径', () => {
+ expect(parseNameStatus('A\t"\\344\\273\\243\\347\\240\\201.md"')).toEqual([
+ { path: '代码.md', status: 'added' },
+ ]);
+ });
+});
+
+describe('branchRefCandidates', () => {
+ it('本地分支名补充 origin/ 前缀', () => {
+ expect(branchRefCandidates('dev')).toEqual(['dev', 'origin/dev']);
+ });
+
+ it('master 回退到 main 候选', () => {
+ expect(branchRefCandidates('master')).toEqual(['master', 'origin/master', 'main', 'origin/main']);
+ });
+
+ it('已是远程引用时不重复拼接', () => {
+ expect(branchRefCandidates('origin/main')).toEqual(['origin/main']);
+ });
+});
+
describe('pickRepoRoot', () => {
const ws = '/Users/lost/tre/copilot-union/code-chat';
@@ -117,3 +206,19 @@ describe('pickRepoRoot', () => {
expect(pickRepoRoot(roots, undefined)).toBe('/a/repo');
});
});
+
+describe('getCommitFiles: git show revision placement', () => {
+ const repoRoot = path.resolve(__dirname, '../../../../../..');
+ const execGit = (args: string[]) =>
+ promisify(execFile)('git', ['-c', 'core.quotepath=false', ...args], { cwd: repoRoot })
+ .then((r) => r.stdout.trim());
+
+ it('revision 必须在 -- 之前,否则会被当成 pathspec 导致空列表', async () => {
+ const good = await execGit(['show', '--name-status', '--format=', 'HEAD']);
+ const bad = await execGit(['show', '--name-status', '--format=', '--', 'HEAD']);
+ expect(good.length).toBeGreaterThan(0);
+ expect(bad).toBe('');
+ expect(parseNameStatus(good).length).toBeGreaterThan(0);
+ expect(parseNameStatus(bad)).toEqual([]);
+ });
+});
diff --git a/extensions/vscode/src/extension/services/cliParse.ts b/extensions/vscode/src/extension/services/cliParse.ts
index c219bce..383faa7 100644
--- a/extensions/vscode/src/extension/services/cliParse.ts
+++ b/extensions/vscode/src/extension/services/cliParse.ts
@@ -1,11 +1,11 @@
-import { CliResult, CliRunOptions, LogLine, ReviewComment } from '../../shared/types';
+import { CliResult, CliRunOptions, LogLine, ReviewComment, ReviewMode } from '../../shared/types';
export function buildReviewArgs(opts: CliRunOptions): string[] {
const args: string[] = ['review'];
- if (opts.mode === 'branch') {
+ if (opts.mode === ReviewMode.Branch) {
if (opts.from) args.push('--from', opts.from);
if (opts.to) args.push('--to', opts.to);
- } else if (opts.mode === 'commit') {
+ } else if (opts.mode === ReviewMode.Commit) {
if (opts.commit) args.push('--commit', opts.commit);
}
args.push('--format', 'json');
diff --git a/extensions/vscode/src/extension/services/gitMap.ts b/extensions/vscode/src/extension/services/gitMap.ts
index 0c92b4f..68b3e21 100644
--- a/extensions/vscode/src/extension/services/gitMap.ts
+++ b/extensions/vscode/src/extension/services/gitMap.ts
@@ -36,6 +36,7 @@ export function parsePorcelain(output: string): FileChange[] {
const c = x !== ' ' && x !== '?' ? x : y;
code = c;
}
+ path = unquoteGitPath(path);
if (seen.has(path)) continue;
seen.add(path);
files.push({ path, status: mapStatusCode(code) });
@@ -43,6 +44,40 @@ export function parsePorcelain(output: string): FileChange[] {
return files;
}
+/** 解析 `git ls-files --others --exclude-standard` 输出的未跟踪路径列表。 */
+export function parseUntrackedList(output: string): string[] {
+ return output.split('\n').map((line) => unquoteGitPath(line.trim())).filter(Boolean);
+}
+
+/** 合并已跟踪变更与未跟踪文件,按路径去重。 */
+export function mergeWorkspaceFiles(tracked: FileChange[], untrackedPaths: string[]): FileChange[] {
+ const files: FileChange[] = [];
+ const seen = new Set();
+ for (const file of tracked) {
+ if (seen.has(file.path)) continue;
+ seen.add(file.path);
+ files.push(file);
+ }
+ for (const path of untrackedPaths) {
+ if (seen.has(path)) continue;
+ seen.add(path);
+ files.push({ path, status: 'added' });
+ }
+ return files;
+}
+
+/**
+ * 从 git diff / ls-files 输出构建工作区文件列表。
+ * 与 OCR CLI workspace 模式一致:先 diff HEAD,空则回退 staged,再合并未跟踪文件。
+ */
+export function buildWorkspaceFiles(diffHeadOut: string, diffCachedOut: string, untrackedOut: string): FileChange[] {
+ let tracked = parseNameStatus(diffHeadOut);
+ if (tracked.length === 0) {
+ tracked = parseNameStatus(diffCachedOut);
+ }
+ return mergeWorkspaceFiles(tracked, parseUntrackedList(untrackedOut));
+}
+
/**
* 从候选仓库根路径中选出与 workspace 匹配的那个。
* VSCode git 扩展异步扫描嵌套仓库,repositories 顺序不稳定,直接取 [0] 会漂移到子仓库。
@@ -63,6 +98,50 @@ export function pickRepoRoot(roots: string[], workspacePath?: string): string |
return roots[0];
}
+/** 生成用于 rev-parse 验证的分支引用候选列表。 */
+export function branchRefCandidates(ref: string): string[] {
+ const candidates = [ref];
+ if (!ref.includes('/')) {
+ candidates.push(`origin/${ref}`);
+ }
+ if (ref === 'master') {
+ candidates.push('main', 'origin/main');
+ } else if (ref === 'main') {
+ candidates.push('master', 'origin/master');
+ }
+ return [...new Set(candidates)];
+}
+
+/**
+ * 解码 Git quotepath 转义路径(core.quotepath=true 时中文等会显示为 "\344\273...")。
+ */
+export function unquoteGitPath(path: string): string {
+ if (!path.startsWith('"') || !path.endsWith('"')) return path;
+
+ const bytes: number[] = [];
+ const inner = path.slice(1, -1);
+ for (let i = 0; i < inner.length; i++) {
+ if (inner[i] !== '\\' || i + 1 >= inner.length) {
+ bytes.push(inner.charCodeAt(i));
+ continue;
+ }
+ if (i + 3 < inner.length && /^\d{3}$/.test(inner.slice(i + 1, i + 4))) {
+ bytes.push(parseInt(inner.slice(i + 1, i + 4), 8));
+ i += 3;
+ continue;
+ }
+ i += 1;
+ const esc = inner[i];
+ if (esc === 'n') bytes.push(0x0a);
+ else if (esc === 't') bytes.push(0x09);
+ else if (esc === 'r') bytes.push(0x0d);
+ else if (esc === '\\') bytes.push(0x5c);
+ else if (esc === '"') bytes.push(0x22);
+ else bytes.push(esc.charCodeAt(0));
+ }
+ return Buffer.from(bytes).toString('utf8');
+}
+
/**
* 解析 `git diff --name-status` / `git show --name-status` 输出。
* 每行制表符分隔:statuspath,重命名为 Roldnew(取 new)。
@@ -75,7 +154,7 @@ export function parseNameStatus(output: string): FileChange[] {
const parts = rawLine.split('\t');
if (parts.length < 2) continue;
const codeChar = parts[0][0];
- const path = parts.length >= 3 ? parts[parts.length - 1] : parts[1];
+ const path = unquoteGitPath(parts.length >= 3 ? parts[parts.length - 1] : parts[1]);
if (seen.has(path)) continue;
seen.add(path);
files.push({ path, status: mapStatusCode(codeChar) });
diff --git a/extensions/vscode/src/shared/i18n.ts b/extensions/vscode/src/shared/i18n.ts
index 346af78..6086566 100644
--- a/extensions/vscode/src/shared/i18n.ts
+++ b/extensions/vscode/src/shared/i18n.ts
@@ -153,6 +153,9 @@ const messages: Record> = {
'ext.comment.statusFalsePositive': '✅ [False Positive]',
'ext.comment.jumpFailed': 'Cannot locate ',
'ext.comment.jumpNotAFile': ': is not an openable file.',
+ 'ext.comment.jumpLineUnresolved': 'Cannot jump to {path}: line number could not be resolved.',
+ 'ext.comment.jumpFileMissing': 'Cannot jump to {path}: file not found in the review snapshot.',
+ 'ext.comment.applyWorkspaceOnly': 'Apply is only available in Workspace review mode.',
'ext.deleteProviderConfirm': 'Delete custom provider "{name}"?',
'ext.deleteProviderConfirmBtn': 'Delete',
'ext.git.justNow': 'just now',
@@ -295,6 +298,9 @@ const messages: Record> = {
'ext.comment.statusFalsePositive': '✅ [已误报]',
'ext.comment.jumpFailed': '无法定位到 ',
'ext.comment.jumpNotAFile': ':该路径不是可打开的文件。',
+ 'ext.comment.jumpLineUnresolved': '无法跳转到 {path}:未能解析行号。',
+ 'ext.comment.jumpFileMissing': '无法跳转到 {path}:在审查快照中找不到该文件。',
+ 'ext.comment.applyWorkspaceOnly': '仅工作区审查模式支持应用建议。',
'ext.deleteProviderConfirm': '确定删除自定义 Provider「{name}」?',
'ext.deleteProviderConfirmBtn': '删除',
'ext.git.justNow': '刚刚',
diff --git a/extensions/vscode/src/shared/providers.ts b/extensions/vscode/src/shared/providers.ts
index ef03f51..684fb7a 100644
--- a/extensions/vscode/src/shared/providers.ts
+++ b/extensions/vscode/src/shared/providers.ts
@@ -95,6 +95,14 @@ export const PROVIDER_PRESETS: OcrProviderPreset[] = [
envVar: 'Z_AI_API_KEY',
models: ['glm-5.2', 'glm-5.1', 'glm-5-turbo', 'glm-4.7'],
},
+ {
+ name: 'z-ai-coding',
+ displayName: 'Z.AI Coding Plan API',
+ protocol: 'openai',
+ baseUrl: 'https://open.bigmodel.cn/api/coding/paas/v4',
+ envVar: 'Z_AI_CODING_API_KEY',
+ models: ['glm-5.2', 'glm-5.1', 'glm-5-turbo', 'glm-4.7'],
+ },
{
name: 'mimo',
displayName: 'Xiaomi MiMo API',
diff --git a/extensions/vscode/src/shared/types.ts b/extensions/vscode/src/shared/types.ts
index b6e54ac..8219bbd 100644
--- a/extensions/vscode/src/shared/types.ts
+++ b/extensions/vscode/src/shared/types.ts
@@ -1,4 +1,8 @@
-export type ReviewMode = 'workspace' | 'branch' | 'commit';
+export enum ReviewMode {
+ Workspace = 'workspace',
+ Branch = 'branch',
+ Commit = 'commit',
+}
export type ReviewState =
| 'idle' | 'running' | 'done' | 'empty' | 'cancelled' | 'failed';
@@ -105,7 +109,11 @@ export interface CliRunOptions {
concurrency?: number;
}
+/** 审查完成后的评论挂载上下文(与 CliRunOptions 字段一致)。 */
+export type ReviewContext = Pick;
+
export interface CommentSyncState {
index: number;
status: CommentStatus;
+ jumpable?: boolean;
}
diff --git a/extensions/vscode/src/webview/App.tsx b/extensions/vscode/src/webview/App.tsx
index caebc28..2291095 100644
--- a/extensions/vscode/src/webview/App.tsx
+++ b/extensions/vscode/src/webview/App.tsx
@@ -55,14 +55,14 @@ export function App() {