+
@@ -91,10 +92,6 @@ agent의 강점은 동적 판단과 동적 context 검색이 중요한 지점에
## 사용 방법
-### 사전 요구 사항
-
-- **Git >= 2.41** — Open Code Review는 diff 생성, 코드 검색, 저장소 작업에 Git을 사용합니다.
-
### CLI
#### 설치
@@ -107,18 +104,6 @@ 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):
@@ -280,10 +265,6 @@ 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 # 디렉터리 또는 특정 파일 스캔
@@ -419,35 +400,10 @@ 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
@@ -462,8 +418,6 @@ GitHub의 경우, 이 리포지터리는 루트에 바로 사용할 수 있는 c
| `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 정보 표시 |
@@ -477,54 +431,17 @@ GitHub의 경우, 이 리포지터리는 루트에 바로 사용할 수 있는 c
| `--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`를 따르는 파일 시스템 탐색으로 폴백).
@@ -570,12 +487,6 @@ 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
@@ -583,12 +494,6 @@ 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
@@ -800,22 +705,13 @@ 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 3ed3d78..8910699 100644
--- a/README.md
+++ b/README.md
@@ -13,6 +13,7 @@
+
@@ -91,10 +92,6 @@ 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
@@ -107,18 +104,6 @@ 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):
@@ -280,10 +265,6 @@ 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
@@ -421,35 +402,10 @@ 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
@@ -464,8 +420,6 @@ Pin to a version tag or commit SHA for reproducibility. See the [`examples/githu
| `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 |
@@ -479,55 +433,16 @@ Pin to a version tag or commit SHA for reproducibility. See the [`examples/githu
| `--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` | — | `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 |
+| `--max-git-procs` | — | built-in | Max concurrent git subprocesses |
+| `--tools` | — | — | Path to custom JSON tools config |
### `ocr scan` Flags
@@ -577,12 +492,6 @@ 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
@@ -590,12 +499,6 @@ 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
@@ -850,23 +753,13 @@ 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
-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.
+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 c1310fa..7034883 100644
--- a/README.ru-RU.md
+++ b/README.ru-RU.md
@@ -13,6 +13,7 @@
+
@@ -91,10 +92,6 @@ Open Code Review — это CLI-инструмент для код-ревью н
## Как использовать
-### Предварительные требования
-
-- **Git >= 2.41** — Open Code Review использует Git для генерации diff, поиска по коду и операций с репозиторием.
-
### CLI
#### Установка
@@ -107,18 +104,6 @@ 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):
@@ -280,10 +265,6 @@ 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 # сканировать каталог или конкретные файлы
@@ -421,35 +402,10 @@ 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/).
## Команды
@@ -464,8 +420,6 @@ 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` | — | Показать информацию о версии |
@@ -479,56 +433,17 @@ 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`).
@@ -574,12 +489,6 @@ 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
@@ -587,12 +496,6 @@ 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
@@ -847,22 +750,13 @@ 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 13ae792..83524ea 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -13,6 +13,7 @@
+
@@ -91,10 +92,6 @@ Open Code Review 的核心设计理念是将确定性工程与 Agent 结合,
## 如何使用
-### 前置条件
-
-- **Git >= 2.41** — Open Code Review 依赖 Git 进行 diff 生成、代码搜索和仓库操作。
-
### CLI
#### 安装
@@ -107,18 +104,6 @@ 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):
@@ -280,10 +265,6 @@ 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 # 扫描指定目录或文件
@@ -419,35 +400,10 @@ 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/) 目录。
## 命令
@@ -462,8 +418,6 @@ 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` | — | 显示版本信息 |
@@ -477,53 +431,17 @@ 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` 的文件系统遍历)。
@@ -569,12 +487,6 @@ 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
@@ -582,12 +494,6 @@ 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
@@ -832,22 +738,13 @@ 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 07d0b38..4fa8ada 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -19,9 +19,6 @@ 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).
@@ -33,15 +30,13 @@ OpenCodeReview currently provides:
PyCharm, and other JetBrains IDEs with the same capabilities as the
existing VSCode extension.
-### Delegate Mode
+### MCP Integration
-- **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.
+- **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.
### Ultra Mode
diff --git a/action.yml b/action.yml
deleted file mode 100644
index 203b2e2..0000000
--- a/action.yml
+++ /dev/null
@@ -1,305 +0,0 @@
-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
deleted file mode 100644
index 2c14a5c..0000000
--- a/cmd/opencodereview/background_file.go
+++ /dev/null
@@ -1,135 +0,0 @@
-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
deleted file mode 100644
index cb18f21..0000000
--- a/cmd/opencodereview/background_file_test.go
+++ /dev/null
@@ -1,312 +0,0 @@
-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 a7c1341..64b511b 100644
--- a/cmd/opencodereview/config_cmd.go
+++ b/cmd/opencodereview/config_cmd.go
@@ -480,13 +480,23 @@ func ensureModelInList(models []string, model string) []string {
if model == "" {
return models
}
- if llm.ModelListContains(models, model) {
+ if 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 f153fe2..4082ca2 100644
--- a/cmd/opencodereview/emit_run_result_test.go
+++ b/cmd/opencodereview/emit_run_result_test.go
@@ -7,9 +7,6 @@ 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"
)
@@ -25,8 +22,6 @@ 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 }
@@ -39,8 +34,6 @@ 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}
@@ -87,32 +80,6 @@ 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() {
@@ -126,19 +93,6 @@ 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}}
@@ -221,78 +175,3 @@ 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 1f72bb1..ccda515 100644
--- a/cmd/opencodereview/flags.go
+++ b/cmd/opencodereview/flags.go
@@ -101,12 +101,10 @@ 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
@@ -127,14 +125,12 @@ 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")
@@ -166,9 +162,6 @@ 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":
@@ -210,9 +203,6 @@ 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
@@ -224,29 +214,22 @@ 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
- -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)`)
+ --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)`)
}
// --- config subcommand ---
diff --git a/cmd/opencodereview/flags_test.go b/cmd/opencodereview/flags_test.go
index 8e25996..15701ca 100644
--- a/cmd/opencodereview/flags_test.go
+++ b/cmd/opencodereview/flags_test.go
@@ -5,20 +5,6 @@ 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 {
@@ -36,23 +22,6 @@ 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 dba1d6f..0185b56 100644
--- a/cmd/opencodereview/git.go
+++ b/cmd/opencodereview/git.go
@@ -12,15 +12,6 @@ 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 4528b29..0630a4c 100644
--- a/cmd/opencodereview/git_test.go
+++ b/cmd/opencodereview/git_test.go
@@ -23,17 +23,11 @@ func initTestGitRepo(t *testing.T) string {
}
}
f := filepath.Join(dir, "README.md")
- if err := os.WriteFile(f, []byte("hello"), 0o644); err != nil {
- t.Fatalf("write README: %v", err)
- }
+ os.WriteFile(f, []byte("hello"), 0o644)
cmd := exec.Command("git", "-C", dir, "add", ".")
- if out, err := cmd.CombinedOutput(); err != nil {
- t.Fatalf("git add: %v: %s", err, out)
- }
+ cmd.CombinedOutput()
cmd = exec.Command("git", "-C", dir, "commit", "-m", "initial commit")
- if out, err := cmd.CombinedOutput(); err != nil {
- t.Fatalf("git commit: %v: %s", err, out)
- }
+ cmd.CombinedOutput()
return dir
}
@@ -99,18 +93,9 @@ func TestResolveRepoDir_NotGitRepo(t *testing.T) {
func TestResolveRepoDir_EmptyUsesWd(t *testing.T) {
dir := initTestGitRepo(t)
- origDir, err := os.Getwd()
- if err != nil {
- t.Fatalf("getwd: %v", err)
- }
- defer func() {
- if err := os.Chdir(origDir); err != nil {
- t.Errorf("restore chdir: %v", err)
- }
- }()
- if err := os.Chdir(dir); err != nil {
- t.Fatalf("chdir: %v", err)
- }
+ origDir, _ := os.Getwd()
+ defer os.Chdir(origDir)
+ os.Chdir(dir)
resolved, err := resolveRepoDir("")
if err != nil {
diff --git a/cmd/opencodereview/main.go b/cmd/opencodereview/main.go
index 378e22e..fac044e 100644
--- a/cmd/opencodereview/main.go
+++ b/cmd/opencodereview/main.go
@@ -56,8 +56,6 @@ 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
@@ -79,7 +77,6 @@ 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:
@@ -92,7 +89,6 @@ 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.
@@ -100,7 +96,6 @@ 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 0006164..3ec31c3 100644
--- a/cmd/opencodereview/output.go
+++ b/cmd/opencodereview/output.go
@@ -61,19 +61,7 @@ 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 != "" {
- 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):]
- }
+ for _, ln := range wrapByRunes(sanitizeTerminal(comment.Content), 100) {
fmt.Printf("%s\n", ln)
}
fmt.Println()
@@ -95,41 +83,6 @@ 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")
@@ -240,15 +193,12 @@ 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 {
@@ -266,10 +216,9 @@ 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, traceID string, resumeInfo *agent.ResumeInfo, sessionID string) error {
+ duration time.Duration, projectSummary string, toolCalls map[string]int64) error {
out := jsonOutput{
Status: "success",
- TraceID: traceID,
Comments: comments,
Summary: &jsonSummary{
FilesReviewed: filesReviewed,
@@ -282,8 +231,6 @@ 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 {
@@ -317,10 +264,9 @@ func outputJSONWithWarnings(comments []model.LlmComment, warnings []agent.AgentW
return enc.Encode(out)
}
-func outputJSONNoFiles(traceID string) error {
+func outputJSONNoFiles() 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 80520b2..d0d25b2 100644
--- a/cmd/opencodereview/output_helpers_test.go
+++ b/cmd/opencodereview/output_helpers_test.go
@@ -168,8 +168,9 @@ 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, "abc123trace", nil, "")
- _ = w.Close()
+ err := outputJSONWithWarnings(nil, warnings, 1, 10, 5, 15, 0, 0, time.Second, "", nil)
+
+ w.Close()
os.Stdout = old
if err != nil {
@@ -177,21 +178,16 @@ func TestOutputJSONWithWarnings_NoCommentsSubtaskError(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 {
- t.Fatalf("unmarshal: %v", err)
- }
+ json.Unmarshal(buf.Bytes(), &out)
if out.Status != "completed_with_errors" {
t.Errorf("status = %q, want completed_with_errors", out.Status)
}
if !strings.Contains(out.Message, "errors") {
t.Errorf("message = %q, expected to mention errors", out.Message)
}
- if out.TraceID != "abc123trace" {
- t.Errorf("trace_id = %q, want abc123trace", out.TraceID)
- }
}
func TestStatusBadge(t *testing.T) {
@@ -226,7 +222,7 @@ func TestOutputJSON(t *testing.T) {
}
err := outputJSON(comments)
- _ = w.Close()
+ w.Close()
os.Stdout = old
if err != nil {
@@ -234,7 +230,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 {
@@ -255,7 +251,7 @@ func TestOutputJSON_NoComments(t *testing.T) {
err := outputJSON(nil)
- _ = w.Close()
+ w.Close()
os.Stdout = old
if err != nil {
@@ -263,12 +259,10 @@ func TestOutputJSON_NoComments(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 {
- t.Fatalf("unmarshal: %v", err)
- }
+ json.Unmarshal(buf.Bytes(), &out)
if out.Message == "" {
t.Error("expected non-empty message when no comments")
}
@@ -281,8 +275,9 @@ 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}, "trace-xyz-789", nil, "")
- _ = w.Close()
+ err := outputJSONWithWarnings(comments, warnings, 5, 100, 50, 150, 10, 5, 3*time.Second, "summary", map[string]int64{"file_read": 3})
+
+ w.Close()
os.Stdout = old
if err != nil {
@@ -290,12 +285,10 @@ func TestOutputJSONWithWarnings(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 {
- t.Fatalf("unmarshal: %v", err)
- }
+ json.Unmarshal(buf.Bytes(), &out)
if out.Status != "completed_with_errors" {
t.Errorf("status = %q, want completed_with_errors", out.Status)
}
@@ -308,9 +301,6 @@ 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) {
@@ -319,8 +309,9 @@ 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, "", nil, "")
- _ = w.Close()
+ err := outputJSONWithWarnings(nil, warnings, 2, 50, 20, 70, 0, 0, time.Second, "", nil)
+
+ w.Close()
os.Stdout = old
if err != nil {
@@ -328,12 +319,10 @@ func TestOutputJSONWithWarnings_NoCommentsNoErrors(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 {
- t.Fatalf("unmarshal: %v", err)
- }
+ json.Unmarshal(buf.Bytes(), &out)
if out.Status != "completed_with_warnings" {
t.Errorf("status = %q, want completed_with_warnings", out.Status)
}
@@ -347,9 +336,9 @@ func TestOutputJSONNoFiles(t *testing.T) {
r, w, _ := os.Pipe()
os.Stdout = w
- err := outputJSONNoFiles("test-trace-id-456")
+ err := outputJSONNoFiles()
- _ = w.Close()
+ w.Close()
os.Stdout = old
if err != nil {
@@ -357,18 +346,13 @@ func TestOutputJSONNoFiles(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 {
- t.Fatalf("unmarshal: %v", err)
- }
+ json.Unmarshal(buf.Bytes(), &out)
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 {
@@ -380,10 +364,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 038cd84..e0cccfb 100644
--- a/cmd/opencodereview/output_test.go
+++ b/cmd/opencodereview/output_test.go
@@ -1,78 +1,6 @@
package main
-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)
- }
-}
+import "testing"
func TestSanitizeTerminal(t *testing.T) {
tests := []struct {
@@ -93,8 +21,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)", "before\u009bafter", "beforeafter"},
- {"strips C1 OSC (U+009D)", "before\u009dafter", "beforeafter"},
+ {"strips C1 CSI (U+009B)", "beforeafter", "beforeafter"},
+ {"strips C1 OSC (U+009D)", "beforeafter", "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 db66b71..995efb7 100644
--- a/cmd/opencodereview/provider_cmd.go
+++ b/cmd/opencodereview/provider_cmd.go
@@ -32,9 +32,13 @@ func runConfigProvider() error {
final := finalModel.(providerTUIModel)
if !final.confirmed {
- // TUI persists changes during the session; Esc only abandons the final
- // provider/API-key confirmation step.
- printWizardCancelled(final.savedInSession, "Configuration changes")
+ // 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.")
return nil
}
@@ -51,16 +55,6 @@ 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 {
@@ -139,8 +133,7 @@ func applyCustomProviderConfig(configPath string, cfg *Config, result providerTU
if result.provider == "" {
return fmt.Errorf("provider name is required")
}
- model := result.resolvedModel()
- if model == "" {
+ if result.model == "" {
return fmt.Errorf("model is required")
}
@@ -149,11 +142,11 @@ func applyCustomProviderConfig(configPath string, cfg *Config, result providerTU
}
entry := cfg.CustomProviders[result.provider]
- entry.Model = model
+ entry.Model = result.model
if len(result.models) > 0 {
entry.Models = append([]string(nil), result.models...)
}
- entry.Models = ensureModelInList(entry.Models, model)
+ entry.Models = ensureModelInList(entry.Models, result.model)
if result.url != "" {
entry.URL = result.url
}
@@ -169,16 +162,14 @@ 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 = model
+ cfg.Model = result.model
} else if cfg.Provider == result.provider {
- cfg.Model = model
+ cfg.Model = result.model
}
if err := saveConfig(configPath, cfg); err != nil {
@@ -191,13 +182,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", model)
+ fmt.Printf("Model: %s\n", result.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", model)
+ fmt.Printf("Model: %s\n", result.model)
fmt.Println("\nTesting connection...")
if err := runLLMTest(); err != nil {
@@ -211,11 +202,7 @@ func applyCustomProviderConfig(configPath string, cfg *Config, result providerTU
}
func applyOfficialProviderConfig(configPath string, cfg *Config, result providerTUIResult) error {
- if result.provider == "" {
- return fmt.Errorf("provider and model are required")
- }
- model := result.resolvedModel()
- if model == "" {
+ if result.provider == "" || result.model == "" {
return fmt.Errorf("provider and model are required")
}
@@ -236,15 +223,12 @@ func applyOfficialProviderConfig(configPath string, cfg *Config, result provider
}
entry := cfg.Providers[result.provider]
- entry.Model = model
+ entry.Model = result.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
@@ -252,14 +236,14 @@ func applyOfficialProviderConfig(configPath string, cfg *Config, result provider
cfg.Model = ""
}
cfg.Provider = result.provider
- cfg.Model = model
+ cfg.Model = result.model
if err := saveConfig(configPath, cfg); err != nil {
return err
}
fmt.Printf("\nProvider set to: %s\n", result.provider)
- fmt.Printf("Model: %s\n", model)
+ fmt.Printf("Model: %s\n", result.model)
fmt.Println("\nTesting connection...")
if err := runLLMTest(); err != nil {
@@ -290,10 +274,8 @@ 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)
@@ -311,15 +293,7 @@ func runConfigModel() error {
provider.Models = mergeModelLists(entry.Models)
}
- m := newModelTUIConfig(modelTUIConfig{
- Provider: provider,
- CurrentModel: currentModel,
- RegistryModels: registryModels,
- ExistingCfg: cfg,
- ConfigPath: configPath,
- ProviderName: cfg.Provider,
- IsCustom: isCustom,
- })
+ m := newModelTUI(provider, currentModel)
p := tea.NewProgram(m)
finalModel, err := p.Run()
if err != nil {
@@ -328,7 +302,7 @@ func runConfigModel() error {
final := finalModel.(modelTUIModel)
if final.cancelled {
- printWizardCancelled(final.savedInSession, "Model list changes")
+ fmt.Println("Cancelled.")
return nil
}
@@ -351,9 +325,7 @@ func runConfigModel() error {
}
entry := cfg.Providers[cfg.Provider]
entry.Model = 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) {
+ if !modelListContains(provider.Models, 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 d1f03bb..54dc906 100644
--- a/cmd/opencodereview/provider_cmd_test.go
+++ b/cmd/opencodereview/provider_cmd_test.go
@@ -2,7 +2,6 @@ package main
import (
"encoding/json"
- "io"
"os"
"path/filepath"
"testing"
@@ -204,175 +203,3 @@ 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 8e55a76..2e0b795 100644
--- a/cmd/opencodereview/provider_tui.go
+++ b/cmd/opencodereview/provider_tui.go
@@ -58,50 +58,17 @@ 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
- 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
+ provider string
+ model string
+ models []string
+ apiKey string
+ isCustom bool
+ isEdit bool
+ editTargetName string
+ isManual bool
+ url string
+ protocol string
+ authHeader string
}
type providerTUIModel struct {
@@ -153,9 +120,6 @@ 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
@@ -182,17 +146,6 @@ 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
@@ -334,7 +287,7 @@ func newProviderTUI(cfg *Config, configPath string) providerTUIModel {
if cfg.Llm.AuthToken != "" {
m.manualTokenOriginal = cfg.Llm.AuthToken
m.manualTokenMasked = true
- m.manualTokenInput.SetValue(maskedSecretPlaceholder())
+ m.manualTokenInput.SetValue(strings.Repeat("*", 20))
}
if cfg.Llm.UseAnthropic == nil || *cfg.Llm.UseAnthropic {
m.manualProtocolIdx = 0 // anthropic
@@ -377,97 +330,12 @@ 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:
- provider := m.currentProvider()
- models := registryModelsForProvider(provider.Name, provider.Models)
+ models := m.currentProvider().Models
if m.existingCfg != nil {
+ provider := m.currentProvider()
if entry, ok := m.existingCfg.Providers[provider.Name]; ok {
models = mergeModelLists(models, entry.Models)
}
@@ -481,19 +349,12 @@ func (m providerTUIModel) models() []string {
return nil
}
-func (m *providerTUIModel) prepareModelSelection(providerName, configModel string) {
+func (m *providerTUIModel) prepareModelSelection(currentModel 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
@@ -509,32 +370,6 @@ func (m *providerTUIModel) prepareModelSelection(providerName, configModel strin
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 {
@@ -552,9 +387,43 @@ func (m *providerTUIModel) syncSessionModelSelection() error {
if model == "" {
return nil
}
- // Remember the pick for in-wizard navigation only; persist provider/model on
- // final confirm (applyOfficialProviderConfig / applyCustomProviderConfig).
- m.recordSessionModelPick(model)
+
+ 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
return nil
}
@@ -661,11 +530,12 @@ 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.cursorOnDeletableModel() {
+ if m.step == stepModel && m.activeTab == tabCustom && m.customIdx < len(m.customProviders) {
models := m.models()
- m.confirmingDeleteModel = true
- m.deleteModelName = models[m.modelIdx]
- return m, nil
+ if m.modelIdx < len(models) {
+ m.confirmingDeleteModel = true
+ m.deleteModelName = models[m.modelIdx]
+ }
}
return m, nil
@@ -685,9 +555,6 @@ 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
@@ -714,21 +581,17 @@ func (m providerTUIModel) updateCustomModelInput(key string, msg tea.KeyPressMsg
if name == "" {
return m, nil
}
- if llm.ModelListContains(m.models(), name) {
- m.formError = fmt.Sprintf("Already in list: %s", name)
- return m, nil
+ for _, existing := range m.models() {
+ if existing == name {
+ m.formError = fmt.Sprintf("Already in list: %s", name)
+ return m, nil
+ }
}
m.formError = ""
- persisted, err := m.persistCustomModelName(name)
- if err != nil {
+ if err := m.addCustomModelToSession(name); 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("")
@@ -744,122 +607,37 @@ func (m providerTUIModel) updateCustomModelInput(key string, msg tea.KeyPressMsg
}
}
-// 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")
- }
+// 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 {
if m.existingCfg == nil {
- return false, nil
+ return nil
}
- 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)
+ cp, ok := m.selectedCustomProvider()
+ if !ok {
+ 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
+ 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)
}
-}
-
-// 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.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)
+ }
}
- 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("")
+ m.savedInSession = true
+ return nil
}
// refreshModelSelectionForCustom moves the cursor to "Enter custom model name..."
@@ -873,34 +651,6 @@ 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":
@@ -909,11 +659,6 @@ 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":
@@ -921,7 +666,12 @@ func (m providerTUIModel) updateAPIKeyInput(key string, msg tea.KeyPressMsg) (te
return m, tea.Quit
default:
if m.apiKeyMasked {
- m.beginAPIKeyReplace()
+ if len(key) == 1 {
+ m.apiKeyMasked = false
+ m.apiKeyInput.SetValue("")
+ } else {
+ return m, nil
+ }
}
var cmd tea.Cmd
m.apiKeyInput, cmd = m.apiKeyInput.Update(msg)
@@ -977,7 +727,12 @@ func (m providerTUIModel) updateCustomProviderForm(key string, msg tea.KeyPressM
}
if m.cpStep == cpStepAPIKey {
if m.apiKeyMasked {
- m.beginAPIKeyReplace()
+ if len(key) == 1 {
+ m.apiKeyMasked = false
+ m.apiKeyInput.SetValue("")
+ } else {
+ return m, nil
+ }
}
var cmd tea.Cmd
m.apiKeyInput, cmd = m.apiKeyInput.Update(msg)
@@ -1005,7 +760,7 @@ func (m *providerTUIModel) enterEditCustomProvider() {
if entry.APIKey != "" {
m.apiKeyOriginal = entry.APIKey
m.apiKeyMasked = true
- m.apiKeyInput.SetValue(maskedSecretPlaceholder())
+ m.apiKeyInput.SetValue(strings.Repeat("*", 20))
} else {
m.apiKeyInput.SetValue("")
m.apiKeyMasked = false
@@ -1020,8 +775,6 @@ 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:
@@ -1074,14 +827,11 @@ 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(r.provider, m.customProviderEntry(r.provider, ProviderEntry{}).Model)
+ m.prepareModelSelection(m.customProviderEntry(r.provider, ProviderEntry{}).Model)
return m, nil
}
if m.creatingCustom {
@@ -1122,7 +872,9 @@ func (m providerTUIModel) applyCreateCustomProvider() (tea.Model, tea.Cmd) {
URL: r.url,
Protocol: r.protocol,
AuthHeader: r.authHeader,
- APIKey: strings.TrimSpace(m.apiKeyInput.Value()),
+ }
+ if r.apiKey != "" {
+ entry.APIKey = r.apiKey
}
m.existingCfg.CustomProviders[r.provider] = entry
@@ -1148,7 +900,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(r.provider, "")
+ m.prepareModelSelection("")
return m, nil
}
@@ -1225,8 +977,8 @@ func (m *providerTUIModel) applyEditCustomProviderSave() error {
entry.URL = r.url
entry.Protocol = r.protocol
entry.AuthHeader = r.authHeader
- if key, edited := m.customAPIKeyForSave(); edited {
- entry.APIKey = key
+ if r.apiKey != "" {
+ entry.APIKey = r.apiKey
}
// If name changed, delete old key
if r.editTargetName != "" && r.editTargetName != r.provider {
@@ -1307,9 +1059,7 @@ func (m providerTUIModel) passThroughCPInput(msg tea.Msg) (tea.Model, tea.Cmd) {
case cpStepBaseURL:
m.cpURLInput, cmd = m.cpURLInput.Update(msg)
case cpStepAPIKey:
- if m.apiKeyMasked && isUserEditMsg(msg) {
- m.beginAPIKeyReplace()
- }
+ // masked unlock is handled in updateCustomProviderForm default branch
m.apiKeyInput, cmd = m.apiKeyInput.Update(msg)
case cpStepAuthHeader:
m.cpAuthInput, cmd = m.cpAuthInput.Update(msg)
@@ -1336,7 +1086,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(maskedSecretPlaceholder())
+ m.manualTokenInput.SetValue(strings.Repeat("*", 20))
} else {
m.manualTokenInput.SetValue("")
m.manualTokenMasked = false
@@ -1375,7 +1125,12 @@ func (m providerTUIModel) updateManualForm(key string, msg tea.KeyPressMsg) (tea
}
}
if m.manualStep == manualStepAuthToken && m.manualTokenMasked {
- m.beginManualTokenReplace()
+ if len(key) == 1 {
+ m.manualTokenMasked = false
+ m.manualTokenInput.SetValue("")
+ } else {
+ return m, nil
+ }
}
return m.passThroughManualInput(msg)
}
@@ -1432,14 +1187,50 @@ 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":
- switch m.activeTab {
- case tabCustom:
- return m.confirmDeleteCustomModel()
- case tabOfficial:
- return m.confirmDeleteOfficialModel()
- default:
+ 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]
+ 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
@@ -1451,118 +1242,6 @@ 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:
@@ -1583,11 +1262,9 @@ func (m providerTUIModel) handleManualFormEnter() (tea.Model, tea.Cmd) {
m.manualStep = manualStepAuthToken
return m, m.manualTokenInput.Focus()
case manualStepAuthToken:
- if strings.TrimSpace(m.manualTokenInput.Value()) == "" && m.manualTokenOriginal == "" {
- m.formError = manualAuthTokenRequiredError
+ if m.manualTokenInput.Value() == "" && m.manualTokenOriginal == "" {
return m, nil
}
- m.formError = ""
m.manualTokenInput.Blur()
m.manualStep = manualStepAuthHeader
return m, m.manualAuthHeaderInput.Focus()
@@ -1645,9 +1322,6 @@ 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)
@@ -1670,7 +1344,7 @@ func (m providerTUIModel) handleEnter() (tea.Model, tea.Cmd) {
currentModel = activeModelForProvider(m.existingCfg, m.currentProvider().Name, entry)
}
}
- m.prepareModelSelection(m.currentProvider().Name, currentModel)
+ m.prepareModelSelection(currentModel)
return m, nil
case tabCustom:
@@ -1690,7 +1364,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(cp.name, activeModelForProvider(m.existingCfg, cp.name, entry))
+ m.prepareModelSelection(activeModelForProvider(m.existingCfg, cp.name, entry))
return m, nil
case tabManual:
@@ -1778,7 +1452,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(maskedSecretPlaceholder())
+ m.apiKeyInput.SetValue(strings.Repeat("*", 20))
}
return
}
@@ -1789,7 +1463,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(maskedSecretPlaceholder())
+ m.apiKeyInput.SetValue(strings.Repeat("*", 20))
}
}
@@ -1809,28 +1483,24 @@ 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 = strings.TrimSpace(m.apiKeyInput.Value())
+ apiKey = m.apiKeyInput.Value()
}
return providerTUIResult{
- provider: p.Name,
- model: model,
- apiKey: apiKey,
- sessionModelPick: m.sessionModelPickSnapshot(),
+ provider: p.Name,
+ model: model,
+ apiKey: apiKey,
}
case tabCustom:
if m.creatingCustom || m.editingCustom {
protocol := cpProtocols[m.cpProtocolIdx]
- apiKey := strings.TrimSpace(m.apiKeyInput.Value())
+ apiKey := m.apiKeyInput.Value()
if m.apiKeyMasked {
apiKey = m.apiKeyOriginal
}
@@ -1858,9 +1528,6 @@ 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
}
@@ -1868,18 +1535,17 @@ func (m providerTUIModel) result() providerTUIResult {
if m.apiKeyMasked {
apiKey = m.apiKeyOriginal
} else {
- apiKey = strings.TrimSpace(m.apiKeyInput.Value())
+ apiKey = 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,
- sessionModelPick: m.sessionModelPickSnapshot(),
+ 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,
}
}
return providerTUIResult{}
@@ -1910,16 +1576,6 @@ 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)
@@ -1927,13 +1583,6 @@ 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 {
@@ -2008,9 +1657,6 @@ 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")
}
}
@@ -2027,11 +1673,8 @@ 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(listCursorPrefixForModel(isCursor, highlight))
- s.WriteString(renderModelName(cp.name, isCursor, highlight))
+ s.WriteString(listCursorPrefix(isCursor) + renderListName(cp.name, isCursor))
if activeModel != "" {
s.WriteString(" " + tuiDimStyle.Render("("+activeModel+")"))
}
@@ -2107,9 +1750,6 @@ 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")
}
@@ -2187,9 +1827,6 @@ 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")
}
@@ -2221,16 +1858,7 @@ func (m providerTUIModel) viewModel(s *strings.Builder) {
for i, model := range models {
isCursor := i == m.modelIdx
- 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(listCursorPrefix(isCursor) + renderListName(model, isCursor))
s.WriteString("\n")
}
@@ -2260,7 +1888,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.cursorOnDeletableModel() {
+ } else if m.activeTab == tabCustom && m.customIdx < len(m.customProviders) {
s.WriteString(tuiHelpStyle.Render(" ↑/↓ Select Enter Confirm d Delete Esc Back"))
} else {
s.WriteString(tuiHelpStyle.Render(" ↑/↓ Select Enter Confirm Esc Back"))
@@ -2282,22 +1910,11 @@ 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")
- hasSavedKey := m.apiKeyMasked && m.apiKeyOriginal != ""
- s.WriteString(tuiDimStyle.Render(officialAPIKeyEnvSetHintLine(provider.EnvVar, hasSavedKey)))
+ s.WriteString(tuiDimStyle.Render(fmt.Sprintf(" $%s is set", provider.EnvVar)))
s.WriteString("\n")
} else {
s.WriteString("\n")
@@ -2306,70 +1923,11 @@ 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 = "▸"
@@ -2386,13 +1944,6 @@ 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().
@@ -2414,16 +1965,6 @@ 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
@@ -2435,231 +1976,52 @@ 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: 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,
+ provider: provider,
+ models: provider.Models,
+ width: 80,
+ height: 24,
+ modelInput: mi,
+ activeModel: currentModel,
}
- if cfg.IsCustom {
- m.models = append([]string(nil), cfg.Provider.Models...)
- }
-
- if cfg.CurrentModel != "" {
+ if currentModel != "" {
found := false
- models := m.displayModels()
- for i, model := range models {
- if model == cfg.CurrentModel {
+ for i, model := range m.models {
+ if model == currentModel {
m.modelIdx = i
found = true
break
}
}
if !found {
- m.modelIdx = len(models)
- m.modelInput.SetValue(cfg.CurrentModel)
+ m.modelIdx = len(m.models)
+ m.modelInput.SetValue(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.displayModels())
+ return idx == len(m.models)
}
func (m modelTUIModel) itemCount() int {
- return len(m.displayModels()) + 1
+ return len(m.models) + 1
}
func (m modelTUIModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -2672,10 +2034,6 @@ 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":
@@ -2684,28 +2042,14 @@ func (m modelTUIModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.modelInput.SetValue("")
return m, nil
case "enter":
- name := strings.TrimSpace(m.modelInput.Value())
- if name == "" {
- return m, nil
+ if m.modelInput.Value() != "" {
+ m.confirmed = true
+ return m, tea.Quit
}
- 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
}
}
@@ -2735,13 +2079,6 @@ 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:
@@ -2754,116 +2091,12 @@ 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()
}
- models := m.displayModels()
- if m.modelIdx < len(models) {
- return models[m.modelIdx]
+ if m.modelIdx < len(m.models) {
+ return m.models[m.modelIdx]
}
return ""
}
@@ -2874,22 +2107,13 @@ func (m modelTUIModel) View() tea.View {
s.WriteString(tuiTitleStyle.Render(fmt.Sprintf(" Select a model (%s)", m.provider.DisplayName)))
s.WriteString("\n\n")
- models := m.displayModels()
- for i, model := range models {
+ for i, model := range m.models {
isCursor := i == m.modelIdx
- 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(listCursorPrefix(isCursor) + renderListName(model, isCursor))
s.WriteString("\n")
}
- customIdx := len(models)
+ customIdx := len(m.models)
isCursor := m.modelIdx == customIdx
customLabel := "Enter custom model name..."
if isCursor {
@@ -2905,23 +2129,8 @@ 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")
-
- 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(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 932b1b0..a5f0001 100644
--- a/cmd/opencodereview/provider_tui_funcs_test.go
+++ b/cmd/opencodereview/provider_tui_funcs_test.go
@@ -1,8 +1,6 @@
package main
import (
- "os"
- "path/filepath"
"strings"
"testing"
@@ -46,73 +44,6 @@ 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()
@@ -616,12 +547,6 @@ 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)
}
@@ -936,447 +861,6 @@ 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) {
@@ -1593,269 +1077,6 @@ 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) {
@@ -1925,23 +1146,15 @@ 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", "m2"}},
+ "cp": {URL: "http://cp", Models: []string{"m1"}},
},
}
m := newProviderTUI(cfg, "")
m.step = stepModel
m.activeTab = tabCustom
m.customIdx = 0
-
- 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)
+ v := m.View()
+ if !strings.Contains(v.Content, "Delete") {
+ t.Errorf("expected delete help for custom tab model view")
}
}
diff --git a/cmd/opencodereview/provider_tui_test.go b/cmd/opencodereview/provider_tui_test.go
index 624bc50..42e9d41 100644
--- a/cmd/opencodereview/provider_tui_test.go
+++ b/cmd/opencodereview/provider_tui_test.go
@@ -8,8 +8,6 @@ import (
"testing"
tea "charm.land/bubbletea/v2"
-
- "github.com/open-code-review/open-code-review/internal/llm"
)
func escKey() tea.KeyPressMsg {
@@ -445,75 +443,10 @@ func TestProviderTUI_ManualFormRequiresTokenOnFirstSetup(t *testing.T) {
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 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)
+ if m2.manualStep != manualStepAuthToken {
+ t.Errorf("should stay on auth token step, got %d", m2.manualStep)
}
}
@@ -748,180 +681,6 @@ 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")
@@ -1353,478 +1112,6 @@ 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")
@@ -1880,692 +1167,6 @@ 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 8379156..d76951b 100644
--- a/cmd/opencodereview/review_cmd.go
+++ b/cmd/opencodereview/review_cmd.go
@@ -11,11 +11,8 @@ 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 {
@@ -47,26 +44,10 @@ 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
@@ -100,7 +81,6 @@ 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,
@@ -115,7 +95,6 @@ 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
@@ -125,75 +104,34 @@ 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 {
- 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)
- }
+ telemetry.SetAttr(span, "error", err.Error())
return fmt.Errorf("review failed: %w", err)
}
return emitRunResult(ctx, ag, comments, startTime, opts.outputFormat, opts.audience, q)
}
-func loadReviewResumeState(repoDir string, opts reviewOptions) (*session.ResumeState, error) {
- if opts.resume == "" {
- return nil, nil
- }
- 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 nil, fmt.Errorf("load resume session: %w (run 'ocr session list' to see available sessions)", err)
- }
- if err := state.ValidateOptions(current); err != nil {
- return nil, fmt.Errorf("%w (run 'ocr session list' to see available sessions)", err)
- }
- 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
+ if input == "" {
+ var err error
+ input, err = os.Getwd()
+ if err != nil {
+ return "", fmt.Errorf("get working directory: %w", err)
+ }
+ }
+ absPath, err := filepath.Abs(input)
+ if err != nil {
+ return "", fmt.Errorf("resolve absolute path: %w", err)
+ }
+ out, err := runGitCmd(absPath, "rev-parse", "--git-dir")
+ if err != nil || len(out) == 0 {
+ return "", fmt.Errorf("%s is not a git repository", absPath)
+ }
+ return absPath, nil
}
// requireGitRepo validates that the given directory is part of a git repository.
diff --git a/cmd/opencodereview/scan_cmd.go b/cmd/opencodereview/scan_cmd.go
index 46353ab..3757e98 100644
--- a/cmd/opencodereview/scan_cmd.go
+++ b/cmd/opencodereview/scan_cmd.go
@@ -3,7 +3,6 @@ package main
import (
"context"
"fmt"
- "os"
"strings"
"time"
@@ -12,8 +11,6 @@ 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
@@ -210,22 +207,11 @@ 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 {
- span.SetStatus(codes.Error, err.Error())
- span.RecordError(err)
- if id := ag.SessionID(); id != "" {
- fmt.Fprintf(os.Stderr, "[ocr] Session: %s\n", id)
- }
+ telemetry.SetAttr(span, "error", err.Error())
return fmt.Errorf("scan failed: %w", err)
}
diff --git a/cmd/opencodereview/session_cmd.go b/cmd/opencodereview/session_cmd.go
deleted file mode 100644
index 805b8a5..0000000
--- a/cmd/opencodereview/session_cmd.go
+++ /dev/null
@@ -1,299 +0,0 @@
-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
deleted file mode 100644
index 1c32a71..0000000
--- a/cmd/opencodereview/session_cmd_test.go
+++ /dev/null
@@ -1,170 +0,0 @@
-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 c99c1b2..8d898a1 100644
--- a/cmd/opencodereview/shared.go
+++ b/cmd/opencodereview/shared.go
@@ -5,7 +5,6 @@ import (
"fmt"
"os"
"path/filepath"
- "strings"
"time"
"github.com/open-code-review/open-code-review/internal/agent"
@@ -101,25 +100,6 @@ 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
}
@@ -251,14 +231,6 @@ 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
@@ -284,10 +256,8 @@ func emitRunResult(
telemetry.RecordCommentsGenerated(ctx, int64(len(comments)))
}
- traceID := telemetry.TraceIDFromContext(ctx)
-
if outputFormat == "json" && len(comments) == 0 && ag.FilesReviewed() == 0 {
- return outputJSONNoFiles(traceID)
+ return outputJSONNoFiles()
}
// Agent-text audiences need stdout back before PrintTraceSummary so the
@@ -303,14 +273,10 @@ 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(), traceID, resumeInfo, ag.SessionID())
+ ag.ProjectSummary(), ag.ToolCalls())
}
outputTextWithWarnings(comments, ag.Warnings())
if summary := ag.ProjectSummary(); summary != "" {
diff --git a/cmd/opencodereview/shared_test.go b/cmd/opencodereview/shared_test.go
index 417a65f..8c9b819 100644
--- a/cmd/opencodereview/shared_test.go
+++ b/cmd/opencodereview/shared_test.go
@@ -2,7 +2,6 @@ package main
import (
"os"
- "os/exec"
"path/filepath"
"testing"
@@ -79,18 +78,9 @@ func TestQuietHandle_IdempotentRestore(t *testing.T) {
func TestResolveWorkingDir_CurrentDir(t *testing.T) {
dir := t.TempDir()
- origDir, err := os.Getwd()
- if err != nil {
- t.Fatalf("getwd: %v", err)
- }
- defer func() {
- if err := os.Chdir(origDir); err != nil {
- t.Errorf("restore chdir: %v", err)
- }
- }()
- if err := os.Chdir(dir); err != nil {
- t.Fatalf("chdir: %v", err)
- }
+ origDir, _ := os.Getwd()
+ defer os.Chdir(origDir)
+ os.Chdir(dir)
absPath, isGit, err := resolveWorkingDir("", false)
if err != nil {
@@ -119,89 +109,6 @@ 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 2ffc3ff..8a4caea 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -6,6 +6,5 @@ This directory contains examples for integrating OpenCodeReview (OCR) into vario
- **[github_actions/](./github_actions/)** - GitHub Actions integration example
- **[gitlab_ci/](./gitlab_ci/)** - GitLab CI integration example
-- **[gitflic_ci/](./gitflic_ci/)** - GitFlic CI integration example
-Each subdirectory contains its own README with detailed setup instructions.
+Each subdirectory contains its own README with detailed setup instructions.
\ No newline at end of file
diff --git a/examples/gitflic_ci/README.md b/examples/gitflic_ci/README.md
deleted file mode 100644
index 9f36ce7..0000000
--- a/examples/gitflic_ci/README.md
+++ /dev/null
@@ -1,80 +0,0 @@
-# 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
deleted file mode 100644
index 2a2b9bd..0000000
--- a/examples/gitflic_ci/gitflic-ci.yaml
+++ /dev/null
@@ -1,84 +0,0 @@
-# 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
deleted file mode 100644
index e8b0555..0000000
--- a/examples/gitflic_ci/post_review.py
+++ /dev/null
@@ -1,480 +0,0 @@
-#!/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
deleted file mode 100644
index be43828..0000000
--- a/examples/gitflic_ci/post_review_test.py
+++ /dev/null
@@ -1,240 +0,0 @@
-#!/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 0236505..dccf654 100644
--- a/examples/github_actions/README.md
+++ b/examples/github_actions/README.md
@@ -1,40 +1,6 @@
-# OpenCodeReview - GitHub Actions Workflow
+# OpenCodeReview - GitHub Actions Demo
-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.
+This demo shows how to integrate OpenCodeReview into your GitHub Actions workflow to automatically review Pull Requests and post review comments.
## How It Works
@@ -44,39 +10,40 @@ 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. 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).
+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
## Setup
-### Configure secrets and variables
+### 1. Copy the workflow file
-Go to your repository's **Settings → Secrets and variables → Actions**.
+Copy `ocr-review.yml` to your repository's `.github/workflows/` directory:
-**Secrets:**
+```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:
| 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 (mapped to env `OCR_LLM_TOKEN` internally) |
+| `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 |
-**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.
+> **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.
## 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:
@@ -89,152 +56,76 @@ on:
### Customize comment trigger keywords
-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:
+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:
```yaml
if: |
- 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')
- )
- )
+ 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'))
```
-Each clause guards against a different abuse vector:
+Or use a more flexible pattern with `contains` to trigger on any comment containing the keyword:
-- `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.
+```yaml
+if: |
+ github.event_name == 'pull_request_target' ||
+ (github.event_name == 'issue_comment' && github.event.issue.pull_request && contains(github.event.comment.body, '/review'))
+```
-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.
+> **Note:** The condition `github.event.issue.pull_request` ensures the comment is on a PR, not a regular issue.
### Use a specific OCR version
```yaml
-- uses: alibaba/open-code-review@main
- with:
- ocr_version: 1.0.0
+- name: Install OpenCodeReview
+ run: npm install -g @alibaba-group/open-code-review@1.0.0
```
### Add custom review rules
-```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'`. |
+Use the `--rule` flag to pass a custom rules JSON file:
```yaml
-- uses: alibaba/open-code-review@main
- with:
- sticky_summary: 'true'
- incremental: 'true'
- incremental_overlap_threshold: '0.75'
+- name: Run OCR review
+ run: ocr review --rule ./my-rules.json --from origin/${{ github.base_ref }} --to origin/${{ github.head_ref }}
```
-> `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 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):
+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):
| Variable | Default | Description |
|----------|---------|-------------|
-| `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_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_MAX_RETRIES` | `3` | Maximum retry attempts per comment when rate-limited |
-| `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_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_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 |
-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`:
+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:
```yaml
-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
+- name: Run OCR review
+ run: ocr review --concurrency 5 --from origin/${{ github.base_ref }} --to origin/${{ github.head_ref }}
```
### Provide background context
+Use the `--background` flag to pass additional context that helps OCR better understand the purpose of the changes:
+
```yaml
-- uses: alibaba/open-code-review@main
- with:
- background: ${{ github.event.pull_request.title }}
+- name: Run OCR review
+ run: ocr review --background "${{ github.event.pull_request.title }}" --from origin/${{ github.base_ref }} --to origin/${{ github.head_ref }}
```
-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 }}
-> ```
+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.
### Customize the review comment author with GitHub App
@@ -277,53 +168,40 @@ 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` | (Optional) The Installation ID from Step 3 — only needed for apps with multiple installations |
+| `GITHUB_APP_INSTALLATION_ID` | The Installation ID from Step 3 |
-#### Step 5: Pass the App token to the action
+#### Step 5: Update the Workflow
-Mint a token with `actions/create-github-app-token` and pass it via the `github_token` input:
+Add a step to obtain a token from the GitHub App, then use it in the "Post review comments to PR" step:
```yaml
- name: Get GitHub App Token
id: app-token
- uses: actions/create-github-app-token@main
+ uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.GITHUB_APP_ID }}
private-key: ${{ secrets.GITHUB_APP_PRIVATE_KEY }}
-- uses: alibaba/open-code-review@main
+- name: Post review comments to PR
+ uses: actions/github-script@v7
with:
- 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 }}
+ github-token: ${{ steps.app-token.outputs.token }}
+ script: |
+ # ... existing script
```
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
-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).
+When a PR is reviewed, comments appear directly in the PR's "Files changed" tab:
-### Summary comment
+- ✅ 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
-A single comment — updated in place on each run when `sticky_summary` is `'true'` (the default) — carries the review outcome and posting statistics.
+### Inline Comment Example
-- ✅ 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:
+The workflow uses GitHub's `suggestion` code block syntax, so reviewers can apply fixes with one click:
````markdown
**Suggestion:**
@@ -332,8 +210,6 @@ Comments with valid line info are posted as PR review comments in "Files changed
```
````
-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:
@@ -342,25 +218,22 @@ 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 variable `OCR_LLM_USE_ANTHROPIC=true`, i.e. `llm_use_anthropic: true`):
+- **Anthropic APIs** (set `OCR_LLM_USE_ANTHROPIC: true`):
- Anthropic Claude models
## Troubleshooting
### Common Issues
-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.
+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
### Debugging
-The action does not use an `OCR_DEBUG` flag. To diagnose a run:
+Enable debug logging by adding to the OCR review step:
-- **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'`.
+```yaml
+env:
+ OCR_DEBUG: "1"
+```
diff --git a/examples/github_actions/ocr-review.yml b/examples/github_actions/ocr-review.yml
index 11a57a3..360eb2e 100644
--- a/examples/github_actions/ocr-review.yml
+++ b/examples/github_actions/ocr-review.yml
@@ -1,64 +1,59 @@
# OpenCodeReview - GitHub Actions PR Auto-Review Demo
#
-# 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'.
+# This workflow automatically reviews pull requests using OpenCodeReview
+# and posts review comments directly on the PR.
#
-# 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
+# Triggers:
+# - PR opened (uses pull_request_target for fork secret access)
+# - Comment on PR containing '/open-code-review' or '@open-code-review'
#
-# For the full list of action inputs/outputs and the four comment-posting modes
-# (sticky / incremental), see action.yml at the repo root.
+# 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.
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.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)
- }}
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
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 the reusable
- # action only reads the diff and does not execute any code from the PR.
+ # available even for PRs from forks. This is safe because OCR only reads
+ # the diff and does not execute any code from the PR.
pull_request_target:
- types: [opened, synchronize, reopened]
+ types: [opened]
issue_comment:
types: [created]
@@ -69,38 +64,19 @@ permissions:
jobs:
code-review:
runs-on: ubuntu-latest
- 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.
+ # Run on PR events, or on comments starting with trigger keywords
if: |
- 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')
- )
- )
+ 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'))
steps:
- name: Get PR context
id: pr-context
- if: github.event_name == 'issue_comment'
+ if: github.event_name != 'pull_request_target'
uses: actions/github-script@v7
with:
script: |
- // 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).
+ // For issue_comment events, get PR info
const prNumber = context.issue.number;
const { data: pullRequest } = await github.rest.pulls.get({
owner: context.repo.owner,
@@ -108,16 +84,469 @@ 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: Run OpenCodeReview
- uses: alibaba/open-code-review@main
+ - name: Checkout repository
+ uses: actions/checkout@v4
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 }}
- # 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 }}
+ 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
+ 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));
+ }
diff --git a/extensions/vscode/.gitignore b/extensions/vscode/.gitignore
index e7377a7..ae7fae2 100644
--- a/extensions/vscode/.gitignore
+++ b/extensions/vscode/.gitignore
@@ -2,5 +2,4 @@
.superpowers/
node_modules/
reference
-out/
-*.vsix
\ No newline at end of file
+out/
\ No newline at end of file
diff --git a/extensions/vscode/.vscodeignore b/extensions/vscode/.vscodeignore
index d64358b..213ba03 100644
--- a/extensions/vscode/.vscodeignore
+++ b/extensions/vscode/.vscodeignore
@@ -13,4 +13,3 @@ jest.config.js
.superpowers/**
prototype.html
resources/icon-old.svg
-__mocks__/**
diff --git a/extensions/vscode/LICENSE b/extensions/vscode/LICENSE
deleted file mode 100644
index a4f1fec..0000000
--- a/extensions/vscode/LICENSE
+++ /dev/null
@@ -1,201 +0,0 @@
- 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
new file mode 100644
index 0000000..428b077
Binary files /dev/null and b/extensions/vscode/open-code-review-vscode-0.1.0.vsix differ
diff --git a/extensions/vscode/package.json b/extensions/vscode/package.json
index 53d9678..7644112 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.1",
+ "version": "0.1.0",
"publisher": "open-code-review",
"license": "Apache-2.0",
"repository": {
"type": "git",
- "url": "git+https://github.com/alibaba/open-code-review.git",
+ "url": "git+https://github.com/nigulasikk/open-code-review.git",
"directory": "extensions/vscode"
},
"engines": {
@@ -16,7 +16,6 @@
"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
new file mode 100644
index 0000000..3ce9a0e
--- /dev/null
+++ b/extensions/vscode/resources/icon-old.svg
@@ -0,0 +1 @@
+
diff --git a/extensions/vscode/resources/icon.png b/extensions/vscode/resources/icon.png
deleted file mode 100644
index 0c2add0..0000000
Binary files a/extensions/vscode/resources/icon.png and /dev/null differ
diff --git a/extensions/vscode/resources/icon.svg b/extensions/vscode/resources/icon.svg
index c4a054b..7a0e418 100644
--- a/extensions/vscode/resources/icon.svg
+++ b/extensions/vscode/resources/icon.svg
@@ -1 +1,21 @@
-
\ No newline at end of file
+
diff --git a/extensions/vscode/src/extension/extension.ts b/extensions/vscode/src/extension/extension.ts
index 5bc0c56..c398e6b 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, git);
+ const comments = new CommentProvider(extensionUri);
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 84742f7..02d1dba 100644
--- a/extensions/vscode/src/extension/providers/CommentProvider.ts
+++ b/extensions/vscode/src/extension/providers/CommentProvider.ts
@@ -1,34 +1,22 @@
import { t, resolveLocale, SupportedLocale } from '../../shared/i18n';
import * as vscode from 'vscode';
-import { ReviewComment, CommentStatus, CommentSyncState, ReviewContext, ReviewMode } from '../../shared/types';
+import { ReviewComment, CommentStatus, CommentSyncState } 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,
- private git: GitService,
- ) {
+ constructor(private extensionUri: vscode.Uri) {
this.locale = resolveLocale(vscode.env.language);
this.controller = vscode.comments.createCommentController(COMMENT_CONTROLLER_ID, t(this.locale, 'ext.commentController'));
}
@@ -39,92 +27,62 @@ export class CommentProvider {
private emitSync(): void {
const states: CommentSyncState[] = this.comments.map((_, i) => ({
- index: i,
- status: this.status.get(i) ?? 'pending',
- jumpable: this.jumpable.has(i),
+ index: i, status: this.status.get(i) ?? 'pending',
}));
this.syncListeners.forEach((fn) => fn(states));
}
- /** 展示审查评论:能解析到 git/工作区快照的挂 thread,否则仅侧边栏。 */
- async show(comments: ReviewComment[], ctx: ReviewContext): Promise {
+ /**
+ * 展示审查评论。
+ * @param inEditor 是否在编辑器内创建 CommentThread。仅工作区模式为 true;
+ * 分支对比/单次提交模式下被审查代码不在当前工作区,行号会错位,故只在侧边栏展示。
+ */
+ async show(comments: ReviewComment[], inEditor = true): Promise {
this.clear();
- this.reviewContext = ctx;
+ // 不重排:保持与 webview(result.comments)相同的顺序与下标
this.comments = comments;
-
const root = vscode.workspace.workspaceFolders?.[0].uri.fsPath;
if (!root) return;
- await this.git.prepareReviewFileStatus(ctx);
- const deps = this.buildAnchorDeps(root, ctx);
+ // 非工作区模式:只登记评论与状态供侧边栏同步,不在编辑器内放置 thread。
+ if (!inEditor) {
+ for (let i = 0; i < this.comments.length; i++) this.status.set(i, 'pending');
+ this.emitSync();
+ return;
+ }
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 {
- 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,
+ 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,
author: { name: t(this.locale, 'ext.comment.pending') },
}]);
thread.canReply = false;
thread.label = `${t(this.locale, 'ext.comment.threadLabel')} (${i + 1} / ${this.comments.length})`;
- thread.contextValue = this.threadContextValue(c, ctx);
+ // 有代码建议 → 'pending'(显示应用+忽略);无建议 → 'pendingNoSuggestion'(仅忽略)
+ thread.contextValue = this.hasSuggestion(c) ? 'pending' : 'pendingNoSuggestion';
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 {
- this.jumpBlockReasons.set(i, 'mount-failed');
- }
+ } catch { /* 文件打不开(如目录)则无 thread,但保留下标 */ }
}
-
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,
- locateNote?: string,
- ): vscode.MarkdownString {
- let md = locateNote ? `${locateNote}\n\n${c.content}` : c.content;
+ private renderBody(c: ReviewComment, _index: number, _status: CommentStatus): vscode.MarkdownString {
+ let md = c.content;
if (this.hasSuggestion(c)) {
md += `\n***\n\`\`\`diff\n${c.suggestionCode}\n\`\`\``;
} else {
@@ -136,10 +94,6 @@ 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;
@@ -156,6 +110,8 @@ 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);
@@ -176,7 +132,6 @@ 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'),
@@ -184,11 +139,7 @@ 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, mount?.locateNote),
- }] as any;
+ thread.comments = [{ ...thread.comments[0], author: { name: label }, body: this.renderBody(this.comments[index], index, status) }] as any;
thread.contextValue = status;
thread.collapsibleState = vscode.CommentThreadCollapsibleState.Collapsed;
}
@@ -196,128 +147,27 @@ export class CommentProvider {
}
async jumpTo(index: number): Promise {
- const mount = this.mounts.get(index);
const thread = this.threads.get(index);
- if (!mount || !thread) {
- this.showJumpFailed(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')}`);
return;
}
-
- 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 });
- }
+ await vscode.window.showTextDocument(thread.uri, { selection: thread.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, th] of this.threads) if (th === thread) return i;
+ for (const [i, t] of this.threads) if (t === thread) return i;
return -1;
}
clear(): void {
- this.threads.forEach((th) => th.dispose());
+ this.threads.forEach((t) => t.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 f3beebd..f63ff58 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, ReviewMode } from '../../shared/types';
+import { FileChange } from '../../shared/types';
import { CliService } from '../services/CliService';
import { ConfigService } from '../services/ConfigService';
import { GitService } from '../services/GitService';
@@ -13,7 +13,6 @@ 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,
@@ -38,16 +37,6 @@ 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 {
@@ -59,7 +48,7 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
switch (msg.type) {
case 'ready': {
const config = this.config.read();
- const gitState = await this.git.getState(ReviewMode.Workspace);
+ const gitState = await this.git.getState('workspace');
const locale = resolveLocale(vscode.env.language);
this.post({ type: 'init', config, gitState, locale });
break;
@@ -70,9 +59,9 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
}
case 'getModeFiles': {
let files: FileChange[] = [];
- if (msg.mode === ReviewMode.Branch && msg.from && msg.to) {
+ if (msg.mode === 'branch' && msg.from && msg.to) {
files = await this.git.getBranchDiff(msg.from, msg.to);
- } else if (msg.mode === ReviewMode.Commit && msg.commit) {
+ } else if (msg.mode === 'commit' && msg.commit) {
files = await this.git.getCommitFiles(msg.commit);
}
this.post({ type: 'modeFiles', mode: msg.mode, files });
@@ -86,21 +75,14 @@ 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) => {
- 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 });
- })();
+ this.post({ type: 'reviewDone', result });
+ if (result.comments.length) this.comments.show(result.comments, inEditor);
},
});
break;
diff --git a/extensions/vscode/src/extension/providers/__tests__/commentAnchor.test.ts b/extensions/vscode/src/extension/providers/__tests__/commentAnchor.test.ts
deleted file mode 100644
index 22a14dd..0000000
--- a/extensions/vscode/src/extension/providers/__tests__/commentAnchor.test.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-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
deleted file mode 100644
index b18bc2a..0000000
--- a/extensions/vscode/src/extension/providers/commentAnchor.ts
+++ /dev/null
@@ -1,197 +0,0 @@
-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 8d4eaef..a3b29f0 100644
--- a/extensions/vscode/src/extension/services/GitService.ts
+++ b/extensions/vscode/src/extension/services/GitService.ts
@@ -1,16 +1,11 @@
import { t, resolveLocale } from '../../shared/i18n';
import * as vscode from 'vscode';
-import { readFile } from 'fs/promises';
import { execFile } from 'child_process';
-import { GitState, FileChange, ReviewMode, ReviewContext } from '../../shared/types';
-import { buildWorkspaceFiles, branchRefCandidates, parseNameStatus, pickRepoRoot } from './gitMap';
-
-const WORKSPACE_REFRESH_DEBOUNCE_MS = 300;
+import { GitState, CommitInfo, FileChange, ReviewMode } from '../../shared/types';
+import { parsePorcelain, parseNameStatus, pickRepoRoot } from './gitMap';
export class GitService {
private api: any | null = null;
- private cache: GitState = { branches: [], currentBranch: '', recentCommits: [], workspaceFiles: [] };
- private reviewFileStatus = new Map();
constructor(private log?: vscode.OutputChannel) {}
@@ -66,155 +61,57 @@ 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 {
- this.cache.currentBranch = repo.state.HEAD?.name || '';
+ currentBranch = repo.state.HEAD?.name || '';
} catch { /* ignore */ }
- 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 {
+ let branches: string[] = [];
try {
const refs = await repo.getBranches({ remote: true });
- this.cache.branches = refs.map((r: any) => r.name).filter(Boolean);
- } catch {
- this.cache.branches = [];
- }
- }
+ branches = refs.map((r: any) => r.name).filter(Boolean);
+ } catch { /* ignore */ }
- private async refreshRecentCommits(repo: any): Promise {
+ let recentCommits: CommitInfo[] = [];
try {
const commits = await repo.log({ maxEntries: 20 });
- this.cache.recentCommits = commits.map((c: any) => ({
+ recentCommits = commits.map((c: any) => ({
sha: c.hash.slice(0, 7),
message: c.message.split('\n')[0],
relativeTime: formatRelative(c.authorDate),
}));
- } catch {
- this.cache.recentCommits = [];
+ } 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)}`);
}
+
+ 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', `${resolvedFrom}...${resolvedTo}`]);
+ const out = await runGit(root, ['diff', '--name-status', `${from}...${to}`]);
const files = parseNameStatus(out);
- this.trace(`getBranchDiff(${resolvedFrom}...${resolvedTo}): files=${files.length}`);
+ this.trace(`getBranchDiff(${from}...${to}): files=${files.length}`);
return files;
} catch (e) {
this.trace(`getBranchDiff failed: ${e instanceof Error ? e.message : String(e)}`);
@@ -222,22 +119,12 @@ 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;
@@ -249,7 +136,7 @@ export class GitService {
private async repoRoot(): Promise {
const repo = await this.waitForRepo();
- if (!repo) return this.repoRootFast();
+ if (!repo) return null;
return repo.rootUri?.fsPath
?? vscode.workspace.workspaceFolders?.[0].uri.fsPath
?? process.cwd();
@@ -266,125 +153,41 @@ export class GitService {
const fileUri = vscode.Uri.file(`${root}/${opts.path}`);
+ // 二进制无法做文本 diff,直接打开文件本身。
if (opts.status === 'binary') {
- if (opts.mode === ReviewMode.Workspace) {
- try { await vscode.window.showTextDocument(fileUri, { preview: true }); } catch { /* ignore */ }
- }
+ try { await vscode.window.showTextDocument(fileUri, { preview: true }); } catch { /* ignore */ }
return;
}
- 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})`);
+ // 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 {
return;
}
- 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 {
+ const title = `${opts.path} (${label})`;
try {
await vscode.commands.executeCommand('vscode.diff', left, right, title, { preview: true });
- return true;
} catch (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;
+ this.trace(`openDiff failed: ${e instanceof Error ? e.message : String(e)}`);
}
}
@@ -396,102 +199,11 @@ 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', ['-c', 'core.quotepath=false', ...args], { cwd, maxBuffer: 10 * 1024 * 1024 }, (err, stdout) => {
+ execFile('git', 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 ca8fea1..25145a7 100644
--- a/extensions/vscode/src/extension/services/__tests__/cliParse.test.ts
+++ b/extensions/vscode/src/extension/services/__tests__/cliParse.test.ts
@@ -1,29 +1,28 @@
import { buildReviewArgs, extractCliError, parseCliResult, parseLogLine } from '../cliParse';
-import { ReviewMode } from '../../../shared/types';
describe('buildReviewArgs', () => {
it('workspace 模式加 --format json', () => {
- expect(buildReviewArgs({ mode: ReviewMode.Workspace }))
+ expect(buildReviewArgs({ mode: 'workspace' }))
.toEqual(['review', '--format', 'json']);
});
it('branch 模式加 --from/--to', () => {
- expect(buildReviewArgs({ mode: ReviewMode.Branch, from: 'main', to: 'dev' }))
+ expect(buildReviewArgs({ mode: 'branch', from: 'main', to: 'dev' }))
.toEqual(['review', '--from', 'main', '--to', 'dev', '--format', 'json']);
});
it('commit 模式加 --commit', () => {
- expect(buildReviewArgs({ mode: ReviewMode.Commit, commit: 'abc123' }))
+ expect(buildReviewArgs({ mode: 'commit', commit: 'abc123' }))
.toEqual(['review', '--commit', 'abc123', '--format', 'json']);
});
it('customPrompt 追加 --background', () => {
- expect(buildReviewArgs({ mode: ReviewMode.Workspace, customPrompt: '关注安全' }))
+ expect(buildReviewArgs({ mode: 'workspace', customPrompt: '关注安全' }))
.toEqual(['review', '--format', 'json', '--background', '关注安全']);
});
it('concurrency 追加 --concurrency', () => {
- expect(buildReviewArgs({ mode: ReviewMode.Workspace, concurrency: 4 }))
+ expect(buildReviewArgs({ mode: '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 b8eb991..3cf15a1 100644
--- a/extensions/vscode/src/extension/services/__tests__/gitMap.test.ts
+++ b/extensions/vscode/src/extension/services/__tests__/gitMap.test.ts
@@ -1,18 +1,5 @@
// src/extension/services/__tests__/gitMap.test.ts
-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';
+import { mapStatusCode, parsePorcelain, parseNameStatus, pickRepoRoot } from '../gitMap';
describe('mapStatusCode', () => {
it('VSCode git Status 枚举映射到 FileChange.status', () => {
@@ -94,82 +81,6 @@ 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';
@@ -206,19 +117,3 @@ 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 383faa7..c219bce 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, ReviewMode } from '../../shared/types';
+import { CliResult, CliRunOptions, LogLine, ReviewComment } from '../../shared/types';
export function buildReviewArgs(opts: CliRunOptions): string[] {
const args: string[] = ['review'];
- if (opts.mode === ReviewMode.Branch) {
+ if (opts.mode === 'branch') {
if (opts.from) args.push('--from', opts.from);
if (opts.to) args.push('--to', opts.to);
- } else if (opts.mode === ReviewMode.Commit) {
+ } else if (opts.mode === '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 68b3e21..0c92b4f 100644
--- a/extensions/vscode/src/extension/services/gitMap.ts
+++ b/extensions/vscode/src/extension/services/gitMap.ts
@@ -36,7 +36,6 @@ 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) });
@@ -44,40 +43,6 @@ 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] 会漂移到子仓库。
@@ -98,50 +63,6 @@ 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)。
@@ -154,7 +75,7 @@ export function parseNameStatus(output: string): FileChange[] {
const parts = rawLine.split('\t');
if (parts.length < 2) continue;
const codeChar = parts[0][0];
- const path = unquoteGitPath(parts.length >= 3 ? parts[parts.length - 1] : parts[1]);
+ const path = 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 6086566..346af78 100644
--- a/extensions/vscode/src/shared/i18n.ts
+++ b/extensions/vscode/src/shared/i18n.ts
@@ -153,9 +153,6 @@ 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',
@@ -298,9 +295,6 @@ 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 684fb7a..ef03f51 100644
--- a/extensions/vscode/src/shared/providers.ts
+++ b/extensions/vscode/src/shared/providers.ts
@@ -95,14 +95,6 @@ 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 8219bbd..b6e54ac 100644
--- a/extensions/vscode/src/shared/types.ts
+++ b/extensions/vscode/src/shared/types.ts
@@ -1,8 +1,4 @@
-export enum ReviewMode {
- Workspace = 'workspace',
- Branch = 'branch',
- Commit = 'commit',
-}
+export type ReviewMode = 'workspace' | 'branch' | 'commit';
export type ReviewState =
| 'idle' | 'running' | 'done' | 'empty' | 'cancelled' | 'failed';
@@ -109,11 +105,7 @@ 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 2291095..caebc28 100644
--- a/extensions/vscode/src/webview/App.tsx
+++ b/extensions/vscode/src/webview/App.tsx
@@ -55,14 +55,14 @@ export function App() {