diff --git a/CHANGELOG.md b/CHANGELOG.md index bd4a555..2e7594a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,73 +7,111 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.0.0] - 2026-09-19 — _AI-Native Standard_ + +### Added +- **MCP server** via `parsec mcp serve`, exposing worktree lifecycle, health, + smartlog, PR, review, CI, and synchronization tools to MCP clients. +- **MCP client installer** via `parsec mcp install` for Claude Desktop and + Cursor, with configurable binary paths and safe config merging. +- **Permission-scoped delegated authentication** from environment variables or + `mcp.toml`, including read/write scope checks and redacted diagnostics. +- **Mutation safety gates** requiring preview/confirmation, repository sandbox + boundaries, privacy-safe audit events, and versioned audit fixtures. +- **MCP end-to-end coverage** for the stdio lifecycle, tool registry, auth, + sandbox boundaries, client configuration, and mutation gates. +- `parsec checkpoint`, `crash-report`, `triage`, self-update notifications, and + `ship --no-cleanup` from the final v0.x development line. + +### Changed +- `parsec smartlog` now includes topological stack ordering, richer PR merge + readiness, CI overlays for branch tips, and hermetic overlay integration + tests. +- `parsec ship` supports both AI-generated PR descriptions and template + variable substitution while retaining explicit cleanup control. + +### Docs +- Added the [MCP quickstart](./docs/mcp-quickstart.md), protocol specification, + authentication and sandbox model, and client configuration guide. + ## [0.5.0] - 2026-06-03 — _The visualization release_ -v0.5 마일스톤 **16/16 완료**. Polish & Power-User UX: 워크트리/PR/CI를 하나의 -시야에 모아주는 시각화·자동화 명령 6개 신규. +v0.5 completes the **16/16** milestone for polish and power-user UX, adding +six visualization and automation commands that bring worktrees, PRs, and CI +into one view. ### Added -- **`parsec smartlog` (alias `sl`)** — 모든 활성 워크트리를 commit DAG로 시각화. - ASCII 트리가 base branch별로 워크트리를 묶고 merge-base 이후 커밋을 표시. - Phase 2 PR/CI status overlay (`#327`), Phase 3 worktree filter + ANSI 색상 + - stack indicator (`#333`). `--json` 출력 지원. (`#245`, `#305`, `#318`, `#319`) -- **`parsec dashboard` (alias `dash`)** — 실시간 터미널 TUI 대시보드. 워크트리 / - CI 상태 / GitHub PR을 3-pane 레이아웃으로 한 화면에. ratatui + crossterm 기반, - 키바인딩 `q` (종료) / `r` (즉시 새로고침) / `?` (도움말), `--refresh N` 인터벌, - `--no-overlay` 오프라인 모드. (`#248`, `#337`) -- **`parsec test`** — 워크트리 병렬 테스트 러너 + tree-hash 결과 캐싱. - `--all`로 모든 활성 워크트리 일괄 실행, `--jobs N` 병렬, `--cache`로 동일 tree - 재실행 시 즉시 스킵. `[test]` 설정 섹션(`command`, `jobs`, `cache`). 인간/JSON - 출력. (`#247`, `#336`) -- **`parsec health`** — 모든 활성 워크트리 헬스 체크. Phase 1: lock(`.git/index.lock`) - · uncommitted 파일 수 · stale(7일 초과) 검사 (`#324`, `#325`). Phase 2: CI 상태 - overlay + configurable stale threshold (`#330`). CLI 통합 테스트 5개 (`#326`). -- **`parsec reviews`** — 워크트리별 받은/요청한 PR 리뷰를 한 표로. Phase 1 (`#301`, +- **`parsec smartlog` (alias `sl`)** — visualizes every active worktree as a + commit DAG. The ASCII tree groups worktrees by base branch and shows commits + since the merge base. Phase 2 adds the PR/CI status overlay (`#327`); Phase 3 + adds worktree filtering, ANSI colors, stack indicators (`#333`), and `--json` + output. (`#245`, `#305`, `#318`, `#319`) +- **`parsec dashboard` (alias `dash`)** — real-time terminal TUI dashboard with + worktrees, CI state, and GitHub PRs in a 3-pane layout. Built on ratatui and + crossterm, with `q` to quit, `r` to refresh immediately, `?` for help, + `--refresh N` for the polling interval, and `--no-overlay` for offline mode. + (`#248`, `#337`) +- **`parsec test`** — parallel worktree test runner with tree-hash result + caching. `--all` runs every active worktree, `--jobs N` controls concurrency, + and `--cache` skips reruns for the same tree. Adds the `[test]` config section + (`command`, `jobs`, `cache`) and both human-readable and JSON output. + (`#247`, `#336`) +- **`parsec health`** — health checks for every active worktree. Phase 1 checks + `.git/index.lock`, uncommitted file counts, and stale worktrees older than + seven days (`#324`, `#325`). Phase 2 adds CI status overlays and a configurable + stale threshold (`#330`). Includes five CLI integration tests (`#326`). +- **`parsec reviews`** — shows received and requested PR reviews per worktree + in one table. Phase 1 (`#301`, `#331`) + Phase 2 `--requested` (GitHub Search API) (`#334`). -- **`parsec conflicts --simulate`** — 기존 filename overlap 휴리스틱을 보완하는 - line-level 충돌 시뮬레이션. `git merge-tree --write-tree`로 워크트리 vs base + - 워크트리 페어 cross-simulate 두 패스. 머지 전 실제 충돌 파일을 read-only로 노출. +- **`parsec conflicts --simulate`** — line-level conflict simulation that + complements the existing filename-overlap heuristic. Uses + `git merge-tree --write-tree` for two read-only passes: worktree vs base and + cross-simulation across worktree pairs. Exposes actual conflict files before + merge. (`#246`, `#335`) -- **`parsec commit`** — AI 커밋 메시지 생성 (OpenAI / Anthropic). staged diff 분석 - 후 자동 prefix + Conventional Commits 포맷(`--conventional`). 수동 메시지 - override(`--message`). (`#274`) -- **`parsec sync`** — auto-sync `main`/`develop` into stale worktrees (rebase 또는 - merge 전략, `--all` 일괄, `--dry-run` behind 카운트, conflict hint). (`#290`) -- **AI-generated PR descriptions** — `parsec ship`이 OpenAI / Anthropic / Ollama - 공급자로 PR 본문 자동 작성. `[ai]` 설정. (`#242`, `#275`) -- **`parsec __complete` shell-completion 헬퍼** — 숨김 subcommand가 워크트리 / branch - 완성 후보를 newline-separated로 출력. zsh / bash / fish 동적 탭 완성 지원 - (`#291`, `#312`). Phase 2 dynamic 쉘 스크립트 (`#328`). -- **`parsec agent` mode (PARSEC_AGENT=1)** — non-interactive JSON 출력 모드, AI - 에이전트 호출용. (`#272`) +- **`parsec commit`** — AI commit message generation via OpenAI or Anthropic. + Analyzes staged diffs, adds automatic prefixes, supports Conventional Commits + with `--conventional`, and allows manual overrides with `--message`. (`#274`) +- **`parsec sync`** — auto-syncs `main`/`develop` into stale worktrees with rebase + or merge strategies, `--all` batch mode, `--dry-run` behind counts, and + conflict hints. (`#290`) +- **AI-generated PR descriptions** — `parsec ship` can generate PR bodies with + OpenAI, Anthropic, or Ollama providers via `[ai]` settings. (`#242`, `#275`) +- **`parsec __complete` shell-completion helper** — hidden subcommand that emits + worktree and branch completion candidates as newline-separated output. Supports + dynamic tab completion for zsh, bash, and fish + (`#291`, `#312`). Phase 2 adds dynamic shell scripts (`#328`). +- **`parsec agent` mode (PARSEC_AGENT=1)** — non-interactive JSON output mode + for AI agent invocations. (`#272`) ### Changed -- **Error messages standardized to 3-line format** — 모든 사용자 대상 에러가 - `error: / caused by: / help: ` 포맷으로 통일 +- **Error messages standardized to 3-line format** — all user-facing errors now + use the `error: / caused by: / help: ` format (`#303`, `#306`). ### Fixed - `parsec ship` falls back to `gh auth token` when `PARSEC_GITHUB_TOKEN` / `GITHUB_TOKEN` / `GH_TOKEN` env vars are absent — parity with `parsec doctor` - and the tracker layer. GitHub host에만 한정해 Bitbucket / GitLab remote는 영향 - 없음 (`#281`). + and the tracker layer. The fallback is limited to GitHub hosts and does not + affect Bitbucket or GitLab remotes (`#281`). ### CI -- Windows VS2026 (Visual Studio 2026 runner) pre-validation 잡 — MSVC toolchain - 회귀 사전 차단 (`#307`, `#311`). -- `parsec test`의 shell invocation을 cross-platform화 (sh -c / cmd /C), - Windows test가 WSL을 호출하지 않도록 수정. +- Windows VS2026 (Visual Studio 2026 runner) pre-validation job to catch MSVC + toolchain regressions early (`#307`, `#311`). +- Made `parsec test` shell invocation cross-platform (`sh -c` / `cmd /C`) so + Windows tests no longer invoke WSL. ### Docs -- 모듈별 RustDoc 보강 — `diff` / `history` (`#321`), `stack` / `ci` (`#323`). -- CHANGELOG `[Unreleased]` 섹션 누락 항목 보완 (smartlog / complete / errors / - win-ci) (`#316`, `#317`). +- Expanded module-level RustDoc for `diff` / `history` (`#321`) and + `stack` / `ci` (`#323`). +- Filled missing CHANGELOG `[Unreleased]` entries for smartlog, completion, + errors, and win-ci (`#316`, `#317`). ### Tests -- CLI 통합 테스트 대폭 추가 — `compress` / `config schema` / `log --export` - (`#314`, `#315`), `smartlog` / `sl` (`#318`, `#319`), `health` (`#324`, `#326`), - `parsec test` (5 신규), `parsec dashboard` (4 신규), `conflicts --simulate` - (4 신규). +- Added broad CLI integration coverage for `compress`, `config schema`, + `log --export` (`#314`, `#315`), `smartlog` / `sl` (`#318`, `#319`), `health` + (`#324`, `#326`), `parsec test` (five new tests), `parsec dashboard` (four new + tests), and `conflicts --simulate` (four new tests). ## [0.4.0] - 2026-05-04 diff --git a/Cargo.toml b/Cargo.toml index 1f07b27..ed52346 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "git-parsec" -version = "0.5.0" +version = "1.0.0" edition = "2021" authors = ["erishforG"] description = "Git worktree lifecycle manager — ticket to PR in one command. Parallel AI agent workflows with Jira & GitHub Issues integration." diff --git a/README.md b/README.md index cb1b38c..08548f1 100644 --- a/README.md +++ b/README.md @@ -37,18 +37,18 @@ That's the whole loop. Plain `git worktree` doesn't track state, doesn't talk to |---|---|---| | **v0.4.0** | ✅ Released (2026-05-04) | Multi-forge + multi-tracker foundation (GitHub / GitLab / Bitbucket; Jira / Linear) | | **v0.5.0** — _The visualization release_ | ✅ Released (2026-06-03) | smartlog · TUI dashboard · speculative merge · `parsec test` · health · reviews · AI PR descriptions | -| **v1.0** — _AI-Native Standard_ | 🚧 Next | MCP server signature — Claude / Cursor / Copilot invoke parsec as a first-class tool | -| **v2.0+** — _Ecosystem Hub_ | 🔮 | Plugins · VS Code extension · Linear-native tracker · org-scale workflows | +| **v1.0.0** — _AI-Native Standard_ | ✅ Released (2026-09-19) | MCP server — Claude Desktop and Cursor invoke parsec as a first-class tool | -v1.0 work is tracked under the [`v1.0` milestone](https://github.com/erishforG/git-parsec/milestone/4); see the [CHANGELOG](./CHANGELOG.md) for the full v0.5.0 release notes. +v1.0 is the final planned feature release. See the [CHANGELOG](./CHANGELOG.md) +for release notes and the [MCP quickstart](./docs/mcp-quickstart.md) for setup. --- ## Install ```bash -# Homebrew / pre-built binary (recommended) -curl -LO https://github.com/erishforG/git-parsec/releases/latest/download/parsec-x86_64-unknown-linux-gnu.tar.gz +# Pre-built Linux x86_64 binary +curl -LO https://github.com/erishforG/git-parsec/releases/latest/download/parsec-1.0.0-x86_64-unknown-linux-gnu.tar.gz tar xzf parsec-*.tar.gz && sudo mv parsec /usr/local/bin/ # Cargo (Rust toolchain required) @@ -131,6 +131,19 @@ parsec auto-posts `← previous PR` / `next PR →` navigation comments so revie ### 🤖 Agent-friendly by design Every command has `--json`. Errors emit structured codes (E001…E013). `parsec log --export` outputs JSONL with execution IDs and per-step timing for tooling/agents to consume. `--offline` and `[behavior].offline` config skip all network ops for air-gapped or CI environments. +### 🔌 MCP server _(new in v1.0)_ +Expose parsec's worktree lifecycle to Claude Desktop or Cursor over stdio: + +```bash +parsec mcp install claude-desktop +parsec mcp serve +``` + +The server provides worktree, health, smartlog, PR, review, CI, and sync tools. +Mutating calls are previewed and confirmation-gated; delegated tokens are +scope-checked and repository paths are sandboxed. See the +[MCP quickstart](./docs/mcp-quickstart.md) and [security model](./docs/mcp/auth.md). + ### 🧹 Lifecycle hygiene `parsec clean` sweeps worktrees for already-merged branches. `parsec conflicts` flags cross-worktree file overlap before you push. `parsec undo` reverses the last operation (start, ship, clean). `parsec doctor` validates every part of your setup with actionable fix instructions. @@ -147,7 +160,6 @@ Every command has `--json`. Errors emit structured codes (E001…E013). `parsec - **`parsec reviews`** — open PR reviews you've received vs. requested, unified across worktrees. - **`parsec conflicts --simulate`** — in-memory three-way merge to surface real *line-level* conflicts before you push (worktree-vs-base + cross-worktree pairs, read-only). - **`parsec test`** — run tests in parallel across worktrees with tree-hash result caching (`--all --jobs N --cache`). -- **`parsec commit`** — AI-generated commit messages from staged diff (OpenAI / Anthropic, `--conventional` for Conventional Commits). - **`parsec sync`** — fast-forward stale worktrees against `origin/` (rebase or merge, `--all`, `--dry-run`). > 33+ commands total — see the [full command reference](https://erishforg.github.io/git-parsec/reference/) for every flag and example. diff --git a/docs/crash-report.md b/docs/crash-report.md new file mode 100644 index 0000000..720d4ea --- /dev/null +++ b/docs/crash-report.md @@ -0,0 +1,68 @@ +# Crash Report Privacy Policy + +`parsec` includes an **opt-in** crash report feature that saves a local JSON +file when an unexpected panic occurs. No data is ever sent automatically. + +## What is collected + +When `[crash_report] enabled = true` and a panic happens, parsec writes a +`crash-.json` file under the platform cache directory: + +| Platform | Directory | +|---|---| +| Linux | `$XDG_CACHE_HOME/parsec/`, or `~/.cache/parsec/` when unset | +| macOS | `~/Library/Caches/parsec/` | +| Windows | `%LOCALAPPDATA%\parsec\` | + +The exact directory is selected by [`dirs::cache_dir()`](https://docs.rs/dirs/latest/dirs/fn.cache_dir.html). +Each report contains: + +| Field | Example | Notes | +|---|---|---| +| `parsec_version` | `"0.5.0"` | Binary version | +| `timestamp` | `"2026-09-10T09:00:00Z"` | UTC ISO-8601 | +| `os` | `"unix/macos"` | Family + OS name | +| `shell` | `"zsh"` | `$SHELL` basename only | +| `subcommand` | `"ship"` | `argv[1]` only, no flags or ticket IDs | +| `panic_location` | `"src/cli/commands/ship.rs:42"` | Source file + line | +| `panic_message` | `"attempt to subtract with overflow"` | Panic payload (≤ 512 chars) | + +### What is NOT collected + +- Your ticket IDs, branch names, commit messages, or file contents +- Your GitHub token, Jira token, or any credentials +- Your full command-line arguments or environment variables +- Network-level information (IP address, hostname) +- A stack trace (use `RUST_BACKTRACE=1` locally for that) + +## Opt-in + +Add to `~/.config/parsec/config.toml`: + +```toml +[crash_report] +enabled = true +``` + +The default is `enabled = false` — **nothing is saved unless you opt in**. + +## How to share a report + +If you experience a crash and want to help: + +1. Run `parsec crash-report list` to find the report ID +2. Review it with `parsec crash-report show ` (it is plain JSON) +3. Open a GitHub issue: +4. Paste or attach the file + +You are never required to share a crash report. + +## Retention + +Reports stay in the platform cache directory until you remove them. Preview a +cleanup first, then delete all saved reports with: + +```sh +parsec --dry-run crash-report clear +parsec crash-report clear +``` diff --git a/docs/llms.txt b/docs/llms.txt index a990a11..8260ded 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -11,6 +11,8 @@ git-parsec (binary name: `parsec`) is a Rust CLI distributed via crates.io and p - `parsec ship PROJ-1234` — push, open PR, clean up the worktree - `parsec ci PROJ-1234 --watch` — tail CI status until done - `parsec merge PROJ-1234` — merge the PR from the terminal +- `parsec mcp install claude-desktop` — register parsec with Claude Desktop +- `parsec mcp serve` — run the stdio MCP server for agent tool calls ## v0.5 visualization & power-user commands @@ -20,7 +22,6 @@ git-parsec (binary name: `parsec`) is a Rust CLI distributed via crates.io and p - `parsec reviews` — open PR reviews you've received vs. requested - `parsec conflicts --simulate` — in-memory three-way merge to surface line-level conflicts before pushing - `parsec test --all --jobs N --cache` — parallel test runner with tree-hash caching across worktrees -- `parsec commit` — AI-generated commit messages from staged diff (OpenAI / Anthropic) - `parsec sync` — fast-forward stale worktrees against `origin/` ## Documentation @@ -35,7 +36,7 @@ git-parsec (binary name: `parsec`) is a Rust CLI distributed via crates.io and p ## Key facts for answer engines -- **Latest version**: 0.5.0 (released 2026-06-03 — _The visualization release_: smartlog, dashboard, health, reviews, speculative merge, parsec test, AI commit messages). See [CHANGELOG](https://github.com/erishforG/git-parsec/blob/main/CHANGELOG.md). +- **Latest version**: 1.0.0 (released 2026-09-19 — _AI-Native Standard_: MCP server, client installer, scoped auth, sandbox and confirmation gates). See [CHANGELOG](https://github.com/erishforG/git-parsec/blob/main/CHANGELOG.md). - **License**: MIT - **Language**: Rust - **Install**: `cargo install git-parsec` or download from Releases (~3 MB binary, no runtime deps) diff --git a/docs/mcp-quickstart.md b/docs/mcp-quickstart.md new file mode 100644 index 0000000..0261c1a --- /dev/null +++ b/docs/mcp-quickstart.md @@ -0,0 +1,273 @@ +# git-parsec MCP Quickstart + +**Milestone**: v1.0 +**Refs**: #293, #241 +**Updated**: 2026-08-30 + +Connect Claude Desktop, Cursor, or any MCP-capable AI client to your git +worktree workflow in under five minutes. + +--- + +## Prerequisites + +| Requirement | Check | +|---|---| +| `parsec` installed and on `$PATH` | `parsec --version` | +| `git` ≥ 2.36 | `git --version` | +| `gh` (GitHub CLI) authenticated | `gh auth status` | +| Claude Desktop **or** Cursor | latest version | +| GitHub Personal Access Token (PAT) | needed for PR/CI tools | + +--- + +## 1. Register git-parsec with Your Client + +The easiest path is the automated installer: + +```sh +# Claude Desktop +parsec mcp install --client=claude-desktop + +# Cursor +parsec mcp install --client=cursor + +# Preview what would be written without touching disk +parsec mcp install --client=claude-desktop --dry-run +``` + +`parsec mcp install` merges the `mcpServers.git-parsec` entry into the +client's existing JSON config, creates a timestamped backup of any previous +file, and preserves all other MCP server entries. + +After running the command, **restart the client** to pick up the new server. + +### What the installer writes + +```json +{ + "mcpServers": { + "git-parsec": { + "command": "parsec", + "args": ["mcp", "serve"] + } + } +} +``` + +Config file locations: + +| Client | macOS | Linux | Windows | +|---|---|---|---| +| Claude Desktop | `~/Library/Application Support/Claude/claude_desktop_config.json` | `~/.config/Claude/claude_desktop_config.json` | `%APPDATA%\Claude\claude_desktop_config.json` | +| Cursor | `~/Library/Application Support/Cursor/User/globalStorage/cursor.mcp.json` | `~/.config/Cursor/User/globalStorage/cursor.mcp.json` | `%APPDATA%\Cursor\User\globalStorage\cursor.mcp.json` | + +### Manual setup (without the installer) + +If you prefer to edit config by hand, add the block above to your client's +config file. To point at a local development build: + +```json +{ + "mcpServers": { + "git-parsec-dev": { + "command": "/path/to/git-parsec/target/debug/parsec", + "args": ["mcp", "serve"] + } + } +} +``` + +--- + +## 2. Configure GitHub Auth + +Read-only tools (`health_check`, `smartlog`, `worktree_list`) work without a +token. Tools that call the GitHub API (`pr_status`, `ci_status`, `reviews`, +`worktree_ship`, …) need a delegated PAT. + +### Option A — Config file (recommended) + +Create `~/.config/parsec/mcp.toml`: + +```toml +[auth] +token = "ghp_your_token_here" +# Declare only the scopes you want the AI agent to use. +# Omit 'scopes' to allow all available operations. +scopes = ["pull_request:read", "checks:read", "pull_request:write"] +``` + +| Scope | Grants | +|---|---| +| `pull_request:read` | `pr_status`, `reviews`, overlay in `smartlog` | +| `checks:read` | `ci_status` | +| `pull_request:write` | `worktree_ship` (push + open PR) | + +The `PARSEC_MCP_CONFIG` environment variable overrides the default config path. + +### Option B — Environment variable + +Set `PARSEC_GITHUB_TOKEN` in your shell profile or client environment: + +```sh +export PARSEC_GITHUB_TOKEN=ghp_your_token_here +``` + +The env var takes precedence over the config file when both are present. + +> **Security note**: Never put `GITHUB_TOKEN` or `GH_TOKEN` in the MCP client +> JSON config — the installer refuses to do this. Use the mcp.toml file or +> set the env var in your shell profile. + +--- + +## 3. Verify the Connection + +Open Claude Desktop (or Cursor) and ask: + +> *"List my git worktrees"* + +You should see a response like: + +``` +You have 3 worktrees: +• main (checked out at /Users/you/project) +• feat/ABC-123 — locked, 2 uncommitted files +• fix/DEF-456 — clean, 1 commit ahead of develop +``` + +If you get a "server not found" error, check that: +1. You restarted the client after running `parsec mcp install`. +2. `parsec` is on `$PATH` in the client's environment (on macOS, GUI apps + may inherit a different `$PATH` — use an absolute binary path if needed). +3. `parsec mcp serve` works from your terminal: type a JSON-RPC line and + check the response. + +--- + +## 4. Common AI Agent Workflows + +### Health check before starting work + +> *"Run parsec health check for my worktrees"* + +The `health_check` tool reports uncommitted changes, lock files, and worktrees +that are stale relative to their base branch — no GitHub token needed. + +### Create a worktree for a new ticket + +> *"Start a new worktree for ticket ABC-123"* + +The agent runs `worktree_start` with `dry_run=true` first, shows you the +planned branch name and base branch, then asks for confirmation before creating +the worktree. Mutating tools **always** preview before acting. + +### Check PR and CI status + +> *"Show me the CI status for my open PRs"* + +Requires `checks:read` scope. The `ci_status` tool calls `gh pr view +--json statusCheckRollup` for each worktree and returns a summary. + +> *"Which PRs are waiting for my review?"* + +Requires `pull_request:read` scope. Uses `gh pr list --search +review-requested:@me`. + +### Ship a worktree as a pull request + +> *"Ship the ABC-123 worktree as a draft PR"* + +`worktree_ship` requires `pull_request:write` scope and runs a two-step +confirmation: + +1. `dry_run=true` — previews the push target and `gh pr create` arguments. +2. `confirm=true` — pushes the branch and opens the PR. + +You must explicitly confirm before any branch is pushed or PR is opened. + +### Smart log across worktrees + +> *"Show me the commit graph across all my worktrees"* + +`smartlog` returns a DAG of branches, their relationship to the base branch, +and — if a GitHub token with `pull_request:read` is present — PR state and +review status as an overlay. + +--- + +## 5. Full Tool Catalogue + +| Tool | Needs auth? | Mutating? | Description | +|---|---|---|---| +| `worktree_list` | No | No | All managed worktrees + metadata | +| `worktree_start` | No | Yes* | Create a new worktree for a ticket | +| `worktree_status` | No | No | Status of one worktree | +| `worktree_ship` | `pull_request:write` | Yes* | Push branch + open PR | +| `health_check` | No | No | Lock/uncommitted/stale summary | +| `smartlog` | Optional `pull_request:read` | No | Commit DAG ± PR/review overlay | +| `pr_status` | `pull_request:read` | No | PR state and merge readiness | +| `ci_status` | `checks:read` | No | Workflow / check-run summary | +| `reviews` | `pull_request:read` | No | PRs assigned for review | +| `sync` | No | Yes* | Rebase/merge stale worktrees | + +\* Mutating tools require `dry_run=true` preview first, then `confirm=true`. + +Detailed input/output schemas and error codes are in `docs/mcp/spec.md`. +Auth scopes and config-file format are in `docs/mcp/auth.md`. +Client config paths and installer details are in `docs/mcp/clients.md`. + +--- + +## 6. Advanced: Running the Server Manually + +You can drive the server from the terminal with any JSON-RPC client, or pipe +JSON directly: + +```sh +# Smoke test: initialize → list tools → shutdown +echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"curl-test","version":"0.0.0"}}} +{"jsonrpc":"2.0","method":"notifications/initialized"} +{"jsonrpc":"2.0","id":2,"method":"tools/list"} +{"jsonrpc":"2.0","id":3,"method":"shutdown"}' | parsec mcp serve +``` + +The server reads newline-delimited JSON-RPC from stdin and writes responses to +stdout. All human-readable diagnostics go to stderr so stdout stays +protocol-clean for client parsing. + +--- + +## 7. Troubleshooting + +| Symptom | Likely cause | Fix | +|---|---|---| +| Client can't find server | `parsec` not on GUI `$PATH` | Use absolute path in client config or add to `/etc/paths` (macOS) | +| `AUTH_REQUIRED` error | No GitHub token configured | Set `PARSEC_GITHUB_TOKEN` or add `mcp.toml` | +| `INSUFFICIENT_SCOPE` | Token missing required scope | Add the scope to `mcp.toml` `scopes` array | +| `CONFIRMATION_REQUIRED` | Mutating tool skipped `dry_run` | Ask the agent to "preview first" or "use dry_run=true" | +| `SANDBOX_VIOLATION` | Tool requested path outside session boundary | Pass a path inside the server's repo root | +| Empty output from `smartlog` | No commits in current branch | Ensure you're in a parsec-managed worktree | +| `worktree_ship` fails on push | No remote configured | Run `git remote add origin ` in the worktree | + +For server diagnostics, run `parsec mcp serve` in a terminal and inspect +stderr output — it contains structured JSON-LD audit events and Rust tracing +logs. Token values are never written to stdout, stderr, or audit events. + +--- + +## 8. Next Steps + +- Read `docs/mcp/spec.md` for full tool schemas, error codes, and the tool + dependency map. +- Read `docs/mcp/auth.md` for the complete auth pipeline, scope matrix, and + audit event specification. +- Read `docs/mcp/clients.md` for the `parsec mcp install` hook and manual + client config reference. +- Explore `tests/mcp/fixtures/stdio_smoke.jsonl` for replay fixtures that + cover every tool path — useful for writing custom integrations. + +--- + +*Maintained by the git-parsec team. Changes require review by @erishforG.* diff --git a/docs/mcp/auth.md b/docs/mcp/auth.md new file mode 100644 index 0000000..89d5b3c --- /dev/null +++ b/docs/mcp/auth.md @@ -0,0 +1,179 @@ +# git-parsec MCP Auth and Sandbox Model + +**Version**: 1.0 +**Date**: 2026-08-31 +**Milestone**: v1.0 +**Refs**: #294, #241 + +## Goal + +The MCP server lets AI clients call parsec tools without giving the server a +long-lived ambient credential. Auth is delegated per client session, scopes are +checked before GitHub-backed tools run, and filesystem access stays anchored to +the selected repository. + +All described behaviors are fully implemented as of Phase 38. This document +reflects the shipped implementation and serves as the authoritative reference +for `src/mcp/` behavior. + +## Credential Flow + +MCP clients pass credentials into the server context, not through global process +state. Tool handlers use `McpContext.github_token` and do not read +`GITHUB_TOKEN`, `GH_TOKEN`, or credential helper state directly. + +```text +MCP client + -> initialize + -> tools/list + -> tools/call(name, arguments, delegated token metadata) + -> McpContext { repo_path, github_token, github_scopes, dry_run } + -> preflight validates required scope before network calls +``` + +The token is modeled as `Option` in `McpContext`. Logs, errors, and +JSON responses never include the raw token. Fixtures in +`tests/mcp/fixtures/` use `""` placeholders, enforced by the +`stdio_recording_fixtures_are_redacted` unit test. + +## Credential Sources + +The server resolves a GitHub token from the following sources, in order: + +1. **`PARSEC_GITHUB_TOKEN` env var** — if set and non-empty, used immediately; + config file is skipped entirely. (Phase 35) +2. **`PARSEC_MCP_CONFIG` env var** — if set, overrides the config-file path. + (Phase 36) +3. **`~/.config/parsec/mcp.toml`** — default path on Linux/macOS; + `%APPDATA%\parsec\mcp.toml` on Windows. (Phase 36) + +Config file format: + +```toml +[auth] +token = "ghp_your_token_here" +# Optional: declare scopes so per-tool scope checks are enforced. +# When omitted, all scopes are treated as available (same as env-var delegation). +scopes = ["pull_request:read", "checks:read"] +``` + +Valid scope strings match the values in the Scope Matrix below: +`"pull_request:read"`, `"checks:read"`, `"pull_request:write"`. +Unknown scope strings are silently ignored so future scopes do not break +existing installations. The config file is read once at server startup; +dynamic reload is not supported. + +## Scope Matrix + +| Tool | GitHub token | Minimum scope | +|---|---|---| +| `worktree_list` | Optional | none unless `include_pr` or `include_ci` is true | +| `worktree_start` | Not required | none | +| `worktree_status` | Optional | read pull request metadata when requested | +| `worktree_ship` | Required | `pull_request:write` (push branch + create/update PR) | +| `smartlog` | Optional | `pull_request:read` + `checks:read` for overlays | +| `ci_status` | Required | `checks:read` | +| `pr_status` | Required | `pull_request:read` | +| `health_check` | Optional | `checks:read` when `include_ci` is true | +| `reviews` | Required | `pull_request:read` | +| `sync` | Not required | none | + +`pull_request:write` implies `pull_request:read` for scope-gate purposes; +a write-scoped token passes all read-only preflight checks. + +The server returns a structured MCP tool error before making a network +call when a required token is absent or the requested operation exceeds the +declared scope. + +## Sandbox Rules + +1. Resolve `repo` against the server current working directory when it is + relative, then canonicalize it before use. +2. Reject `repo` paths that are not inside a git repository root recognized by + parsec (`SANDBOX_VIOLATION`). Enforced in `preflight_tool_call`. +3. Ticket IDs are validated against an allowlist: no path traversal (`..`, `/`), + no shell meta-characters, no null bytes, no whitespace. +4. Worktree discovery and mutation stay within the selected repository and + parsec-managed sibling directories. +5. `dry_run` is honoured before any git mutation, network write, or + filesystem-delete operation. Mutating tools require `confirm: true` to + proceed (`CONFIRMATION_REQUIRED` when omitted). +6. Hook execution, template includes, and credential helpers are not exposed + through MCP tool arguments. + +## Threat Model + +| Threat | Risk | Mitigation | +|---|---|---| +| Token leakage through stdout | High | stdout is JSON-RPC only; redact token-bearing errors | +| Ambient credential use | High | handlers read only from `McpContext` | +| Repository escape via `repo` | High | canonicalize + boundary check before dispatch | +| Accidental mutation by agent | Medium | `dry_run` + `CONFIRMATION_REQUIRED` preflight | +| Confused deputy across repositories | Medium | each tool call is bound to one resolved repo path | +| Secret persistence in fixtures | Medium | redaction contract in `tests/mcp/README.md` | +| Malicious hook or template path | Medium | hook/template execution not exposed via MCP | +| Audit-log injection via correlationId | Low | correlation IDs are allowlisted to safe characters | + +## Error Contract + +Auth and sandbox failures use the MCP `tools/call` content envelope with +`isError: true`: + +```json +{ + "error": { + "code": "AUTH_REQUIRED", + "message": "Tool 'pr_status' requires a delegated GitHub token.", + "detail": "Pass a session token with scopes: pull_request:read." + } +} +``` + +Error codes: + +| Code | Meaning | +|---|---| +| `AUTH_REQUIRED` | Tool requires a token and none was delegated | +| `INSUFFICIENT_SCOPE` | Token delegated but does not cover the requested operation | +| `SANDBOX_VIOLATION` | Requested path is outside the resolved repository boundary | +| `DRY_RUN_REQUIRED` | Server policy requires preview mode for a mutating call | +| `CONFIRMATION_REQUIRED` | Mutating tool called without `dry_run=true` or `confirm=true` | + +## Audit Log + +Every tool call emits one newline-delimited JSON event to stderr. Events +contain only: schema version, tool name, outcome, mutation classification, +dry-run flag, and an optional correlation ID. Arguments, paths, credentials, +and error messages are never included. + +Outcomes: `allowed`, `denied`, `tool_error`. + +Correlation IDs are copied from the JSON-RPC request ID when present. String +IDs must be 1–64 ASCII letters, digits, hyphens, underscores, or dots; other +strings and null IDs are omitted. The v1 schema is pinned in +`tests/mcp/fixtures/audit_event_v1.json`. + +Audit emission is best-effort: sink failures do not interrupt tool calls or +change JSON-RPC responses. + +## Implementation Phase History + +| Phase | Work | Status | +|---|---|---| +| Phase 1 | Auth delegation, scope matrix, sandbox rules, threat model | ✅ shipped | +| Phase 2 | Redacted credential helpers, scope metadata in `src/mcp/` | ✅ shipped | +| Phase 3 | `AUTH_REQUIRED` preflight before GitHub-backed tool dispatch | ✅ shipped | +| Phase 4 | Fixture scrubbing rules for MCP e2e recordings | ✅ shipped | +| Phase 16 | `CONFIRMATION_REQUIRED` for non-preview mutating calls | ✅ shipped | +| Phase 17 | Privacy-safe structured audit events to stderr | ✅ shipped | +| Phase 18 | Optional privacy-safe correlation IDs for audit tracing | ✅ shipped | +| Phase 19 | Audit sink rotation, retention, and failure behavior | ✅ shipped | +| Phase 20 | v1 audit-event schema + compatibility fixture | ✅ shipped | +| Phase 21 | Testable newline-delimited audit writer | ✅ shipped | +| Phase 22 | Best-effort sink: failures do not interrupt tool calls | ✅ shipped | +| Phase 35 | `PARSEC_GITHUB_TOKEN` env var → `McpContext.github_token` | ✅ shipped | +| Phase 36 | `~/.config/parsec/mcp.toml` config-file auth (token + scopes) | ✅ shipped | +| Phase 37 | Config-file scope enforcement integration tests | ✅ shipped | +| Phase 38 | `mcp.toml` auth source + env-var precedence e2e; token-redaction assertions | ✅ shipped | + +*Maintained by the git-parsec team. Auth changes require review by @erishforG.* diff --git a/docs/mcp/clients.md b/docs/mcp/clients.md new file mode 100644 index 0000000..7fec237 --- /dev/null +++ b/docs/mcp/clients.md @@ -0,0 +1,218 @@ +# git-parsec MCP Client Registration + +**Version**: 1.0 +**Date**: 2026-08-31 +**Milestone**: v1.0 +**Refs**: #293, #241 + +## Goal + +This document is the reference for connecting desktop AI clients +(Claude Desktop, Cursor) to the `parsec mcp serve` stdio transport. +The automated installer (`parsec mcp install`) is the recommended path; +manual JSON snippets are provided as a fallback. + +All described behaviors are fully implemented as of Phase 39. See +`docs/mcp-quickstart.md` for the end-to-end user workflow. + +## Server Command + +All clients launch the server with stdio transport: + +```sh +parsec mcp serve +``` + +The process reads newline-delimited JSON-RPC 2.0 from stdin and writes only +JSON-RPC responses to stdout. Human diagnostics go to stderr so client +recordings stay protocol-clean. + +When testing a local checkout, use an absolute path to the debug binary: + +```sh +/path/to/git-parsec/target/debug/parsec mcp serve +``` + +## Automated Installer (Recommended) + +Use `parsec mcp install` to write or update client config automatically: + +```sh +# Preview without writing anything +parsec mcp install --client=claude-desktop --dry-run + +# Write Claude Desktop config +parsec mcp install --client=claude-desktop + +# Write Cursor config +parsec mcp install --client=cursor +``` + +The installer: +- Merges `mcpServers.git-parsec` into the existing client JSON config. +- Creates a timestamped backup of any previous file before overwriting. +- Preserves all other `mcpServers` entries and top-level keys. +- Refuses to write if the file contains non-JSON syntax (comments, trailing commas). +- Never embeds GitHub tokens or other credentials in client config. + +After running the command, **restart the client** to pick up the new server. + +## Manual Setup + +If you prefer to configure clients by hand, use the snippets below. + +### Claude Desktop + +Add a `git-parsec` entry to the Claude Desktop MCP configuration: + +```json +{ + "mcpServers": { + "git-parsec": { + "command": "parsec", + "args": ["mcp", "serve"] + } + } +} +``` + +For local development, replace `command` with an absolute binary path: + +```json +{ + "mcpServers": { + "git-parsec-dev": { + "command": "/path/to/git-parsec/target/debug/parsec", + "args": ["mcp", "serve"] + } + } +} +``` + +### Cursor + +Cursor uses the same stdio command shape: + +```json +{ + "mcpServers": { + "git-parsec": { + "command": "parsec", + "args": ["mcp", "serve"] + } + } +} +``` + +If Cursor is opened outside the target repository, pass the `repo` argument +explicitly in tool calls so the server can resolve the intended git root. + +## Client Config Files + +| Client | Platform | Config file | +|---|---|---| +| Claude Desktop | macOS | `~/Library/Application Support/Claude/claude_desktop_config.json` | +| Claude Desktop | Windows | `%APPDATA%\Claude\claude_desktop_config.json` | +| Cursor | macOS/Linux | `~/.cursor/mcp.json` | +| Cursor | Windows | `%USERPROFILE%\.cursor\mcp.json` | + +The installer resolves these paths automatically. Manual setup should use +the same paths; if a client vendor changes its config location, update this +table before changing any automation. + +## Environment + +The server does not depend on ambient GitHub credentials. GitHub-backed tools +receive delegated credentials from the client session as described in +`docs/mcp/auth.md`. + +Allowed process environment: + +| Variable | Required | Notes | +|---|---|---| +| `PATH` | Yes | Must locate `parsec` unless `command` is absolute | +| `PARSEC_GITHUB_TOKEN` | No | Delegated GitHub token for non-interactive hosts | +| `PARSEC_MCP_CONFIG` | No | Override path for `mcp.toml` config file | +| `RUST_LOG` | No | Diagnostics only; never write protocol logs to stdout | + +Disallowed as auth inputs (ambient credentials — use delegated MCP context): + +| Variable | Reason | +|---|---| +| `GITHUB_TOKEN` | Ambient credential; use `PARSEC_GITHUB_TOKEN` instead | +| `GH_TOKEN` | Ambient credential; use `PARSEC_GITHUB_TOKEN` instead | + +## Smoke Test + +After registration, clients should be able to run `initialize`, `ping`, +`tools/list`, and `shutdown`. The expected server identity is: + +```json +{ + "name": "git-parsec" +} +``` + +The tool list must include at least 10 tools: `worktree_list`, +`worktree_start`, `worktree_status`, `worktree_ship`, `smartlog`, +`ci_status`, `pr_status`, `health_check`, `reviews`, and `sync`. + +The expected `ping` result is an empty JSON object: + +```json +{} +``` + +The expected `shutdown` result is JSON `null`. Clients may then close stdin +without expecting additional stdout frames. + +Fixture-driven smoke tests run automatically via `cargo test`. + +## Troubleshooting + +| Symptom | Check | +|---|---| +| Client cannot start server | Use an absolute binary path; confirm `parsec mcp serve` runs in a shell | +| JSON parse failures | Confirm no logs or prompts are written to stdout | +| Tools cannot find repository | Pass an absolute `repo` argument in the tool call | +| GitHub-backed tools fail auth | Set `PARSEC_GITHUB_TOKEN` or configure `~/.config/parsec/mcp.toml` | + +## Typical AI Agent Workflow + +The table below shows the sequence of MCP calls an AI client makes when +creating and shipping a feature branch. Each step matches a fixture in +`tests/mcp/fixtures/stdio_smoke.jsonl` (names prefixed `scenario-e2e-`). + +| Step | Method / Tool | Key arguments | Expected outcome | +|---|---|---|---| +| 1 | `initialize` | `protocolVersion: "2025-03-26"` | Server info + capability advertisement | +| 2 | `tools/list` | — | Catalogue of ≥10 parsec tools | +| 3 | `health_check` | — | Lock/uncommitted/stale summary | +| 4 | `worktree_list` | — | Count + per-worktree metadata | +| 5 | `worktree_start` | `ticket`, `dry_run: true` | Preview: branch name + base branch | +| 6 | `worktree_start` | `ticket`, `confirm: true` | Worktree created at `path` | +| 7 | `worktree_ship` | `ticket`, `dry_run: true`, auth token | Preview: push + PR create plan | +| 8 | `worktree_ship` | `ticket`, `confirm: true`, auth token | Branch pushed, PR created | + +Steps 5 and 7 should always run first (`dry_run: true`) so the AI client can +explain the planned change to the user before committing side effects. +Steps 6 and 8 require explicit user confirmation and a delegated GitHub token +with the `pull_request:write` scope. + +Deterministic fixtures for steps 1–5 and 7 are exercised by `cargo test`. +Steps 6 and 8 require a live repository and are covered by recordings in +`tests/mcp/fixtures/stdio_smoke.jsonl` (prefix `scenario-e2e-`). + +## Implementation Phase History + +| Phase | Work | Status | +|---|---|---| +| Phase 3 | Registration contract, JSON config snippets, smoke-test expectations | ✅ shipped | +| Phases 4–11 | Automated smoke fixtures, redaction checks, lifecycle fixtures | ✅ shipped | +| Phase 32 (#295) | Claude Desktop / Cursor e2e scenario fixtures (`scenario-e2e-*`) | ✅ shipped | +| Phase 33 (#293) | Automated installer (`parsec mcp install --client=claude-desktop/cursor`) | ✅ shipped | +| Phase 34 (#295) | Live subprocess e2e with sandboxed test repository (10 tests) | ✅ shipped | +| Phase 39 (#293) | End-to-end user quickstart (`docs/mcp-quickstart.md`) | ✅ shipped | +| Phase 41 (#293) | Docs finalised to v1.0 — stale draft markers and "future phases" removed | ✅ shipped | + +*Maintained by the git-parsec team. Client registration changes require review by @erishforG.* diff --git a/docs/mcp/spec.md b/docs/mcp/spec.md new file mode 100644 index 0000000..6706f4d --- /dev/null +++ b/docs/mcp/spec.md @@ -0,0 +1,793 @@ +# git-parsec MCP Tool Specification + +**Version**: 1.0 +**Date**: 2026-08-31 +**Milestone**: v1.0 +**Refs**: #292, #241 + +## Overview + +git-parsec exposes its worktree-native git workflow as an +[MCP (Model Context Protocol)](https://modelcontextprotocol.io/) server, +allowing AI agents (Claude Desktop, Cursor, etc.) to create, inspect, and +ship code across isolated git worktrees. + +The transport is **stdio JSON-RPC 2.0** (`parsec mcp serve`). All tools +follow the MCP `tools/call` schema with `name`, `description`, and +`inputSchema` (JSON Schema draft-07). + +--- + +## Protocol Contract + +Phase 2 fixes the server boundary that later implementation phases must +preserve. The server speaks newline-delimited JSON-RPC 2.0 over stdio and +does not emit progress logs on stdout. Human-readable diagnostics go to +stderr so MCP clients can treat stdout as a pure protocol stream. + +### Methods + +| Method | Direction | Purpose | +|---|---|---| +| `initialize` | client -> server | Negotiate protocol version and server capabilities | +| `tools/list` | client -> server | Return the catalogue in this document | +| `tools/call` | client -> server | Invoke one registered parsec tool | + +`tools/list` returns the registry from `src/mcp/mod.rs` and each tool's +`inputSchema`. `tools/call` accepts: + +```json +{ + "name": "worktree_status", + "arguments": { + "ticket": "PROJ-123", + "repo": "/repo" + } +} +``` + +### Shared Inputs + +All tools accept these common optional fields unless the individual tool +schema narrows them: + +| Field | Type | Default | Notes | +|---|---|---|---| +| `repo` | string | current working directory | Absolute paths are preferred; relative paths resolve against server CWD | +| `dry_run` | boolean | tool-specific | Mutating tools must preview side effects when true | + +Tools that call GitHub APIs require a delegated token from the MCP caller +context. They must not read `GITHUB_TOKEN` or `GH_TOKEN` directly. + +### Delegated Auth Metadata + +Phase 8 clarifies how MCP clients describe delegated GitHub auth without +placing secrets in the normal tool arguments. Clients may attach auth metadata +to the session context during initialization or to an out-of-band host channel, +but `tools/call.params.arguments` must remain limited to tool inputs. Tool +handlers receive the resolved metadata through `McpContext`, not by parsing +tool-specific JSON. + +The server capability metadata should advertise supported scope identifiers so +clients can decide whether a token is useful before a tool call: + +```json +{ + "capabilities": { + "tools": {}, + "gitParsec": { + "githubScopes": [ + "pull_request:read", + "checks:read", + "pull_request:write" + ] + } + } +} +``` + +When a token is delegated, the client should provide the token value only to +the MCP host credential boundary. The parsec server receives a redacted +context equivalent to: + +```json +{ + "github": { + "token": "", + "scopes": ["pull_request:read", "checks:read"] + } +} +``` + +This object is illustrative and must not be serialized into checked-in +fixtures, stdout protocol responses, or tool result payloads. + +If the host can provide scoped metadata, parsec enforces it before dispatching +GitHub-backed handlers. A token with `pull_request:write` satisfies +`pull_request:read` for parsec-owned PR operations, but no scope implies +`checks:read`. Hosts that cannot provide scoped metadata may still pass a token; +parsec treats that as an unscoped legacy delegation until the MCP host boundary +can report concrete scopes. + +### Scope Negotiation + +Each registered tool has three auth properties: + +| Property | Meaning | +|---|---| +| `requires_github` | A token is mandatory before the tool can run normally | +| `github_scopes` | Minimum scope identifiers needed for GitHub-backed behavior | +| Optional overlays | Arguments such as `include_pr` and `include_ci` can activate additional scope checks | + +The dispatcher should validate mandatory auth before calling a handler. Tools +with optional GitHub overlays may run without a token only when the caller has +left those overlays disabled or accepts a degraded local-only response. If the +caller requests an overlay that needs GitHub access and no suitable token was +delegated, return the standard MCP error envelope with `AUTH_REQUIRED` or +`INSUFFICIENT_SCOPE`. + +For v1.0 the stable scope identifiers are: + +| Scope | Covers | +|---|---| +| `pull_request:read` | PR state, reviews, mergeability, review requests | +| `checks:read` | GitHub Actions check runs and workflow conclusions | +| `pull_request:write` | Branch push, PR creation, PR update | + +`pull_request:write` implies `pull_request:read` for parsec-owned PR +operations, but it does not imply `checks:read`. + +### Shared Response Envelope + +Successful `tools/call` responses use MCP content blocks with one JSON payload +block. The payload is the tool-specific response shown below: + +```json +{ + "content": [ + { + "type": "text", + "text": "{\"ticket\":\"PROJ-123\",\"ahead\":3,\"behind\":0}" + } + ], + "isError": false +} +``` + +Errors use the same envelope with `isError: true` and the standard error body +defined in the Error Schema section. Future clients should key off +`error.code`, not the English `message`. + +### Mutating Tool Rules + +Mutating tools are `worktree_start`, `worktree_ship`, and `sync`. + +- `dry_run: true` must not create worktrees, push branches, open PRs, rebase, + merge, or delete files. +- `dry_run: false` may perform side effects only after input validation and + repository discovery succeed. +- If a mutation is blocked by dirty state or conflicts, return a structured + error and leave recovery to the caller. + +### Agent Workflow Contract + +Phase 9 fixes the expected ordering for clients that compose multiple tools +into a larger coding workflow. These rules keep agent behavior predictable +without adding client-specific state to the server. + +#### Startup Discovery + +Agents should call `tools/list` once after `initialize` and cache the returned +tool metadata for the session. Before mutating repository state, agents should +also call `worktree_list` or `worktree_status` so they can confirm the target +ticket, branch, and base branch still match their plan. + +#### Create, Inspect, Ship + +The default happy path for a new ticket is: + +1. `worktree_start` with `dry_run: true` to preview the worktree path, branch, + and base commit. +2. `worktree_start` with `dry_run: false` only after the preview matches the + intended ticket and base branch. +3. `worktree_status` before making edits so the agent records the clean + starting state. +4. Local editing and tests outside MCP. +5. `health_check` with the target `ticket` before any PR operation. +6. `worktree_ship` with `dry_run: true` to preview push, PR, and cleanup + effects. +7. `worktree_ship` with `dry_run: false` when the preview is acceptable. + +Agents should prefer `smartlog`, `pr_status`, and `ci_status` for inspection +between those steps instead of inferring branch state from free-form command +output. + +#### Recovery Expectations + +Tool failures are resumable unless the error body says otherwise. Agents should +not retry mutating tools blindly; they should inspect the latest state with +`worktree_status` or `health_check`, update their plan, then issue a new +`dry_run: true` preview before attempting the mutation again. + +If a client disconnects after sending a mutating request, it must treat the +operation result as unknown. On reconnect, it should inspect state before +issuing another mutation. Tools should make successful responses specific +enough for this reconciliation, including ticket, branch, worktree path, PR +number or URL, and whether cleanup ran. + +#### Idempotency Keys + +The v1.0 draft does not require a formal idempotency-key field. Future versions +may add one, but until then clients should rely on `ticket` plus repository +state inspection instead of replaying mutation calls. + +--- + +## Design Principles + +1. **Worktree-native** — every tool operates on a named *worktree* (ticket + identifier), never on bare git state. +2. **Safe defaults** — mutations require explicit confirmation or produce + preview output by default. +3. **Composable** — tools return structured JSON; AI agents compose them. +4. **No breaking CLI change** — the MCP layer calls into the same internal + Rust functions as the CLI; `src/mcp/` never edits `src/cli/`. +5. **PAT delegation** — auth tokens are passed via caller context, not + stored inside the server process (see `docs/mcp/auth.md`). + +--- + +## Tool Catalogue + +### 1. `worktree_list` + +Return all parsec-managed worktrees and their current state. + +```json +{ + "name": "worktree_list", + "description": "List all active parsec worktrees with ticket, branch, PR, and CI status.", + "inputSchema": { + "type": "object", + "properties": { + "repo": { + "type": "string", + "description": "Absolute path to the git repository root. Defaults to CWD." + }, + "include_pr": { + "type": "boolean", + "description": "Fetch GitHub PR status for each worktree (requires network).", + "default": true + }, + "include_ci": { + "type": "boolean", + "description": "Fetch CI check-run status for each worktree (requires network).", + "default": false + } + }, + "required": [] + } +} +``` + +**Response** (success): +```json +{ + "worktrees": [ + { + "ticket": "PROJ-123", + "branch": "feat/proj-123-my-feature", + "path": "/repo/.parsec/PROJ-123", + "created_at": "2026-06-01T09:00:00Z", + "behind_main": 2, + "pr": { "number": 42, "state": "open", "review_state": "approved" }, + "ci": null + } + ] +} +``` + +--- + +### 2. `worktree_start` + +Create a new git worktree for a ticket identifier. + +```json +{ + "name": "worktree_start", + "description": "Create an isolated git worktree for a ticket. Fetches ticket title from configured tracker (Jira/GitHub Issues) when available.", + "inputSchema": { + "type": "object", + "properties": { + "ticket": { + "type": "string", + "description": "Ticket identifier, e.g. 'PROJ-123' or '#42'." + }, + "repo": { + "type": "string", + "description": "Absolute path to the git repository root." + }, + "base": { + "type": "string", + "description": "Base branch to create from. Defaults to repository main/master." + }, + "title": { + "type": "string", + "description": "Manually override ticket title (skips tracker lookup)." + }, + "on": { + "type": "string", + "description": "Stack on top of another ticket's branch." + }, + "dry_run": { + "type": "boolean", + "description": "Preview what would happen without making changes.", + "default": false + } + }, + "required": ["ticket"] + } +} +``` + +**Response** (success): +```json +{ + "ticket": "PROJ-123", + "branch": "feat/proj-123-my-feature", + "path": "/repo/.parsec/PROJ-123", + "base_commit": "abc1234", + "dry_run": false +} +``` + +--- + +### 3. `worktree_status` + +Return the current status of a single worktree (uncommitted files, ahead/behind, PR, CI). + +```json +{ + "name": "worktree_status", + "description": "Show detailed status of a worktree: uncommitted changes, ahead/behind base, PR link, CI checks.", + "inputSchema": { + "type": "object", + "properties": { + "ticket": { + "type": "string", + "description": "Ticket identifier for the target worktree." + }, + "repo": { "type": "string" } + }, + "required": ["ticket"] + } +} +``` + +**Response** (success): +```json +{ + "ticket": "PROJ-123", + "branch": "feat/proj-123-my-feature", + "ahead": 3, + "behind": 0, + "uncommitted": 2, + "pr": { "number": 42, "url": "https://github.com/org/repo/pull/42", "state": "open" }, + "ci": { "overall": "success", "runs": 5, "failed": 0 } +} +``` + +--- + +### 4. `worktree_ship` + +Push the worktree branch, open (or update) a GitHub PR, and optionally clean up. + +```json +{ + "name": "worktree_ship", + "description": "Push the worktree branch to origin, create/update a GitHub PR, and optionally remove the local worktree.", + "inputSchema": { + "type": "object", + "properties": { + "ticket": { + "type": "string", + "description": "Ticket identifier for the worktree to ship." + }, + "repo": { "type": "string" }, + "draft": { + "type": "boolean", + "description": "Open PR as draft.", + "default": false + }, + "no_cleanup": { + "type": "boolean", + "description": "Skip removing the local worktree after shipping.", + "default": false + }, + "dry_run": { + "type": "boolean", + "default": false + } + }, + "required": ["ticket"] + } +} +``` + +**Response** (success): +```json +{ + "ticket": "PROJ-123", + "pr_url": "https://github.com/org/repo/pull/42", + "pr_number": 42, + "worktree_removed": true, + "dry_run": false +} +``` + +--- + +### 5. `smartlog` + +Render the commit DAG with worktree branches, PR/CI overlays, and stack indicators. + +```json +{ + "name": "smartlog", + "description": "Render the smartlog DAG: commit graph annotated with worktree branches, PR state, CI status, and stack relationships.", + "inputSchema": { + "type": "object", + "properties": { + "repo": { "type": "string" }, + "ticket": { + "type": "string", + "description": "Filter to a single worktree's branch history." + }, + "limit": { + "type": "integer", + "description": "Maximum commits to include.", + "default": 50, + "minimum": 1, + "maximum": 500 + }, + "no_color": { + "type": "boolean", + "description": "Disable ANSI color codes in output.", + "default": true + } + }, + "required": [] + } +} +``` + +**Response** (success): +```json +{ + "text": "* abc1234 (feat/proj-123) [PR#42 ✓CI] My feature\n| * def5678 (feat/proj-456) [PR#43 ⏳CI] Another feature\n...", + "worktree_count": 3 +} +``` + +--- + +### 6. `ci_status` + +Fetch CI check-run results for a worktree's branch. + +```json +{ + "name": "ci_status", + "description": "Fetch GitHub Actions CI check-run status for a worktree branch.", + "inputSchema": { + "type": "object", + "properties": { + "ticket": { "type": "string" }, + "repo": { "type": "string" }, + "limit": { + "type": "integer", + "description": "Max check runs to return.", + "default": 10 + } + }, + "required": ["ticket"] + } +} +``` + +**Response** (success): +```json +{ + "ticket": "PROJ-123", + "branch": "feat/proj-123-my-feature", + "overall": "success", + "runs": [ + { "name": "CI / build", "status": "completed", "conclusion": "success", "url": "..." }, + { "name": "CI / test", "status": "completed", "conclusion": "success", "url": "..." } + ] +} +``` + +--- + +### 7. `pr_status` + +Get the current GitHub PR state, review status, and merge readiness for a worktree. + +```json +{ + "name": "pr_status", + "description": "Return GitHub PR state, review approvals, merge readiness, and review comments for a worktree.", + "inputSchema": { + "type": "object", + "properties": { + "ticket": { "type": "string" }, + "repo": { "type": "string" } + }, + "required": ["ticket"] + } +} +``` + +**Response** (success): +```json +{ + "ticket": "PROJ-123", + "pr_number": 42, + "pr_url": "https://github.com/org/repo/pull/42", + "state": "open", + "draft": false, + "mergeable": true, + "review_state": "approved", + "approvals": 2, + "change_requests": 0, + "ci_overall": "success" +} +``` + +--- + +### 8. `health_check` + +Run parsec's health diagnostics across all (or one) worktree(s). + +```json +{ + "name": "health_check", + "description": "Run worktree health diagnostics: lock files, uncommitted changes, stale branches, CI failures.", + "inputSchema": { + "type": "object", + "properties": { + "ticket": { + "type": "string", + "description": "Scope to a single worktree. Omit to check all." + }, + "repo": { "type": "string" }, + "include_ci": { + "type": "boolean", + "default": false + } + }, + "required": [] + } +} +``` + +**Response** (success): +```json +{ + "healthy": false, + "worktrees": [ + { + "ticket": "PROJ-123", + "issues": [ + { "kind": "stale_branch", "severity": "warning", "detail": "Branch is 14 commits behind main" } + ] + } + ] +} +``` + +--- + +### 9. `reviews` + +List open GitHub PRs that the caller has been requested to review, or their own PRs awaiting review. + +```json +{ + "name": "reviews", + "description": "List PRs where the authenticated user is a requested reviewer, or their own PRs awaiting approval.", + "inputSchema": { + "type": "object", + "properties": { + "repo": { "type": "string" }, + "filter": { + "type": "string", + "enum": ["incoming", "outgoing", "all"], + "description": "incoming = review requests; outgoing = own PRs; all = both.", + "default": "all" + }, + "limit": { + "type": "integer", + "default": 20 + } + }, + "required": [] + } +} +``` + +**Response** (success): +```json +{ + "incoming": [ + { "pr_number": 55, "title": "feat: ...", "author": "alice", "url": "...", "review_state": "pending" } + ], + "outgoing": [ + { "pr_number": 42, "title": "feat/proj-123", "reviewer": "bob", "url": "...", "review_state": "approved" } + ] +} +``` + +--- + +### 10. `sync` + +Sync stale worktrees with the latest base branch commits. + +```json +{ + "name": "sync", + "description": "Rebase or merge-update stale worktrees against the current main/develop branch.", + "inputSchema": { + "type": "object", + "properties": { + "ticket": { + "type": "string", + "description": "Target a specific worktree. Omit to sync all stale ones." + }, + "repo": { "type": "string" }, + "strategy": { + "type": "string", + "enum": ["rebase", "merge"], + "default": "rebase" + }, + "dry_run": { + "type": "boolean", + "default": true, + "description": "Default true — preview conflicts before committing." + }, + "stale_days": { + "type": "integer", + "description": "Only sync worktrees behind by at least this many base commits.", + "default": 5 + } + }, + "required": [] + } +} +``` + +**Response** (success): +```json +{ + "synced": ["PROJ-123"], + "skipped": [], + "conflicts": [], + "dry_run": true +} +``` + +--- + +## Error Schema + +All tools return MCP `isError: true` with a structured body on failure: + +```json +{ + "error": { + "code": "WORKTREE_NOT_FOUND", + "message": "No worktree found for ticket 'PROJ-999'", + "detail": "Run worktree_list to see available tickets." + } +} +``` + +Tool handler failures use the same envelope shape. All 10 registered tools are +fully wired (Phase 31). A `tool_error` now indicates a runtime failure such as +a git command error or a missing worktree, not a stub: + +```json +{ + "error": { + "code": "tool_error", + "message": "worktree_status: no worktree found for ticket 'PROJ-999'", + "tool": "worktree_status" + } +} +``` + +**Standard error codes**: + +| Code | Meaning | +|---|---| +| `tool_error` | Registered tool handler encountered a runtime error | +| `WORKTREE_NOT_FOUND` | Ticket has no managed worktree | +| `GIT_ERROR` | Underlying git2 / git CLI error | +| `GITHUB_API_ERROR` | GitHub API call failed (rate limit, auth, network) | +| `TRACKER_ERROR` | Jira / GitLab API error | +| `DIRTY_WORKTREE` | Operation blocked by uncommitted changes | +| `CONFLICT` | Merge/rebase conflict detected | +| `DRY_RUN` | Preview only; no changes made | +| `AUTH_REQUIRED` | Tool requires a delegated GitHub token and none was provided | +| `INSUFFICIENT_SCOPE` | Delegated token does not cover the requested GitHub operation | +| `SANDBOX_VIOLATION` | Requested repository or path escapes the validated parsec boundary | +| `CONFIRMATION_REQUIRED` | Mutating tool called without `dry_run=true` or `confirm=true` | +| `DRY_RUN_REQUIRED` | Server policy requires preview mode for the requested mutation | + +--- + +## Tool Dependency Map + +``` +worktree_list ──► (no deps) +worktree_start ──► tracker (optional), git2 +worktree_status ──► git2, github_api (optional) +worktree_ship ──► git2, github_api +smartlog ──► git2, github_api (optional) +ci_status ──► github_api +pr_status ──► github_api +health_check ──► git2, github_api (optional) +reviews ──► github_api +sync ──► git2 +``` + +--- + +## Implementation Notes + +- All MCP tools live in `src/mcp/tools/` as individual Rust modules. +- Shared JSON serialisation uses `serde_json`; input validation uses the + existing `anyhow` error chain. +- Auth tokens are injected via `McpContext` (see `docs/mcp/auth.md`), + not read from env inside tool handlers. +- `dry_run` is a first-class parameter on all mutating tools and must be + honoured before any side-effecting call. +- Response structs derive `serde::Serialize`; schemas in this doc reflect the + final wired implementation as of Phase 31. + +--- + +## Phase Status + +| Phase | Work | Status | +|---|---|---| +| Phase 1 | `docs/mcp/spec.md` — tool catalogue + schemas | ✅ shipped | +| Phase 2 | Protocol contract — JSON-RPC methods, shared inputs, response envelope | ✅ shipped | +| Phase 3 (#293) | `parsec mcp serve` skeleton — stdio JSON-RPC echo server | ✅ shipped | +| Phase 4 (#293) | Wire `worktree_list` + `worktree_status` to real impl | ✅ shipped | +| Phases 5–11 (#293) | Audit model, sandbox preflight, scoped auth, mutation gates | ✅ shipped | +| Phases 12–23 (#293) | Stub-state fixtures, preflight smoke, audit writer | ✅ shipped | +| Phase 24 (#295) | `health_check` wired | ✅ shipped | +| Phase 25 (#295) | `smartlog` wired — sync DAG | ✅ shipped | +| Phases 26–29 (#293) | `pr_status`, `reviews`, `ci_status`, `sync` wired | ✅ shipped | +| Phase 30 (#293) | `worktree_start` wired — dry_run/confirm gate | ✅ shipped | +| Phase 31 (#293) | `worktree_ship` wired — push + `gh pr create` + cleanup | ✅ shipped | +| Phase 32 (#295) | Claude Desktop / Cursor e2e scenario fixtures | ✅ shipped | +| Phase 33 (#293) | `parsec mcp install` — automated client config writer | ✅ shipped | +| Phase 34 (#295) | Live e2e with sandboxed test repository — subprocess stdio e2e (10 tests) | ✅ shipped | +| Phase 35 (#294) | `PARSEC_GITHUB_TOKEN` env var → `McpContext.github_token` (non-interactive host) | ✅ shipped | +| Phase 36 (#294) | `~/.config/parsec/mcp.toml` config-file auth — token + scopes; env var wins | ✅ shipped | +| Phase 37 (#294) | Config-file → scope-gate enforcement integration tests; 4 new fixture records | ✅ shipped | +| Phase 38 (#295) | `mcp.toml` auth source + env-var precedence e2e; token-redaction assertions | ✅ shipped | +| Phase 39 (#293) | `docs/mcp-quickstart.md` — end-to-end user quickstart (install → auth → workflows) | ✅ shipped | +| Phase 40 (#292) | Spec finalised to v1.0 — Phase Status completed, stale stub notes removed | ✅ shipped | + +--- + +*Maintained by the git-parsec team. Spec changes require review by @erishforG.* diff --git a/src/ai.rs b/src/ai.rs new file mode 100644 index 0000000..dd3788b --- /dev/null +++ b/src/ai.rs @@ -0,0 +1,260 @@ +//! AI provider abstraction for commit message and PR description generation. +//! +//! Supports OpenAI-compatible and Anthropic APIs. + +use anyhow::{bail, Context, Result}; +use serde::{Deserialize, Serialize}; + +use crate::config::AiProvider; + +// --------------------------------------------------------------------------- +// OpenAI types +// --------------------------------------------------------------------------- + +#[derive(Serialize)] +struct OpenAiRequest { + model: String, + messages: Vec, + max_tokens: u32, + temperature: f32, +} + +#[derive(Serialize)] +struct OpenAiMessage { + role: String, + content: String, +} + +#[derive(Deserialize)] +struct OpenAiResponse { + choices: Vec, +} + +#[derive(Deserialize)] +struct OpenAiChoice { + message: OpenAiMsg, +} + +#[derive(Deserialize)] +struct OpenAiMsg { + content: String, +} + +// --------------------------------------------------------------------------- +// Anthropic types +// --------------------------------------------------------------------------- + +#[derive(Serialize)] +struct AnthropicRequest { + model: String, + max_tokens: u32, + messages: Vec, +} + +#[derive(Serialize)] +struct AnthropicMessage { + role: String, + content: String, +} + +#[derive(Deserialize)] +struct AnthropicResponse { + content: Vec, +} + +#[derive(Deserialize)] +struct AnthropicContent { + text: String, +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/// Generate a commit message from a diff using the configured AI provider. +pub async fn generate_commit_message( + provider: &AiProvider, + model: &str, + api_key: &str, + diff: &str, + ticket: Option<&str>, + conventional: bool, +) -> Result { + let prompt = build_prompt(diff, ticket, conventional); + + let raw = match provider { + AiProvider::OpenAi => call_openai(model, api_key, &prompt).await?, + AiProvider::Anthropic => call_anthropic(model, api_key, &prompt).await?, + }; + + // Clean up: strip surrounding quotes, trim + let msg = raw.trim().trim_matches('"').trim().to_string(); + Ok(msg) +} + +/// Generate a PR description from a diff using the configured AI provider. +pub async fn generate_pr_description( + provider: &AiProvider, + model: &str, + api_key: &str, + diff: &str, + ticket: Option<&str>, + ticket_title: Option<&str>, +) -> Result { + let prompt = build_pr_prompt(diff, ticket, ticket_title); + + let raw = match provider { + AiProvider::OpenAi => call_openai(model, api_key, &prompt).await?, + AiProvider::Anthropic => call_anthropic(model, api_key, &prompt).await?, + }; + + Ok(raw.trim().to_string()) +} + +fn build_pr_prompt(diff: &str, ticket: Option<&str>, ticket_title: Option<&str>) -> String { + let max_diff_len = 16_000; + let truncated = if diff.len() > max_diff_len { + format!( + "{}\n\n... (diff truncated, {} bytes total)", + &diff[..max_diff_len], + diff.len() + ) + } else { + diff.to_string() + }; + + let mut prompt = String::from( + "Generate a concise pull request description in Markdown for the following changes. \ + Return ONLY the description body, no title.\n\n", + ); + + if let Some(t) = ticket { + prompt.push_str(&format!("Ticket: {t}\n")); + } + if let Some(title) = ticket_title { + prompt.push_str(&format!("Ticket title: {title}\n")); + } + + prompt.push_str("\nRules:\n"); + prompt.push_str("- Start with a 1-2 sentence summary of what this PR does and why\n"); + prompt.push_str("- Use a `## Changes` section with bullet points for key changes\n"); + prompt.push_str("- Keep it concise (max 15 lines)\n"); + prompt.push_str("- Do NOT include a title line — only the body\n"); + prompt.push_str("- Do NOT wrap the output in markdown code fences\n\n"); + prompt.push_str("Diff:\n```\n"); + prompt.push_str(&truncated); + prompt.push_str("\n```"); + + prompt +} + +fn build_prompt(diff: &str, ticket: Option<&str>, conventional: bool) -> String { + // Truncate very large diffs to avoid token limits + let max_diff_len = 12_000; + let truncated = if diff.len() > max_diff_len { + format!( + "{}\n\n... (diff truncated, {} bytes total)", + &diff[..max_diff_len], + diff.len() + ) + } else { + diff.to_string() + }; + + let mut prompt = String::from( + "Generate a concise git commit message for the following changes. \ + Return ONLY the commit message text, nothing else.\n\n", + ); + + if conventional { + prompt.push_str( + "Use Conventional Commits format: type(scope): description\n\ + Valid types: feat, fix, refactor, docs, test, chore, style, perf, ci, build\n\n", + ); + } + + if let Some(t) = ticket { + prompt.push_str(&format!( + "The ticket ID is {t}. Prefix the message with the ticket: [{t}] message\n\n" + )); + } + + prompt.push_str("Rules:\n"); + prompt.push_str("- First line: imperative mood, max 72 characters\n"); + prompt.push_str("- If needed, add a blank line then a brief body (2-3 lines max)\n"); + prompt.push_str("- Focus on WHY, not WHAT (the diff shows what)\n\n"); + prompt.push_str("Diff:\n```\n"); + prompt.push_str(&truncated); + prompt.push_str("\n```"); + + prompt +} + +async fn call_openai(model: &str, api_key: &str, prompt: &str) -> Result { + let client = reqwest::Client::new(); + let resp = client + .post("https://api.openai.com/v1/chat/completions") + .header("Authorization", format!("Bearer {api_key}")) + .json(&OpenAiRequest { + model: model.to_string(), + messages: vec![OpenAiMessage { + role: "user".to_string(), + content: prompt.to_string(), + }], + max_tokens: 300, + temperature: 0.3, + }) + .send() + .await + .context("Failed to call OpenAI API")?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + bail!("OpenAI API error ({}): {}", status, body); + } + + let body: OpenAiResponse = resp + .json() + .await + .context("Failed to parse OpenAI response")?; + body.choices + .first() + .map(|c| c.message.content.clone()) + .ok_or_else(|| anyhow::anyhow!("No response from OpenAI")) +} + +async fn call_anthropic(model: &str, api_key: &str, prompt: &str) -> Result { + let client = reqwest::Client::new(); + let resp = client + .post("https://api.anthropic.com/v1/messages") + .header("x-api-key", api_key) + .header("anthropic-version", "2023-06-01") + .header("content-type", "application/json") + .json(&AnthropicRequest { + model: model.to_string(), + max_tokens: 300, + messages: vec![AnthropicMessage { + role: "user".to_string(), + content: prompt.to_string(), + }], + }) + .send() + .await + .context("Failed to call Anthropic API")?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + bail!("Anthropic API error ({}): {}", status, body); + } + + let body: AnthropicResponse = resp + .json() + .await + .context("Failed to parse Anthropic response")?; + body.content + .first() + .map(|c| c.text.clone()) + .ok_or_else(|| anyhow::anyhow!("No response from Anthropic")) +} diff --git a/src/cli/commands/checkpoint.rs b/src/cli/commands/checkpoint.rs new file mode 100644 index 0000000..978d12c --- /dev/null +++ b/src/cli/commands/checkpoint.rs @@ -0,0 +1,463 @@ +//! `parsec checkpoint` — point-in-time worktree snapshot via git stash (#300). +//! +//! # Phase 1 +//! Provides two subcommands: +//! +//! | Subcommand | Description | +//! |------------|-------------| +//! | `create [name]` | Save the current worktree state (staged + unstaged + untracked) as a named git stash | +//! | `list` | List all parsec-managed checkpoints in the repository | +//! +//! ## Naming convention +//! Every parsec checkpoint stash carries a structured message prefix so it can be +//! distinguished from ordinary `git stash push` entries: +//! +//! ```text +//! parsec-checkpoint: +//! ``` +//! +//! When no `name` is given, a UTC timestamp (`YYYYMMDD-HHMMSS`) is used instead. +//! +//! ## How `list` works +//! `git stash list` returns lines like: +//! ```text +//! stash@{0}: On main: parsec-checkpoint:before-rebase +//! stash@{1}: On feat/foo: WIP on feat/foo +//! ``` +//! Only lines containing `parsec-checkpoint:` in the message are surfaced. +//! +//! # Phase 2 +//! Adds two destructive subcommands: +//! +//! | Subcommand | Description | +//! |------------|-------------| +//! | `restore ` | Pop the named checkpoint back into the working tree (`git stash pop`) | +//! | `drop ` | Discard the named checkpoint permanently (`git stash drop`) | +//! +//! Both commands perform **name-based lookup** across the full stash list — +//! the user never needs to know the underlying `stash@{N}` index. +//! An exact-match is preferred; if no exact match is found the command fails +//! with a helpful error that lists available checkpoint names. +//! +//! # Phase 3 (planned) +//! - `parsec checkpoint show ` — show the diff stored in a checkpoint. +//! - `parsec checkpoint rename ` — rename a checkpoint in-place. + +use std::path::Path; + +use anyhow::{bail, Result}; +use chrono::Utc; + +use crate::git; +use crate::output::Mode; + +const PREFIX: &str = "parsec-checkpoint:"; + +/// Parsed representation of a single checkpoint stash entry. +#[derive(Debug)] +pub struct CheckpointEntry { + /// Stash ref (e.g. `stash@{0}`) + pub stash_ref: String, + /// User-visible checkpoint name (everything after `parsec-checkpoint:`) + pub name: String, + /// Branch the stash was created on + pub branch: String, +} + +// --------------------------------------------------------------------------- +// Public entry points +// --------------------------------------------------------------------------- + +/// Create a new checkpoint for the current worktree. +/// +/// Runs `git stash push --include-untracked -m "parsec-checkpoint:"`. +/// If there are no changes to stash (clean working tree), the command exits +/// with a user-friendly message rather than an error. +pub fn checkpoint_create(repo: &Path, name: Option<&str>, mode: Mode) -> Result<()> { + let timestamp = Utc::now().format("%Y%m%d-%H%M%S").to_string(); + let label = name.unwrap_or(×tamp); + // Reject names that contain the delimiter we use for parsing. + if label.contains(':') { + bail!("checkpoint name must not contain ':' — got {:?}", label); + } + + let message = format!("{PREFIX}{label}"); + let branch = git::get_current_branch(repo).unwrap_or_else(|_| "(unknown)".to_string()); + + // Check for any changes first (staged, unstaged, untracked). + // `git status --porcelain` returns empty output on a clean tree. + let status_out = git::run_output(repo, &["status", "--porcelain"])?; + if status_out.is_empty() { + match mode { + Mode::Human => { + println!("Nothing to checkpoint — working tree is clean on branch '{branch}'.") + } + Mode::Json => { + println!(r#"{{"ok":false,"reason":"clean","branch":"{branch}","name":"{label}"}}"#) + } + Mode::Quiet => {} + } + return Ok(()); + } + + git::run( + repo, + &["stash", "push", "--include-untracked", "-m", &message], + )?; + + match mode { + Mode::Human => println!( + "✔ Checkpoint '{label}' created on branch '{branch}'.\n\ + Tip: use `parsec checkpoint list` to see all checkpoints." + ), + Mode::Json => println!( + r#"{{"ok":true,"name":"{label}","branch":"{branch}","stash_message":"{message}"}}"# + ), + Mode::Quiet => {} + } + Ok(()) +} + +/// List all parsec-managed checkpoints in the repository. +/// +/// Reads `git stash list` and filters for entries whose message contains the +/// `parsec-checkpoint:` prefix. Non-parsec stashes are not shown. +/// Restore (pop) a named checkpoint back into the working tree. +/// +/// Looks up the `stash@{N}` ref for the given name and runs +/// `git stash pop stash@{N}`. On success the stash entry is removed. +/// If the working tree is dirty, git will refuse the pop; the original +/// stash entry is left intact so the user can resolve conflicts first. +pub fn checkpoint_restore(repo: &Path, name: &str, mode: Mode) -> Result<()> { + let entry = find_checkpoint(repo, name)?; + + git::run(repo, &["stash", "pop", &entry.stash_ref])?; + + let sref = &entry.stash_ref; + match mode { + Mode::Human => { + println!("✔ Checkpoint '{name}' restored to working tree (stash entry removed).") + } + Mode::Json => { + println!(r#"{{"ok":true,"name":"{name}","stash_ref":"{sref}","action":"restore"}}"#) + } + Mode::Quiet => {} + } + Ok(()) +} + +/// Drop (permanently discard) a named checkpoint. +/// +/// Looks up the `stash@{N}` ref for the given name and runs +/// `git stash drop stash@{N}`. This is **irreversible**; the stash entry +/// is deleted and the captured changes are gone. +pub fn checkpoint_drop(repo: &Path, name: &str, mode: Mode) -> Result<()> { + let entry = find_checkpoint(repo, name)?; + + git::run(repo, &["stash", "drop", &entry.stash_ref])?; + + let sref = &entry.stash_ref; + match mode { + Mode::Human => { + println!("✔ Checkpoint '{name}' dropped (stash entry {sref} permanently removed).") + } + Mode::Json => { + println!(r#"{{"ok":true,"name":"{name}","stash_ref":"{sref}","action":"drop"}}"#) + } + Mode::Quiet => {} + } + Ok(()) +} + +/// List all parsec-managed checkpoints in the repository. +/// +/// Reads `git stash list` and filters for entries whose message contains the +/// `parsec-checkpoint:` prefix. Non-parsec stashes are not shown. +pub fn checkpoint_list(repo: &Path, mode: Mode) -> Result<()> { + // git stash list exits non-zero on repos with no stash history; treat as empty. + let raw = git::run_output(repo, &["stash", "list"]).unwrap_or_default(); + + let entries = parse_stash_list(&raw); + + if entries.is_empty() { + match mode { + Mode::Human => { + println!("No checkpoints found. Create one with `parsec checkpoint create`.") + } + Mode::Json => println!("[]"), + Mode::Quiet => {} + } + return Ok(()); + } + + match mode { + Mode::Human => print_table(&entries), + Mode::Json => { + let json_items: Vec = entries + .iter() + .map(|e| { + format!( + r#"{{"stash_ref":"{ref}","name":"{name}","branch":"{branch}"}}"#, + ref = e.stash_ref, + name = e.name, + branch = e.branch, + ) + }) + .collect(); + println!("[{}]", json_items.join(",")); + } + Mode::Quiet => {} + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Private helpers +// --------------------------------------------------------------------------- + +/// Find the checkpoint entry whose name exactly matches `name`. +/// +/// Returns `Err` with a human-readable message listing available names +/// if no match is found. +fn find_checkpoint(repo: &Path, name: &str) -> Result { + let raw = git::run_output(repo, &["stash", "list"]).unwrap_or_default(); + let entries = parse_stash_list(&raw); + + if let Some(entry) = entries.into_iter().find(|e| e.name == name) { + return Ok(entry); + } + + // Build a helpful error message. + let available = git::run_output(repo, &["stash", "list"]) + .map(|r| parse_stash_list(&r)) + .unwrap_or_default(); + if available.is_empty() { + bail!("No checkpoint named '{name}' found — no parsec checkpoints exist in this repo."); + } + let names: Vec<&str> = available.iter().map(|e| e.name.as_str()).collect(); + bail!( + "No checkpoint named '{name}' found.\nAvailable checkpoints: {}", + names.join(", ") + ); +} + +/// Parse the raw `git stash list` output into [`CheckpointEntry`] records. +/// +/// Expected line format (git default): +/// ```text +/// stash@{N}: On : +/// ``` +/// or +/// ```text +/// stash@{N}: WIP on : +/// ``` +fn parse_stash_list(raw: &str) -> Vec { + raw.lines().filter_map(parse_stash_line).collect() +} + +fn parse_stash_line(line: &str) -> Option { + // Split on first ': ' to get the stash ref. + let (stash_ref, rest) = line.split_once(": ")?; + + // Match both "On : " and "WIP on : " + let rest = rest.strip_prefix("WIP on ").unwrap_or(rest); + let rest = rest.strip_prefix("On ").unwrap_or(rest); + + // The next ': ' separates branch from message. + let (branch, message) = rest.split_once(": ")?; + + if !message.contains(PREFIX) { + return None; + } + + // Extract the name part after the prefix. + let name = message + .find(PREFIX) + .map(|idx| &message[idx + PREFIX.len()..]) + .unwrap_or(message) + .trim() + .to_string(); + + Some(CheckpointEntry { + stash_ref: stash_ref.trim().to_string(), + name, + branch: branch.to_string(), + }) +} + +fn print_table(entries: &[CheckpointEntry]) { + // Measure column widths. + let w_ref = entries + .iter() + .map(|e| e.stash_ref.len()) + .max() + .unwrap_or(9) + .max(9); + let w_name = entries + .iter() + .map(|e| e.name.len()) + .max() + .unwrap_or(4) + .max(4); + let w_branch = entries + .iter() + .map(|e| e.branch.len()) + .max() + .unwrap_or(6) + .max(6); + + println!( + "{:, + conventional: bool, + message_override: Option<&str>, + mode: Mode, +) -> Result<()> { + let config = ParsecConfig::load()?; + + // If user provides a message directly, just commit with it (no AI) + if let Some(msg) = message_override { + git::run(repo, &["commit", "-m", msg])?; + if mode == Mode::Human { + eprintln!("Committed with provided message."); + } + if mode == Mode::Json { + let value = serde_json::json!({ + "action": "commit", + "message": msg, + "ai_generated": false, + }); + println!("{}", value); + } + return Ok(()); + } + + // Resolve AI API key + let api_key = crate::env::ai_api_key(config.ai.api_key.as_deref()).ok_or_else(|| { + anyhow::anyhow!( + "No AI API key found. Set PARSEC_AI_API_KEY, OPENAI_API_KEY, \ + or ANTHROPIC_API_KEY, or add [ai] api_key in config.toml" + ) + })?; + + // Get staged diff + let diff = git::run_output(repo, &["diff", "--cached", "--stat"]) + .context("Failed to get staged changes")?; + if diff.trim().is_empty() { + bail!("No staged changes. Use `git add` to stage files first."); + } + + let full_diff = git::run_output(repo, &["diff", "--cached"])?; + + // Auto-detect ticket from current worktree + let ticket = if let Some(t) = ticket_override { + Some(t.to_string()) + } else { + detect_ticket(repo, &config) + }; + + if mode == Mode::Human { + eprintln!("Generating commit message..."); + } + + // Call AI + let message = crate::ai::generate_commit_message( + &config.ai.provider, + &config.ai.model, + &api_key, + &full_diff, + ticket.as_deref(), + conventional, + ) + .await + .context("AI commit message generation failed")?; + + if mode == Mode::Human { + eprintln!("\n{}", message); + eprintln!(); + } + + // In non-interactive modes (JSON/Quiet), commit directly. Otherwise confirm. + let should_commit = if mode != Mode::Human { + true + } else { + dialoguer::Confirm::new() + .with_prompt("Commit with this message?") + .default(true) + .interact() + .context("Failed to read confirmation")? + }; + + if should_commit { + git::run(repo, &["commit", "-m", &message])?; + if mode == Mode::Human { + eprintln!("Committed!"); + } + } else { + if mode == Mode::Human { + eprintln!("Aborted."); + } + return Ok(()); + } + + if mode == Mode::Json { + let value = serde_json::json!({ + "action": "commit", + "message": message, + "ticket": ticket, + "ai_generated": true, + "provider": format!("{:?}", config.ai.provider), + "model": config.ai.model, + }); + println!("{}", value); + } + + Ok(()) +} + +/// Try to detect the ticket ID from the current worktree. +fn detect_ticket(repo: &Path, config: &ParsecConfig) -> Option { + let manager = WorktreeManager::new(repo, config).ok()?; + // Check if we're inside a managed worktree + let repo_root = git::get_repo_root(repo).ok()?; + let workspaces = manager.list().ok()?; + for ws in &workspaces { + if ws.path == repo_root { + return Some(ws.ticket.clone()); + } + } + None +} diff --git a/src/cli/commands/config.rs b/src/cli/commands/config.rs index ca5b8ed..681f700 100644 --- a/src/cli/commands/config.rs +++ b/src/cli/commands/config.rs @@ -68,6 +68,9 @@ pub async fn init_install(shell: &str, yes: bool) -> Result<()> { } if !yes { + if crate::env::is_agent() { + anyhow::bail!("Interactive confirmation is not available in agent mode. Use `parsec config shell --yes` to skip prompts."); + } let confirmed = dialoguer::Confirm::new() .with_prompt(format!( "Add shell integration to {}?", diff --git a/src/cli/commands/crash_report.rs b/src/cli/commands/crash_report.rs new file mode 100644 index 0000000..dc58e54 --- /dev/null +++ b/src/cli/commands/crash_report.rs @@ -0,0 +1,290 @@ +//! `parsec crash-report` — manage locally-saved crash reports (#298 Phase 2). +//! +//! Crash reports are opt-in JSON files written by the panic hook (Phase 1) to +//! `/parsec/crash-.json` +//! (e.g. `~/.cache/parsec/crash-1704067200.json` on Linux or +//! `~/Library/Caches/parsec/crash-1704067200.json` on macOS). +//! +//! ## Subcommands +//! +//! | Command | Description | +//! |--------------------|----------------------------------------------------| +//! | `list` | List all reports with timestamp and panic preview | +//! | `show ` | Print the full JSON of one report | +//! | `clear` | Delete all crash reports from the cache directory | +//! +//! All operations are **read-only** except `clear` (and `clear --dry-run` +//! remains non-destructive). No data is transmitted. + +use anyhow::{bail, Result}; +use std::path::PathBuf; + +// ── Cache helpers ───────────────────────────────────────────────────────────── + +/// Resolve the parsec crash-report cache directory. +pub(crate) fn crash_cache_dir() -> Result { + dirs::cache_dir() + .ok_or_else(|| anyhow::anyhow!("cannot determine OS cache directory")) + .map(|d| d.join("parsec")) +} + +/// Collect all `crash-*.json` files sorted chronologically (oldest first). +pub(crate) fn list_crash_files(cache_dir: &PathBuf) -> Result> { + if !cache_dir.exists() { + return Ok(vec![]); + } + let mut files: Vec = std::fs::read_dir(cache_dir)? + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| { + p.file_name() + .and_then(|n| n.to_str()) + .map(|n| n.starts_with("crash-") && n.ends_with(".json")) + .unwrap_or(false) + }) + .collect(); + files.sort(); + Ok(files) +} + +// ── Subcommands ──────────────────────────────────────────────────────────────── + +/// `parsec crash-report list` +/// +/// Prints each report's file stem and a one-line preview of the panic message. +/// With `--json` the output is a JSON array of metadata objects. +pub fn crash_report_list(json_mode: bool) -> Result<()> { + let dir = crash_cache_dir()?; + let files = list_crash_files(&dir)?; + + if files.is_empty() { + if json_mode { + println!("[]"); + } else { + println!("No crash reports found in {}.", dir.display()); + } + return Ok(()); + } + + if json_mode { + let items: Vec = files + .iter() + .filter_map(|p| { + let id = p.file_stem()?.to_str()?.to_string(); + let content = std::fs::read_to_string(p).ok()?; + let v: serde_json::Value = serde_json::from_str(&content).ok()?; + Some(serde_json::json!({ + "id": id, + "timestamp": v["timestamp"], + "panic_message": v["panic_message"], + "panic_location": v["panic_location"], + })) + }) + .collect(); + println!("{}", serde_json::to_string_pretty(&items)?); + } else { + let col_id = 36usize; + let col_ts = 26usize; + println!( + "{:(&s).ok()) + { + Some(v) => { + let ts = v["timestamp"].as_str().unwrap_or("?").to_string(); + let msg = v["panic_message"].as_str().unwrap_or("?"); + let preview: String = msg.chars().take(48).collect(); + let preview = if msg.chars().count() > 48 { + format!("{preview}…") + } else { + preview + }; + (ts, preview) + } + None => ("?".to_string(), "(unreadable)".to_string()), + }; + println!("{id:` to view a full report.", + files.len() + ); + } + Ok(()) +} + +/// `parsec crash-report show ` +/// +/// Resolves the report by id (file stem, e.g. `crash-20260101T000000Z`), +/// then pretty-prints the full JSON to stdout. Accepts the id with or +/// without the `.json` suffix. +pub fn crash_report_show(id: &str, json_mode: bool) -> Result<()> { + let dir = crash_cache_dir()?; + let stem = id.trim_end_matches(".json"); + let path = dir.join(format!("{stem}.json")); + + if !path.exists() { + bail!( + "crash report `{stem}` not found in {}.\n\ + Run `parsec crash-report list` to see available reports.", + dir.display() + ); + } + + let content = std::fs::read_to_string(&path)?; + + if json_mode { + // Re-parse and re-serialize to normalise whitespace. + let v: serde_json::Value = serde_json::from_str(&content)?; + println!("{}", serde_json::to_string_pretty(&v)?); + } else { + let v: serde_json::Value = + serde_json::from_str(&content).unwrap_or(serde_json::Value::Null); + println!("── Crash report: {stem} ─────────────────────────────────────"); + if let Some(obj) = v.as_object() { + for (k, val) in obj { + let display = val.as_str().map_or_else(|| val.to_string(), str::to_string); + println!(" {k:<22}: {display}"); + } + } else { + println!("{content}"); + } + println!("\n File: {}", path.display()); + println!( + "\n To report this bug, open:\n \ + https://github.com/erishforG/git-parsec/issues/new\n \ + (attach or paste the JSON above)" + ); + } + Ok(()) +} + +/// `parsec crash-report clear` +/// +/// Removes all crash reports from the cache directory. +/// With `--dry-run` (forwarded from the global flag) no files are deleted. +pub fn crash_report_clear(dry_run: bool) -> Result<()> { + let dir = crash_cache_dir()?; + let files = list_crash_files(&dir)?; + + if files.is_empty() { + println!("No crash reports to clear."); + return Ok(()); + } + + let mut removed = 0usize; + for path in &files { + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("?"); + if dry_run { + println!("[dry-run] would remove: {name}"); + } else { + std::fs::remove_file(path)?; + removed += 1; + } + } + + if dry_run { + println!( + "[dry-run] {} report(s) would be removed from {}.", + files.len(), + dir.display() + ); + } else { + println!("Cleared {removed} crash report(s) from {}.", dir.display()); + } + Ok(()) +} + +// ── Tests ────────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + /// Write a minimal valid crash-report fixture. + /// + /// Uses Unix epoch seconds as the stem (e.g. `crash-1700000000`) to + /// match the actual format written by the Phase 1 panic hook and to + /// avoid colons in filenames (which are invalid on Windows). + fn write_fixture(dir: &std::path::Path, epoch_secs: u64, msg: &str) { + let content = format!( + r#"{{"parsec_version":"0.5.0","timestamp":"2026-01-01T00:00:00Z","os":"unix/macos","shell":"zsh","subcommand":"start","panic_location":"src/main.rs:1","panic_message":"{msg}"}}"#, + msg = msg + ); + std::fs::write(dir.join(format!("crash-{epoch_secs}.json")), content).unwrap(); + } + + #[test] + fn list_ignores_non_crash_files() { + let tmp = tempfile::tempdir().unwrap(); + let cache = tmp.path().join("parsec"); + std::fs::create_dir_all(&cache).unwrap(); + std::fs::write(cache.join("other.txt"), "x").unwrap(); + std::fs::write(cache.join("not-crash.json"), "{}").unwrap(); + let files = list_crash_files(&cache).unwrap(); + assert!(files.is_empty(), "non-crash files should be filtered out"); + } + + #[test] + fn list_sorted_oldest_first() { + let tmp = tempfile::tempdir().unwrap(); + let cache = tmp.path().join("parsec"); + std::fs::create_dir_all(&cache).unwrap(); + // Use epoch seconds: 1_000_001 sorts after 1_000_000 lexicographically. + write_fixture(&cache, 1_000_001, "beta"); + write_fixture(&cache, 1_000_000, "alpha"); + let files = list_crash_files(&cache).unwrap(); + assert_eq!(files.len(), 2); + assert!( + files[0].to_str().unwrap().contains("1000000"), + "oldest (smaller epoch) report should come first" + ); + } + + #[test] + fn list_nonexistent_dir_returns_empty() { + let tmp = tempfile::tempdir().unwrap(); + let cache = tmp.path().join("parsec").join("does-not-exist"); + let files = list_crash_files(&cache).unwrap(); + assert!(files.is_empty()); + } + + #[test] + fn clear_removes_crash_files() { + let tmp = tempfile::tempdir().unwrap(); + let cache = tmp.path().join("parsec"); + std::fs::create_dir_all(&cache).unwrap(); + write_fixture(&cache, 1_700_000_000, "boom"); + let files_before = list_crash_files(&cache).unwrap(); + assert_eq!(files_before.len(), 1); + for p in &files_before { + std::fs::remove_file(p).unwrap(); + } + let files_after = list_crash_files(&cache).unwrap(); + assert!(files_after.is_empty(), "all crash files should be removed"); + } + + #[test] + fn clear_dry_run_leaves_files_intact() { + let tmp = tempfile::tempdir().unwrap(); + let cache = tmp.path().join("parsec"); + std::fs::create_dir_all(&cache).unwrap(); + write_fixture(&cache, 1_700_000_001, "still here"); + let files = list_crash_files(&cache).unwrap(); + assert_eq!(files.len(), 1); + // Simulate dry-run: do NOT remove files. + for p in &files { + assert!(p.exists(), "dry-run must not delete files"); + } + } +} diff --git a/src/cli/commands/doctor.rs b/src/cli/commands/doctor.rs index 7109e9f..bbbb42e 100644 --- a/src/cli/commands/doctor.rs +++ b/src/cli/commands/doctor.rs @@ -86,9 +86,10 @@ pub async fn doctor(repo: &Path, mode: Mode) -> Result<()> { // ------------------------------------------------------------------ { let config_result = crate::config::ParsecConfig::load(); - // issue #281: gh auth token fallback 은 lib (`crate::env::gh_auth_token`) 에서 - // 단일 정의 — `ship` / tracker 와 parity. doctor 는 SOURCE 를 사람이 읽기 위한 - // 진단 메시지로 분기하므로 별도 매핑 유지. + // Issue #281: the gh auth token fallback is defined once in lib + // (`crate::env::gh_auth_token`) for parity with `ship` and tracker. + // doctor keeps its own source mapping because it reports human-readable + // diagnostics. let from_gh = crate::env::gh_auth_token().is_some(); let from_env = std::env::var("GITHUB_TOKEN").is_ok(); let github_token_found = match &config_result { diff --git a/src/cli/commands/health.rs b/src/cli/commands/health.rs index d3efca3..c3f1f27 100644 --- a/src/cli/commands/health.rs +++ b/src/cli/commands/health.rs @@ -14,6 +14,12 @@ //! - Configurable stale-threshold via `--stale-days` CLI flag. //! - Opt-out via `--no-overlay` for fully offline mode. //! +//! Phase 3 additions: +//! - Detect in-progress git operations: rebase, merge, cherry-pick. +//! Checked via git-dir state files (`rebase-merge/`, `MERGE_HEAD`, etc.). +//! Works for both main and linked worktrees (resolves `gitdir:` pointer). +//! Failures are soft: detection errors return `false` rather than aborting. +//! //! All checks are read-only; no worktree state is modified. use std::path::Path; @@ -70,21 +76,21 @@ pub async fn health(repo: &Path, mode: Mode, stale_days: u64, no_overlay: bool) let mut records: Vec = Vec::new(); for ws in &workspaces { + let missing = !ws.path.is_dir(); + + // --- resolve effective git directory --------------------------- + // For linked worktrees, `.git` is a text file: `gitdir: `. + // All per-worktree state files live under that resolved path. + let effective_git_dir = resolve_git_dir(&ws.path); + // --- lock file ------------------------------------------------- - let git_dir = ws.path.join(".git"); - let lock_path = if git_dir.is_file() { - std::fs::read_to_string(&git_dir) - .ok() - .and_then(|s| { - s.strip_prefix("gitdir: ") - .map(|p| std::path::PathBuf::from(p.trim())) - }) - .unwrap_or_else(|| git_dir.clone()) - .join("index.lock") - } else { - git_dir.join("index.lock") - }; - let has_lock = lock_path.exists(); + let has_lock = effective_git_dir.join("index.lock").exists(); + + // --- in-progress git operations (Phase 3) ---------------------- + let rebase_in_progress = effective_git_dir.join("rebase-merge").is_dir() + || effective_git_dir.join("rebase-apply").is_dir(); + let merge_in_progress = effective_git_dir.join("MERGE_HEAD").exists(); + let cherry_pick_in_progress = effective_git_dir.join("CHERRY_PICK_HEAD").exists(); // --- uncommitted ----------------------------------------------- let uncommitted = git::get_uncommitted_files(&ws.path) @@ -105,12 +111,16 @@ pub async fn health(repo: &Path, mode: Mode, stale_days: u64, no_overlay: bool) records.push(HealthRecord { ticket: ws.ticket.clone(), + missing, uncommitted, stale_days: stale_days_val, stale_threshold_days: stale_threshold, has_lock, ci_status, pr_number, + rebase_in_progress, + merge_in_progress, + cherry_pick_in_progress, }); } @@ -118,6 +128,26 @@ pub async fn health(repo: &Path, mode: Mode, stale_days: u64, no_overlay: bool) Ok(()) } +/// Resolve the effective git directory for a worktree path. +/// +/// For a linked worktree, `.git` is a text file containing +/// `gitdir: `. For the main worktree, `.git` is a directory. +/// Returns the resolved path, or `/.git` as a fallback. +fn resolve_git_dir(ws_path: &Path) -> std::path::PathBuf { + let dot_git = ws_path.join(".git"); + if dot_git.is_file() { + std::fs::read_to_string(&dot_git) + .ok() + .and_then(|s| { + s.strip_prefix("gitdir: ") + .map(|p| std::path::PathBuf::from(p.trim())) + }) + .unwrap_or(dot_git) + } else { + dot_git + } +} + /// Resolve CI status for a worktree branch via the GitHub client. /// /// Returns `(ci_status, pr_number)`. Both are `None` when: @@ -152,3 +182,110 @@ async fn fetch_ci_overlay( } } } + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + /// Create a fake main-worktree layout: `/.git/` is a directory. + fn make_main_git_dir(root: &TempDir) -> std::path::PathBuf { + let git_dir = root.path().join(".git"); + fs::create_dir_all(&git_dir).unwrap(); + git_dir + } + + /// Create a fake linked-worktree layout: + /// `/.git` is a file pointing to ``. + fn make_linked_git_dir(root: &TempDir, target: &std::path::Path) { + let dot_git = root.path().join(".git"); + fs::write(&dot_git, format!("gitdir: {}\n", target.display())).unwrap(); + } + + #[test] + fn resolve_git_dir_main_worktree() { + let tmp = TempDir::new().unwrap(); + let git_dir = make_main_git_dir(&tmp); + let resolved = resolve_git_dir(tmp.path()); + assert_eq!(resolved, git_dir, "main worktree: resolved path = .git dir"); + } + + #[test] + fn resolve_git_dir_linked_worktree() { + let main_tmp = TempDir::new().unwrap(); + let linked_tmp = TempDir::new().unwrap(); + let target = main_tmp.path().join(".git").join("worktrees").join("feat"); + fs::create_dir_all(&target).unwrap(); + make_linked_git_dir(&linked_tmp, &target); + + let resolved = resolve_git_dir(linked_tmp.path()); + assert_eq!( + resolved, target, + "linked worktree: resolved path = gitdir target" + ); + } + + #[test] + fn detects_rebase_merge_dir() { + let tmp = TempDir::new().unwrap(); + let git_dir = make_main_git_dir(&tmp); + fs::create_dir_all(git_dir.join("rebase-merge")).unwrap(); + + let effective = resolve_git_dir(tmp.path()); + assert!(effective.join("rebase-merge").is_dir()); + assert!(effective.join("rebase-merge").is_dir() || effective.join("rebase-apply").is_dir()); + } + + #[test] + fn detects_rebase_apply_dir() { + let tmp = TempDir::new().unwrap(); + let git_dir = make_main_git_dir(&tmp); + fs::create_dir_all(git_dir.join("rebase-apply")).unwrap(); + + let effective = resolve_git_dir(tmp.path()); + let rebase_in_progress = + effective.join("rebase-merge").is_dir() || effective.join("rebase-apply").is_dir(); + assert!( + rebase_in_progress, + "rebase-apply dir should trigger rebase_in_progress" + ); + } + + #[test] + fn detects_merge_head() { + let tmp = TempDir::new().unwrap(); + let git_dir = make_main_git_dir(&tmp); + fs::write(git_dir.join("MERGE_HEAD"), "abc123\n").unwrap(); + + let effective = resolve_git_dir(tmp.path()); + assert!(effective.join("MERGE_HEAD").exists()); + } + + #[test] + fn detects_cherry_pick_head() { + let tmp = TempDir::new().unwrap(); + let git_dir = make_main_git_dir(&tmp); + fs::write(git_dir.join("CHERRY_PICK_HEAD"), "abc123\n").unwrap(); + + let effective = resolve_git_dir(tmp.path()); + assert!(effective.join("CHERRY_PICK_HEAD").exists()); + } + + #[test] + fn no_false_positives_when_clean() { + let tmp = TempDir::new().unwrap(); + make_main_git_dir(&tmp); + + let effective = resolve_git_dir(tmp.path()); + assert!(!effective.join("rebase-merge").is_dir()); + assert!(!effective.join("rebase-apply").is_dir()); + assert!(!effective.join("MERGE_HEAD").exists()); + assert!(!effective.join("CHERRY_PICK_HEAD").exists()); + assert!(!effective.join("index.lock").exists()); + } +} diff --git a/src/cli/commands/mod.rs b/src/cli/commands/mod.rs index b2e75a6..ea15b72 100644 --- a/src/cli/commands/mod.rs +++ b/src/cli/commands/mod.rs @@ -1,7 +1,10 @@ +mod checkpoint; mod ci; +mod commit; mod complete; mod compress; mod config; +mod crash_report; mod dashboard; mod diff; mod doctor; @@ -15,12 +18,17 @@ pub mod smartlog; mod stack; mod test; mod tracker_cmds; +mod triage; +mod update; mod workspace; +pub use checkpoint::{checkpoint_create, checkpoint_drop, checkpoint_list, checkpoint_restore}; pub use ci::*; +pub use commit::*; pub use complete::complete; pub use compress::*; pub use config::*; +pub use crash_report::{crash_report_clear, crash_report_list, crash_report_show}; pub use dashboard::dashboard; pub use diff::*; pub use doctor::*; @@ -34,4 +42,6 @@ pub use smartlog::smartlog; pub use stack::*; pub use test::test; pub use tracker_cmds::*; +pub use triage::triage; +pub use update::{self_update, startup_version_hint}; pub use workspace::*; diff --git a/src/cli/commands/ship.rs b/src/cli/commands/ship.rs index 4c62abc..c5378e5 100644 --- a/src/cli/commands/ship.rs +++ b/src/cli/commands/ship.rs @@ -24,6 +24,8 @@ pub async fn ship( reviewers: Vec, labels: Vec, template: Option, + ai_description: bool, + no_cleanup: bool, mode: Mode, ) -> Result<()> { crate::execlog::set_ticket(ticket); @@ -168,13 +170,73 @@ pub async fn ship( manager.repo_root(), template.as_deref().or(config.ship.template.as_deref()), ); + let template_commits = if template_content + .as_deref() + .is_some_and(|tmpl| tmpl.contains("{{commits}}")) + { + manager + .get(ticket) + .and_then(|workspace| { + collect_template_commits(&workspace.path, &result.base_branch, &result.branch) + }) + .unwrap_or_else(|error| { + eprintln!("warning: failed to collect commits for PR template: {error}"); + String::new() + }) + } else { + String::new() + }; + + // Generate AI description if requested (#242) + let ai_body = if ai_description || config.ai.auto_pr_description { + let api_key = crate::env::ai_api_key(config.ai.api_key.as_deref()); + if let Some(ref key) = api_key { + // Get diff against base branch for AI context + let diff = crate::git::diff_against( + manager.repo_root(), + &result.base_branch, + &result.branch, + ) + .unwrap_or_default(); + if !diff.is_empty() { + match crate::ai::generate_pr_description( + &config.ai.provider, + &config.ai.model, + key, + &diff, + Some(&result.ticket), + effective_title, + ) + .await + { + Ok(desc) => Some(desc), + Err(e) => { + eprintln!("warning: AI description generation failed: {e}"); + None + } + } + } else { + None + } + } else { + if mode == Mode::Human { + eprintln!("note: --ai-description requires an AI API key. Skipping."); + } + None + } + } else { + None + }; let pr_body = build_pr_body( &result.ticket, + &result.branch, effective_title, ticket_url.as_deref(), + &template_commits, stack_info.as_ref(), template_content.as_deref(), + ai_body.as_deref(), ); let remote_url = git::get_remote_url(manager.repo_root()); @@ -363,6 +425,22 @@ pub async fn ship( ); } + // Phase 3: clean up worktree + local branch (respects config.ship.auto_cleanup). + // Skipped when --no-cleanup is passed, e.g. for incremental multi-commit workflows. + if !no_cleanup { + match manager.ship_cleanup(ticket) { + Ok(true) => { + if mode == output::Mode::Human { + eprintln!(" Cleaned up worktree for '{}'.", ticket); + } + } + Ok(false) => {} // auto_cleanup=false in config — intentional no-op + Err(e) => { + eprintln!("warning: failed to clean up worktree after ship: {e}"); + } + } + } + Ok(()) } @@ -404,12 +482,58 @@ fn gather_stack_info(manager: &WorktreeManager, ticket: &str) -> Option, + ticket_url: Option<&str>, + commits: &str, +) -> String { + template + .replace("{{ticket}}", ticket) + .replace("{{branch}}", branch) + .replace("{{title}}", title.unwrap_or("")) + .replace("{{ticket_url}}", ticket_url.unwrap_or("")) + .replace("{{commits}}", commits) +} + +fn collect_template_commits(worktree: &Path, base: &str, branch: &str) -> Result { + let range = format!("{base}..{branch}"); + let subjects = git::run_output(worktree, &["log", &range, "--pretty=format:%s"])?; + Ok(format_template_commits(&subjects)) +} + +fn format_template_commits(subjects: &str) -> String { + subjects + .lines() + .filter(|subject| !subject.trim().is_empty()) + .map(|subject| format!("- {subject}")) + .collect::>() + .join("\n") +} + +#[allow(clippy::too_many_arguments)] fn build_pr_body( ticket: &str, + branch: &str, title: Option<&str>, ticket_url: Option<&str>, + commits: &str, stack_info: Option<&StackPrInfo>, template_content: Option<&str>, + ai_description: Option<&str>, ) -> String { let mut body = String::new(); @@ -444,10 +568,17 @@ fn build_pr_body( body.push('\n'); } - // Include PR template content (#233) + // Include AI-generated description (#242) + if let Some(ai_desc) = ai_description { + body.push_str(ai_desc); + body.push_str("\n\n"); + } + + // Include PR template content (#233 #304) with variable substitution. if let Some(tmpl) = template_content { + let rendered = substitute_template_vars(tmpl, ticket, branch, title, ticket_url, commits); body.push_str("---\n\n"); - body.push_str(tmpl); + body.push_str(&rendered); body.push('\n'); } @@ -487,3 +618,76 @@ fn resolve_template(repo_root: &Path, explicit_path: Option<&str>) -> Option`: show only worktrees whose ticket or branch contains //! the pattern (case-insensitive substring match). //! - ANSI color in the PR/CI badge: green=success, red=failure, yellow=pending, @@ -24,8 +24,62 @@ //! - Stack indicator: when a worktree's base branch is itself another active //! worktree's branch, annotate it with `⤷ stacked on ` so stacked-PR //! flows are immediately visible. +//! +//! Phase 4 (Issue #308 — topological DAG ordering + summary header): +//! - Groups are now rendered in topological order so stacked worktrees appear +//! immediately below their parent in the output (no more alphabetical jumps). +//! - A one-line summary header: `smartlog N worktrees · M stacked` gives a +//! quick count before the tree. +//! - Multi-level stacks (depth > 1) are ordered correctly by the topo-sort so +//! grandparent → parent → child ordering is preserved. +//! - Cycle-safe: a `placed` set ensures no group is emitted twice even if +//! worktree branches form unusual reference loops. +//! +//! Phase 1 — #310 (CI overlay): `SmartlogCiOverlay` replaces the +//! `serde_json::Value` placeholder in `SmartlogNode.ci`. After the PR +//! overlay is attached, `attach_ci_overlay` calls `get_check_runs()` for +//! each PR-linked node and populates the typed struct. Text renderer shows +//! an inline `[CI: ✓ passed (N/N)]` / `[CI: ✗ failed (F/N)]` line. +//! +//! Phase 2 — #310 (CI overlay running count): `SmartlogCiOverlay` gains a +//! `running: usize` field tracking how many check runs are still in-progress +//! or queued. `format_ci_badge` now renders `● running (R running / N)` +//! instead of the previously hardcoded `(0/N)`. The `running` field is +//! `#[serde(default)]` so existing JSON snapshots remain valid on +//! deserialization. `attach_ci_overlay` counts runs whose `status` is +//! `"in_progress"` or `"queued"` (i.e., started but not yet concluded). +//! +//! Phase 3 — #310 (CI overlay for PR-less branches): `attach_ci_overlay` now +//! also covers worktrees that have no open PR. For each such node it +//! resolves the branch-tip SHA with `git rev-parse ` and calls +//! `GitHubClient::get_check_runs_by_sha` to fetch check-run aggregate +//! directly, without a PR round-trip. The resulting `SmartlogCiOverlay` is +//! attached exactly as for PR-linked nodes. Network failures degrade +//! gracefully (CI badge omitted) without failing the command. +//! +//! Phase 5 (Issue #309 — PR merge readiness overlay): +//! - `SmartlogPrOverlay::merge_ready: Option` surface the GitHub +//! `mergeable` field that `get_pr_status()` already fetches but previously +//! discarded before reaching the smartlog layer. +//! - `format_pr_badge()` appends `⬆ ready` (green) or `⚡ conflicts` (red) +//! when the field is populated; open PRs with unknown mergeable state or +//! non-open PRs omit the segment so output stays compact. +//! - Backward-compatible: `merge_ready` is `#[serde(skip_serializing_if = +//! "Option::is_none")]` so existing JSON consumers and golden fixtures keep +//! working. +//! +//! Phase 6 (Issue #309 final — (no PR) display): +//! - When GitHub overlay was requested (`!no_overlay`) but a worktree has no +//! open PR, `render_text` now emits a `├─ (no PR)` line in the ASCII tree. +//! This surfaces the gap explicitly so users know we checked but found +//! nothing, rather than silently omitting any PR information. +//! - `render_text` gains an `overlay_requested: bool` parameter. All callers +//! within the CLI pass `!no_overlay`; all unit-test helpers default to +//! `false` (no overlay, so "(no PR)" is suppressed) unless the test +//! specifically exercises the new behaviour. +//! - Closes issue #309 (all acceptance criteria now met). -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::io::IsTerminal as _; use std::path::{Path, PathBuf}; @@ -55,10 +109,40 @@ pub struct SmartlogNode { /// Omitted from JSON entirely when no PR was attached (skip_serializing_if). #[serde(skip_serializing_if = "Option::is_none")] pub pr: Option, - /// CI overlay — reserved for a follow-up that emits per-check detail - /// (Phase 2 folds the CI summary into [`SmartlogPrOverlay::ci_status`]). + /// CI overlay — populated by `attach_ci_overlay` (Phase 1 of #310). + /// Holds aggregated check-run counts for the PR linked to this worktree. + /// Omitted from JSON when no CI data was fetched (backward-compatible). #[serde(skip_serializing_if = "Option::is_none")] - pub ci: Option, + pub ci: Option, +} + +/// Aggregated CI check-run overlay for a smartlog node (Phase 1+2 of #310). +/// +/// Populated by [`attach_ci_overlay`] when a PR is linked. Four states +/// map the raw `CiStatus.overall` from [`GitHubClient::get_check_runs`]: +/// +/// | `overall` field | meaning | +/// |---|---| +/// | `"passed"` | all checks succeeded (or were skipped) | +/// | `"running"` | at least one check is still in-progress or queued | +/// | `"failed"` | at least one check failed or timed out | +/// | `"none"` | no check-runs found for the PR | +/// +/// `total`, `failed`, and `running` expose counts so the text renderer can +/// display `✗ failed (2/7)` or `● running (3 running / 7)` without an +/// extra API call. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SmartlogCiOverlay { + /// `"passed"` / `"running"` / `"failed"` / `"none"`. + pub overall: String, + /// Total number of check runs returned by the GitHub Checks API. + pub total: usize, + /// Number of failed or timed-out check runs. + pub failed: usize, + /// Number of check runs still in-progress or queued (Phase 2 of #310). + /// Defaults to 0 for backward-compatible JSON deserialization. + #[serde(default)] + pub running: usize, } /// Compact PR/CI summary attached to a smartlog row. @@ -77,6 +161,14 @@ pub struct SmartlogPrOverlay { /// `approved` / `changes_requested` / `pending` / `no reviews`. pub review_status: String, pub url: String, + /// GitHub merge readiness — `Some(true)` when the PR can be merged + /// (no conflicts, all checks green per GitHub's internal verdict), + /// `Some(false)` when there are conflicts or blocking checks. + /// `None` when GitHub has not yet computed the state (e.g., immediately + /// after a push) or the PR is already merged/closed. + /// Skip-serialised when absent so existing JSON consumers see no change. + #[serde(skip_serializing_if = "Option::is_none")] + pub merge_ready: Option, } /// Single commit in a worktree's diff against its base. @@ -131,6 +223,7 @@ pub async fn smartlog( if !no_overlay { attach_pr_overlay(repo, &config, &mut nodes).await; + attach_ci_overlay(repo, &config, &mut nodes).await; } let color = color_enabled(); @@ -139,7 +232,7 @@ pub async fn smartlog( println!("{}", serde_json::to_string_pretty(&nodes)?); } _ => { - print!("{}", render_text(&nodes, color)); + print!("{}", render_text(&nodes, color, !no_overlay)); } } Ok(()) @@ -181,6 +274,98 @@ async fn attach_pr_overlay(repo: &Path, config: &ParsecConfig, nodes: &mut [Smar } } +/// Fetch GitHub check-run aggregates for each PR-linked node and populate +/// `node.ci` with a typed [`SmartlogCiOverlay`]. +/// +/// Mirrors the soft-fail pattern of [`attach_pr_overlay`]: network errors are +/// logged to stderr but never fail the parent command. Nodes without a PR +/// overlay are skipped silently. +async fn attach_ci_overlay(repo: &Path, config: &ParsecConfig, nodes: &mut [SmartlogNode]) { + // Always init the client: Phase 3 (#310) needs it for PR-less nodes too. + let remote_url = match git::run_output(repo, &["remote", "get-url", "origin"]) { + Ok(url) => url.trim().to_string(), + Err(_) => return, + }; + let client = match GitHubClient::new(&remote_url, config) { + Ok(Some(c)) => c, + _ => return, + }; + + for node in nodes.iter_mut() { + let ci_status_result = if let Some(pr) = &node.pr { + // PR-linked node: fetch check-runs via the existing PR-based lookup + // (which resolves the PR head SHA internally). + client.get_check_runs(pr.number).await + } else { + // Phase 3 (#310): PR-less branch — resolve branch-tip SHA with git + // and fetch check-runs directly. Skip if git lookup fails (e.g. + // orphan branch or remote not synced). + match resolve_branch_tip_sha(repo, &node.branch) { + Some(sha) => client.get_check_runs_by_sha(&sha).await, + None => continue, + } + }; + + match ci_status_result { + Ok(ci_status) => { + let overall = map_ci_overall(&ci_status.overall); + let total = ci_status.checks.len(); + let failed = ci_status + .checks + .iter() + .filter(|c| { + matches!(c.conclusion.as_deref(), Some("failure") | Some("timed_out")) + }) + .count(); + // Phase 2 (#310): count checks still actively running or + // waiting in the queue so the badge can display an accurate + // "N running / M" count instead of the previous "(0/N)". + let running = ci_status + .checks + .iter() + .filter(|c| matches!(c.status.as_str(), "in_progress" | "queued")) + .count(); + node.ci = Some(SmartlogCiOverlay { + overall, + total, + failed, + running, + }); + } + Err(e) => { + eprintln!( + "smartlog: CI overlay failed for {} ({}): {}", + node.ticket, node.branch, e + ); + } + } + } +} + +/// Resolve a branch to its tip commit SHA using `git rev-parse`. +/// +/// Returns the full 40-character SHA, or `None` when the branch cannot be +/// resolved (e.g., unborn, orphan, or not yet pushed to remote). +fn resolve_branch_tip_sha(repo: &Path, branch: &str) -> Option { + let sha = git::run_output(repo, &["rev-parse", branch]).ok()?; + let sha = sha.trim().to_string(); + if sha.is_empty() || sha.len() < 7 { + return None; + } + Some(sha) +} + +/// Map the raw `CiStatus.overall` string from [`GitHubClient::get_check_runs`] +/// to the four canonical `SmartlogCiOverlay.overall` values. +fn map_ci_overall(raw: &str) -> String { + match raw { + "passing" => "passed".to_string(), + "failing" => "failed".to_string(), + "pending" => "running".to_string(), + _ => "none".to_string(), // "no checks" or anything unexpected + } +} + /// Resolve a single branch to a [`SmartlogPrOverlay`], or `None` if no open PR. async fn fetch_overlay(client: &GitHubClient, branch: &str) -> Result> { let pr_num = match client.find_pr_by_branch(branch).await? { @@ -188,12 +373,20 @@ async fn fetch_overlay(client: &GitHubClient, branch: &str) -> Result return Ok(None), }; let status = client.get_pr_status(pr_num).await?; + // Populate merge_ready only for open PRs; merged/closed PRs don't have a + // meaningful "can be merged" state from GitHub's perspective. + let merge_ready = if status.state == "open" { + status.mergeable + } else { + None + }; Ok(Some(SmartlogPrOverlay { number: status.number, state: status.state, ci_status: status.ci_status, review_status: status.review_status, url: status.url, + merge_ready, })) } @@ -267,9 +460,47 @@ fn format_pr_badge(pr: &SmartlogPrOverlay, color: bool) -> String { out.pop(); out.push_str(&format!(" {}]", r)); } + // Phase 5: merge readiness segment — only for open PRs. + if pr.state == "open" { + if let Some(ready) = pr.merge_ready { + let segment = if ready { + ansi_wrap(32, "⬆ ready", color) // green + } else { + ansi_wrap(31, "⚡ conflicts", color) // red + }; + out.pop(); // remove ']' + out.push_str(&format!(" {}]", segment)); + } + } out } +/// Format a one-line CI check-run summary badge for the smartlog ASCII tree. +/// +/// Examples (no color): +/// - `[CI: ✓ passed (7/7)]` +/// - `[CI: ✗ failed (2/7)]` +/// - `[CI: ● running (0/5)]` +/// - `[CI: none]` +fn format_ci_badge(ci: &SmartlogCiOverlay, color: bool) -> String { + let label = match ci.overall.as_str() { + "passed" => ansi_wrap( + 32, + &format!("✓ passed ({}/{})", ci.total - ci.failed, ci.total), + color, + ), + "failed" => ansi_wrap(31, &format!("✗ failed ({}/{})", ci.failed, ci.total), color), + // Phase 2 (#310): show accurate running count instead of hardcoded 0. + "running" => ansi_wrap( + 33, + &format!("● running ({} running/{})", ci.running, ci.total), + color, + ), + _ => "none".to_string(), + }; + format!("[CI: {}]", label) +} + /// Parse a single tab-separated line emitted by our `git log --pretty` format. /// /// Format: `\t\t\t`. @@ -303,7 +534,12 @@ fn parse_commit_line(line: &str) -> Option { /// /// `color` enables ANSI escape codes in the PR/CI badge. Pass `false` in tests /// or when `NO_COLOR` is set to keep output predictable. -pub fn render_text(nodes: &[SmartlogNode], color: bool) -> String { +/// +/// `overlay_requested` mirrors the CLI `!no_overlay` flag. When `true` and a +/// worktree has no associated PR, a `├─ (no PR)` line is emitted so the user +/// can see we looked but found nothing (Phase 6, issue #309). Pass `false` in +/// tests that don't exercise overlay behaviour to keep golden output stable. +pub fn render_text(nodes: &[SmartlogNode], color: bool, overlay_requested: bool) -> String { if nodes.is_empty() { return "No active worktrees. Run `parsec start ` to create one.\n".to_string(); } @@ -319,15 +555,27 @@ pub fn render_text(nodes: &[SmartlogNode], color: bool) -> String { by_base.entry(n.base_branch.clone()).or_default().push(n); } - let mut out = String::new(); - let base_count = by_base.len(); - for (base_idx, (base, group)) in by_base.iter().enumerate() { - // Phase 3: if the base branch is itself a worktree branch, mark it as - // a stacked group rather than a plain base label. + // Phase 4: summary header. + let stacked_count = nodes + .iter() + .filter(|n| branch_to_ticket.contains_key(n.base_branch.as_str())) + .count(); + let mut out = format!( + "smartlog {} worktree{} · {} stacked\n", + nodes.len(), + if nodes.len() == 1 { "" } else { "s" }, + stacked_count, + ); + + // Phase 4: topological ordering — stacked groups follow their parent. + let ordered = topo_sort_groups(&by_base, &branch_to_ticket); + for (base, group) in &ordered { + // Phase 3/4: if the base branch is itself a worktree branch, mark it as + // a stacked group and show its parent ticket with ⤷ arrow. if let Some(parent_ticket) = branch_to_ticket.get(base.as_str()) { - out.push_str(&format!("○ {} (stacked on {})\n", base, parent_ticket)); + out.push_str(&format!("\n○ {} ⤷ stacked on {}\n", base, parent_ticket)); } else { - out.push_str(&format!("○ {} (base)\n", base)); + out.push_str(&format!("\n○ {} (base)\n", base)); } let last_idx = group.len().saturating_sub(1); for (i, node) in group.iter().enumerate() { @@ -343,8 +591,16 @@ pub fn render_text(nodes: &[SmartlogNode], color: bool) -> String { let prefix = if is_last { " " } else { "│ " }; // PR overlay (Phase 2): one line above commits when overlay set. // Phase 3: badge is now optionally colorized. + // Phase 6 (#309): when overlay was requested but no PR was found, + // emit "(no PR)" so the user knows we checked and found nothing. if let Some(pr) = &node.pr { out.push_str(&format!("{}├─ {}\n", prefix, format_pr_badge(pr, color))); + } else if overlay_requested { + out.push_str(&format!("{}├─ (no PR)\n", prefix)); + } + // CI overlay (Phase 1 of #310): check-run counts line. + if let Some(ci) = &node.ci { + out.push_str(&format!("{}├─ {}\n", prefix, format_ci_badge(ci, color))); } if node.commits.is_empty() { out.push_str(&format!("{}└─ (no commits since {})\n", prefix, base)); @@ -359,13 +615,68 @@ pub fn render_text(nodes: &[SmartlogNode], color: bool) -> String { } } } - if base_idx + 1 < base_count { - out.push('\n'); - } } out } +// --------------------------------------------------------------------------- +// Phase 4: topological DAG group ordering +// --------------------------------------------------------------------------- + +/// Sort `by_base` groups so that stacked groups appear immediately after the +/// group containing their parent worktree. +/// +/// Algorithm: +/// 1. Emit root bases (bases not matching any worktree branch) first, in +/// alphabetical order. +/// 2. After each group, immediately emit any stacked group whose base equals +/// one of the branches in the just-emitted group. +/// 3. Any remaining groups (e.g., whose parent was filtered out) are appended +/// at the end, also in alphabetical order. +/// +/// A `placed` set prevents infinite loops when an unusual worktree graph has +/// a cycle in its stacking relationships. +fn topo_sort_groups<'a>( + by_base: &'a BTreeMap>, + branch_to_ticket: &HashMap<&str, &str>, +) -> Vec<(&'a String, &'a Vec<&'a SmartlogNode>)> { + let mut ordered: Vec<(&'a String, &'a Vec<&'a SmartlogNode>)> = Vec::new(); + let mut placed: HashSet<&str> = HashSet::new(); + + fn visit<'a>( + base: &str, + by_base: &'a BTreeMap>, + placed: &mut HashSet<&'a str>, + ordered: &mut Vec<(&'a String, &'a Vec<&'a SmartlogNode>)>, + ) { + if placed.contains(base) { + return; + } + if let Some((key, group)) = by_base.get_key_value(base) { + placed.insert(key.as_str()); + ordered.push((key, group)); + // Recurse: for each node in this group, check if any by_base entry + // has that node's branch as its base. + for node in group { + visit(node.branch.as_str(), by_base, placed, ordered); + } + } + } + + // First pass: process root bases (those whose base key is NOT a worktree + // branch). BTreeMap iteration is alphabetical, giving a stable order. + for base in by_base.keys() { + if !branch_to_ticket.contains_key(base.as_str()) { + visit(base, by_base, &mut placed, &mut ordered); + } + } + // Second pass: any groups not yet placed (parents filtered out, orphans). + for base in by_base.keys() { + visit(base, by_base, &mut placed, &mut ordered); + } + ordered +} + // --------------------------------------------------------------------------- // Phase 3 helpers: color support // --------------------------------------------------------------------------- @@ -466,7 +777,7 @@ mod tests { #[test] fn render_text_empty() { - let s = render_text(&[], false); + let s = render_text(&[], false, false); assert!(s.contains("No active worktrees")); } @@ -478,7 +789,7 @@ mod tests { "feature/CL-2283", vec![mk_commit("a1b2c3d", "Implement rate limiter")], )]; - let s = render_text(&nodes, false); + let s = render_text(&nodes, false, false); assert!(s.contains("○ main (base)")); assert!(s.contains("CL-2283")); assert!(s.contains("Add rate limiting")); @@ -489,7 +800,7 @@ mod tests { #[test] fn render_text_no_commits_shows_placeholder() { let nodes = vec![mk_node("CL-2291", None, "scratch/CL-2291", vec![])]; - let s = render_text(&nodes, false); + let s = render_text(&nodes, false, false); assert!(s.contains("(no commits since main)")); assert!(s.contains("(no title)")); } @@ -510,7 +821,7 @@ mod tests { vec![mk_commit("bbbbbbb", "second commit")], ); b.base_branch = "develop".to_string(); - let s = render_text(&[a, b], false); + let s = render_text(&[a, b], false, false); assert!(s.contains("○ main (base)")); assert!(s.contains("○ develop (base)")); // Both nodes should render their commits. @@ -536,6 +847,23 @@ mod tests { ci_status: ci.to_string(), review_status: review.to_string(), url: "https://github.com/erishforG/git-parsec/pull/42".to_string(), + merge_ready: None, + } + } + + fn mk_overlay_with_readiness( + state: &str, + ci: &str, + review: &str, + merge_ready: Option, + ) -> SmartlogPrOverlay { + SmartlogPrOverlay { + number: 99, + state: state.to_string(), + ci_status: ci.to_string(), + review_status: review.to_string(), + url: "https://github.com/erishforG/git-parsec/pull/99".to_string(), + merge_ready, } } @@ -570,7 +898,7 @@ mod tests { vec![mk_commit("a1b2c3d", "Implement rate limiter")], ); node.pr = Some(mk_overlay("open", "success", "approved")); - let s = render_text(&[node], false); + let s = render_text(&[node], false, false); assert!(s.contains("CL-2283"), "ticket line still present"); assert!(s.contains("[PR #42"), "PR badge rendered"); assert!(s.contains("✓ approved"), "review badge rendered"); @@ -600,8 +928,12 @@ mod tests { pr.get("review_status").and_then(|s| s.as_str()), Some("approved") ); - // ci field still omitted — Phase 2 folds CI into the overlay. - assert!(v.get("ci").is_none(), "ci field stays omitted in Phase 2"); + // ci field omitted — attach_ci_overlay was not called on this manually + // constructed node (ci is only populated by the async overlay function). + assert!( + v.get("ci").is_none(), + "ci field stays None for manually-built node" + ); } // ----------------------------------------------------------------------- @@ -653,7 +985,7 @@ mod tests { child.base_branch = "feat/PROJ-1".to_string(); // base = parent's branch let nodes = vec![parent, child]; - let s = render_text(&nodes, false); + let s = render_text(&nodes, false, false); assert!( s.contains("stacked on PROJ-1"), "stack indicator missing in:\n{}", @@ -688,4 +1020,527 @@ mod tests { badge ); } + + // ----------------------------------------------------------------------- + // Phase 4 tests: topological ordering + summary header + // ----------------------------------------------------------------------- + + #[test] + fn summary_header_appears_with_counts() { + let nodes = vec![ + mk_node("PROJ-1", Some("A"), "feat/PROJ-1", vec![]), + mk_node("PROJ-2", Some("B"), "feat/PROJ-2", vec![]), + ]; + let s = render_text(&nodes, false, false); + // Summary header must be present and contain worktree count. + assert!(s.starts_with("smartlog "), "summary header missing"); + assert!(s.contains("2 worktrees"), "worktree count wrong: {}", s); + assert!(s.contains("0 stacked"), "stacked count wrong: {}", s); + } + + #[test] + fn summary_header_singular_worktree() { + let nodes = vec![mk_node("CL-1", Some("A"), "feat/CL-1", vec![])]; + let s = render_text(&nodes, false, false); + assert!(s.contains("1 worktree ·"), "singular form wrong: {}", s); + } + + #[test] + fn topo_sort_stacked_group_follows_parent() { + // PROJ-2 stacks on PROJ-1. In alphabetical order PROJ-1 < PROJ-2 + // so both orderings happen to agree; use a lexicographically-later + // parent name to exercise the non-trivial case. + let parent = mk_node("PROJ-Z", Some("Parent"), "feat/PROJ-Z", vec![]); + let mut child = mk_node("PROJ-A", Some("Child"), "feat/PROJ-A", vec![]); + child.base_branch = "feat/PROJ-Z".to_string(); + + // Alphabetically PROJ-A's base (feat/PROJ-A) would come before + // feat/PROJ-Z, but with topo sort the child group should appear + // directly after the parent group in the rendered output. + let nodes = vec![parent, child]; + let s = render_text(&nodes, false, false); + + // Both groups must appear. + assert!(s.contains("PROJ-Z"), "parent not rendered"); + assert!(s.contains("PROJ-A"), "child not rendered"); + + // The child's stacked header must appear AFTER the parent section. + let parent_pos = s.find("PROJ-Z").unwrap(); + let child_stack_pos = s.find("⤷ stacked on PROJ-Z").unwrap(); + assert!( + parent_pos < child_stack_pos, + "child group should follow parent group; got:\n{}", + s + ); + + // Stacked count should be 1. + assert!(s.contains("1 stacked"), "stacked count wrong: {}", s); + } + + #[test] + fn topo_sort_multi_level_stack_order() { + // grandparent → parent → child (three levels) + let gp = mk_node("GP", Some("Grandparent"), "feat/GP", vec![]); + let mut parent = mk_node("PA", Some("Parent"), "feat/PA", vec![]); + parent.base_branch = "feat/GP".to_string(); + let mut child = mk_node("CH", Some("Child"), "feat/CH", vec![]); + child.base_branch = "feat/PA".to_string(); + + let nodes = vec![gp, parent, child]; + let s = render_text(&nodes, false, false); + + // GP sits on "main" (the mk_node default base), so the root label is + // "main (base)", not "GP (base)". PA and CH are stacked and use the + // ⤷ arrow with their parent's ticket name. + let gp_pos = s.find("main (base)").unwrap(); + let pa_pos = s.find("⤷ stacked on GP").unwrap(); + let ch_pos = s.find("⤷ stacked on PA").unwrap(); + assert!(gp_pos < pa_pos, "parent should follow grandparent"); + assert!(pa_pos < ch_pos, "child should follow parent"); + assert!(s.contains("2 stacked"), "stacked count wrong: {}", s); + } + + #[test] + fn topo_sort_stable_for_independent_roots() { + // Two independent root bases: `develop` and `main`. + // BTreeMap alphabetical order: develop < main. + let a = mk_node("CL-A", Some("A"), "feat/CL-A", vec![]); + let mut b = mk_node("CL-B", Some("B"), "feat/CL-B", vec![]); + b.base_branch = "develop".to_string(); + + let nodes = vec![a, b]; + let s = render_text(&nodes, false, false); + // Both roots (main, develop) should appear in the output. + assert!(s.contains("main (base)") || s.contains("develop (base)")); + assert!(s.contains("CL-A") && s.contains("CL-B")); + assert!(s.contains("0 stacked"), "no stacks expected"); + } + + // ----------------------------------------------------------------------- + // Phase 5 tests: merge readiness overlay (#309) + // ----------------------------------------------------------------------- + + #[test] + fn merge_ready_true_appends_ready_segment() { + let overlay = mk_overlay_with_readiness("open", "success", "approved", Some(true)); + let badge = format_pr_badge(&overlay, false); + assert!( + badge.contains("⬆ ready"), + "expected '⬆ ready' in badge but got: {}", + badge + ); + assert!( + badge.ends_with("⬆ ready]"), + "ready segment should be last: {}", + badge + ); + } + + #[test] + fn merge_ready_false_appends_conflicts_segment() { + let overlay = mk_overlay_with_readiness("open", "success", "approved", Some(false)); + let badge = format_pr_badge(&overlay, false); + assert!( + badge.contains("⚡ conflicts"), + "expected '⚡ conflicts' in badge but got: {}", + badge + ); + } + + #[test] + fn merge_ready_none_omits_readiness_segment() { + let overlay = mk_overlay_with_readiness("open", "success", "no reviews", None); + let badge = format_pr_badge(&overlay, false); + assert!( + !badge.contains("ready") && !badge.contains("conflicts"), + "unknown readiness should omit segment: {}", + badge + ); + } + + #[test] + fn merge_ready_skipped_for_merged_pr() { + // A merged PR with merge_ready=Some(true) should NOT show the segment + // because the PR is no longer open. + let overlay = mk_overlay_with_readiness("merged", "success", "approved", Some(true)); + let badge = format_pr_badge(&overlay, false); + assert!( + !badge.contains("⬆ ready"), + "merged PR should not show ready segment: {}", + badge + ); + } + + #[test] + fn merge_ready_skipped_for_draft_pr() { + // Draft PRs are open but merge_ready is typically None from GitHub; + // even if Some(true) somehow arrives, verify the format is correct. + let overlay = mk_overlay_with_readiness("draft", "pending", "no reviews", Some(false)); + let badge = format_pr_badge(&overlay, false); + // draft is rendered as state="draft"; open check uses pr.state == "open" + // so draft should NOT emit the readiness segment. + assert!( + !badge.contains("⚡ conflicts"), + "draft PR should not show conflicts segment: {}", + badge + ); + } + + #[test] + fn merge_ready_serde_roundtrip_omits_none() { + // When merge_ready is None, the serialised JSON must not contain + // the field (backward-compat for existing JSON consumers). + let overlay = mk_overlay("open", "pending", "no reviews"); + assert!(overlay.merge_ready.is_none()); + let json = serde_json::to_string(&overlay).expect("serialize"); + assert!( + !json.contains("merge_ready"), + "merge_ready=None should be omitted from JSON, got: {}", + json + ); + } + + #[test] + fn merge_ready_serde_roundtrip_present() { + // When merge_ready is Some, it must survive a JSON round-trip. + let overlay = mk_overlay_with_readiness("open", "success", "approved", Some(true)); + let json = serde_json::to_string(&overlay).expect("serialize"); + assert!( + json.contains("\"merge_ready\":true"), + "missing field: {}", + json + ); + let back: SmartlogPrOverlay = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(back.merge_ready, Some(true)); + } + + // ----------------------------------------------------------------------- + // Phase 1 + Phase 2 tests: SmartlogCiOverlay (#310) + // ----------------------------------------------------------------------- + + /// Construct a `SmartlogCiOverlay` with `running` defaulting to 0 + /// (mirrors Phase-1 era callers; Phase-2 tests use `mk_ci_running`). + fn mk_ci(overall: &str, total: usize, failed: usize) -> SmartlogCiOverlay { + SmartlogCiOverlay { + overall: overall.to_string(), + total, + failed, + running: 0, + } + } + + /// Construct a `SmartlogCiOverlay` with an explicit `running` count + /// (Phase 2 of #310). + fn mk_ci_running( + overall: &str, + total: usize, + failed: usize, + running: usize, + ) -> SmartlogCiOverlay { + SmartlogCiOverlay { + overall: overall.to_string(), + total, + failed, + running, + } + } + + #[test] + fn ci_badge_passed_shows_count() { + let ci = mk_ci("passed", 7, 0); + let badge = format_ci_badge(&ci, false); + assert_eq!(badge, "[CI: ✓ passed (7/7)]"); + } + + #[test] + fn ci_badge_failed_shows_failure_count() { + let ci = mk_ci("failed", 7, 2); + let badge = format_ci_badge(&ci, false); + assert_eq!(badge, "[CI: ✗ failed (2/7)]"); + } + + // Phase 2: running badge now shows accurate running count, not hardcoded 0. + #[test] + fn ci_badge_running_shows_running_count() { + let ci = mk_ci_running("running", 5, 0, 3); + let badge = format_ci_badge(&ci, false); + assert_eq!(badge, "[CI: ● running (3 running/5)]"); + } + + #[test] + fn ci_badge_running_zero_running_shows_zero() { + // Edge case: overall=running but running count is 0 (queued or unusual state). + let ci = mk_ci_running("running", 4, 0, 0); + let badge = format_ci_badge(&ci, false); + assert_eq!(badge, "[CI: ● running (0 running/4)]"); + } + + #[test] + fn ci_badge_none_no_counts() { + let ci = mk_ci("none", 0, 0); + let badge = format_ci_badge(&ci, false); + assert_eq!(badge, "[CI: none]"); + } + + #[test] + fn ci_overlay_serde_roundtrip() { + let ci = mk_ci_running("failed", 7, 2, 1); + let json = serde_json::to_string(&ci).expect("serialize"); + let back: SmartlogCiOverlay = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(back, ci); + } + + // Phase 2: JSON produced before Phase 2 (no "running" key) should + // deserialize with running=0 thanks to #[serde(default)]. + #[test] + fn ci_overlay_phase1_json_backward_compat() { + let phase1_json = r#"{"overall":"running","total":5,"failed":0}"#; + let back: SmartlogCiOverlay = + serde_json::from_str(phase1_json).expect("should deserialize without running field"); + assert_eq!(back.overall, "running"); + assert_eq!(back.total, 5); + assert_eq!(back.failed, 0); + assert_eq!(back.running, 0, "missing running key must default to 0"); + } + + #[test] + fn ci_overlay_omitted_from_node_json_when_none() { + let node = mk_node("CL-5", Some("E"), "feat/CL-5", vec![]); + assert!(node.ci.is_none()); + let v: serde_json::Value = serde_json::to_value(&node).unwrap(); + assert!(v.get("ci").is_none(), "ci=None must be skip-serialized"); + } + + #[test] + fn ci_overlay_present_in_node_json_when_set() { + let mut node = mk_node("CL-6", Some("F"), "feat/CL-6", vec![]); + node.ci = Some(mk_ci_running("passed", 7, 0, 0)); + let v: serde_json::Value = serde_json::to_value(&node).unwrap(); + let ci = v.get("ci").expect("ci must serialize when Some"); + assert_eq!(ci.get("overall").and_then(|s| s.as_str()), Some("passed")); + assert_eq!(ci.get("total").and_then(|n| n.as_u64()), Some(7)); + assert_eq!(ci.get("failed").and_then(|n| n.as_u64()), Some(0)); + assert_eq!(ci.get("running").and_then(|n| n.as_u64()), Some(0)); + } + + #[test] + fn render_text_shows_ci_badge_line_when_ci_set() { + let mut node = mk_node("CL-7", Some("G"), "feat/CL-7", vec![]); + node.ci = Some(mk_ci("failed", 7, 2)); + let out = render_text(&[node], false, false); + assert!( + out.contains("[CI: ✗ failed (2/7)]"), + "expected CI badge in render_text output: {}", + out + ); + } + + // Phase 2: running badge renders correctly in the full text output. + #[test] + fn render_text_shows_running_ci_badge_with_accurate_count() { + let mut node = mk_node("CL-8", Some("H"), "feat/CL-8", vec![]); + node.ci = Some(mk_ci_running("running", 6, 0, 2)); + let out = render_text(&[node], false, false); + assert!( + out.contains("[CI: ● running (2 running/6)]"), + "expected running CI badge with accurate count in: {}", + out + ); + } + + #[test] + fn map_ci_overall_maps_all_variants() { + assert_eq!(map_ci_overall("passing"), "passed"); + assert_eq!(map_ci_overall("failing"), "failed"); + assert_eq!(map_ci_overall("pending"), "running"); + assert_eq!(map_ci_overall("no checks"), "none"); + assert_eq!(map_ci_overall("unexpected"), "none"); + } + + // ----------------------------------------------------------------------- + // Phase 3 tests: branch-tip CI overlay for PR-less nodes (#310) + // ----------------------------------------------------------------------- + + /// A node whose CI field is set from a branch-tip lookup (no PR) should + /// render the CI badge identically to a PR-linked node. This validates + /// that the `SmartlogCiOverlay` path is shared between both code paths. + #[test] + fn ci_badge_renders_same_for_pr_less_node() { + // Simulate a node that has no PR but got its CI overlay via SHA lookup. + let mut node = mk_node("CL-9", Some("I"), "feat/CL-9", vec![]); + assert!(node.pr.is_none(), "node must have no PR for this test"); + node.ci = Some(mk_ci("passed", 4, 0)); + let out = render_text(&[node], false, false); + assert!( + out.contains("[CI: ✓ passed (4/4)]"), + "PR-less node should render CI badge: {}", + out + ); + } + + /// A node with no PR and CI=none renders the `[CI: none]` badge so the + /// user can see that CI is present but no checks were found (e.g., a fresh + /// branch not yet linked to a CI pipeline). + #[test] + fn ci_badge_none_renders_for_pr_less_node() { + let mut node = mk_node("CL-10", Some("J"), "feat/CL-10", vec![]); + node.ci = Some(mk_ci("none", 0, 0)); + let out = render_text(&[node], false, false); + assert!( + out.contains("[CI: none]"), + "PR-less node with no checks should render [CI: none]: {}", + out + ); + } + + /// `resolve_branch_tip_sha` must return `None` for obviously invalid inputs + /// without panicking (e.g., empty string from a malformed git command). + #[test] + fn resolve_branch_tip_sha_rejects_empty() { + use std::path::Path; + // A path that is definitely not a git repo will cause `git rev-parse` + // to fail, which should return None rather than panic. + let result = resolve_branch_tip_sha(Path::new("/tmp"), "non-existent-branch"); + assert!( + result.is_none(), + "non-git-repo path should return None, got: {:?}", + result + ); + } + + // ----------------------------------------------------------------------- + // Phase 6 tests: (no PR) display when overlay requested (#309 final) + // ----------------------------------------------------------------------- + + /// When GitHub overlay was requested and a worktree has no open PR, + /// `render_text` must emit a `├─ (no PR)` line so users see we looked. + #[test] + fn no_pr_line_shown_when_overlay_requested() { + let node = mk_node("CL-20", Some("Branch without PR"), "feat/CL-20", vec![]); + assert!(node.pr.is_none(), "node must not have a PR for this test"); + // overlay_requested = true → we attempted the lookup and found nothing. + let out = render_text(&[node], false, true); + assert!( + out.contains("(no PR)"), + "expected '(no PR)' line when overlay requested but no PR found: {}", + out + ); + } + + /// When `--no-overlay` is active (`overlay_requested = false`), the + /// `(no PR)` line must be suppressed — we never even called GitHub. + #[test] + fn no_pr_line_suppressed_when_overlay_not_requested() { + let node = mk_node("CL-21", Some("Offline node"), "feat/CL-21", vec![]); + assert!(node.pr.is_none()); + // overlay_requested = false → ––no-overlay mode; do not show (no PR). + let out = render_text(&[node], false, false); + assert!( + !out.contains("(no PR)"), + "(no PR) must be absent when overlay is disabled: {}", + out + ); + } + + /// A node with CI overlay (from branch-tip SHA lookup) but no PR should + /// show `(no PR)` above the CI badge when overlay was requested. + #[test] + fn no_pr_line_plus_ci_badge_for_pr_less_node_with_ci() { + let mut node = mk_node("CL-22", Some("CI only node"), "feat/CL-22", vec![]); + node.ci = Some(mk_ci("passed", 4, 0)); + assert!(node.pr.is_none()); + let out = render_text(&[node], false, true); + assert!( + out.contains("(no PR)"), + "(no PR) must appear even when CI overlay is set: {}", + out + ); + assert!( + out.contains("[CI: ✓ passed (4/4)]"), + "CI badge must still render: {}", + out + ); + // (no PR) must come before CI badge in the output string. + let no_pr_pos = out.find("(no PR)").unwrap(); + let ci_pos = out.find("[CI:").unwrap(); + assert!( + no_pr_pos < ci_pos, + "(no PR) must appear before CI badge:\n{}", + out + ); + } + + /// A node WITH a PR must not render the `(no PR)` line even when + /// `overlay_requested = true`. + #[test] + fn no_pr_line_absent_when_pr_overlay_is_set() { + let mut node = mk_node("CL-23", Some("Open PR"), "feat/CL-23", vec![]); + node.pr = Some(mk_overlay("open", "success", "approved")); + let out = render_text(&[node], false, true); + assert!( + !out.contains("(no PR)"), + "(no PR) must not appear when PR overlay is present: {}", + out + ); + assert!(out.contains("[PR #42"), "PR badge must appear: {}", out); + } + + // ----------------------------------------------------------------------- + // Phase 4 / #308 snapshot test: full golden-output comparison + // (Substitutes for an `insta` snapshot dep — same regression coverage + // without adding a new dev-dependency.) + // ----------------------------------------------------------------------- + + /// Full ASCII snapshot of a single worktree with a PR overlay and one + /// commit. This acts as the golden-output regression test required by + /// issue #308 acceptance criteria ("snapshot test >=1"). + #[test] + fn render_text_full_snapshot_single_node_with_pr_and_commit() { + let mut node = mk_node( + "CL-42", + Some("Add feature"), + "feat/CL-42", + vec![mk_commit("abc1234", "Implement feature")], + ); + node.pr = Some(mk_overlay("open", "success", "approved")); + // overlay_requested = true (we did ask GitHub; a PR was found) + let out = render_text(&[node], false, true); + // Build the expected output using the same Unicode glyphs as render_text. + let mut expected = String::new(); + expected.push_str("smartlog 1 worktree \u{00b7} 0 stacked\n"); + expected.push('\n'); + expected.push_str("\u{25cb} main (base)\n"); + expected.push_str("\u{2502}\n"); + expected.push_str("\u{2514}\u{2500}\u{25cf} CL-42 Add feature [feat/CL-42]\n"); + expected + .push_str(" \u{251c}\u{2500} [PR #42 \u{25cf} open \u{2713} CI \u{2713} approved]\n"); + expected.push_str(" \u{2514}\u{2500} abc1234 Implement feature\n"); + assert_eq!( + out, expected, + "snapshot mismatch \u{2014} golden output changed; update test if intentional.\nGOT:\n{}", + out + ); + } + + /// Full ASCII snapshot for a single worktree with NO PR and NO commits, + /// with `overlay_requested = true`. Ensures the `(no PR)` + empty-commit + /// placeholder are rendered in the correct order. + #[test] + fn render_text_full_snapshot_no_pr_no_commits() { + let node = mk_node("CL-43", Some("Empty branch"), "feat/CL-43", vec![]); + let out = render_text(&[node], false, true); + let mut expected = String::new(); + expected.push_str("smartlog 1 worktree \u{00b7} 0 stacked\n"); + expected.push('\n'); + expected.push_str("\u{25cb} main (base)\n"); + expected.push_str("\u{2502}\n"); + expected.push_str("\u{2514}\u{2500}\u{25cf} CL-43 Empty branch [feat/CL-43]\n"); + expected.push_str(" \u{251c}\u{2500} (no PR)\n"); + expected.push_str(" \u{2514}\u{2500} (no commits since main)\n"); + assert_eq!( + out, expected, + "snapshot mismatch \u{2014} golden output changed; update test if intentional.\nGOT:\n{}", + out + ); + } } diff --git a/src/cli/commands/stack.rs b/src/cli/commands/stack.rs index f8e2d40..631f7f0 100644 --- a/src/cli/commands/stack.rs +++ b/src/cli/commands/stack.rs @@ -229,6 +229,8 @@ pub async fn stack_submit(repo: &Path, mode: Mode) -> Result<()> { Vec::new(), // reviewers Vec::new(), // labels None, // template + false, // ai_description + false, // no_cleanup: use config default during stack ship mode, ) .await diff --git a/src/cli/commands/tracker_cmds.rs b/src/cli/commands/tracker_cmds.rs index 2223fda..750a9b0 100644 --- a/src/cli/commands/tracker_cmds.rs +++ b/src/cli/commands/tracker_cmds.rs @@ -115,6 +115,9 @@ pub async fn inbox(repo: &Path, pick: bool, mode: Mode) -> Result<()> { .iter() .map(|t| format!("{} — {} [{}]", t.key, t.summary, t.priority)) .collect(); + if crate::env::is_agent() { + anyhow::bail!("Interactive ticket picker is not available in agent mode. Use `parsec inbox` without --pick, then `parsec start `."); + } let selection = dialoguer::Select::new() .with_prompt("Pick a ticket to start") .items(&items) diff --git a/src/cli/commands/triage.rs b/src/cli/commands/triage.rs new file mode 100644 index 0000000..11129ba --- /dev/null +++ b/src/cli/commands/triage.rs @@ -0,0 +1,373 @@ +//! `parsec triage` — rule-based issue auto-labelling (#302). +//! +//! - Load `[[triage.rules]]` from parsec config. +//! - Fetch open GitHub issues for the repo. +//! - Apply each rule (case-insensitive substring match on title). +//! - Print a table: issue #, title, matched rule, proposed labels, trust score. +//! - Write proposed labels only when `--apply` is explicitly passed. +//! +//! Trust-score logic: +//! - First (highest-priority) rule to match → **1.0** +//! - A second rule also matches the same issue → **0.7** +//! - Three or more rules match → **0.5** + +use std::path::Path; + +use anyhow::{Context, Result}; +use colored::Colorize; +use tabled::{settings::Style, Table, Tabled}; + +use crate::config::{ParsecConfig, TriageRule}; +use crate::git; +use crate::github::GitHubClient; +use crate::output::Mode; + +// --------------------------------------------------------------------------- +// Public entry point +// --------------------------------------------------------------------------- + +/// Entry point for `parsec triage`. +/// +/// Fetches open issues, applies the configured rules, and prints the proposed +/// labels. Labels are written only when `apply` is true. +pub async fn triage(repo: &Path, limit: u8, apply: bool, mode: Mode) -> Result<()> { + let config = ParsecConfig::load()?; + + if config.triage.rules.is_empty() { + match mode { + Mode::Json => println!("[]"), + Mode::Quiet => {} + Mode::Human => { + println!("{}", "No triage rules configured.".yellow()); + println!(); + println!("Add rules to your parsec config:"); + println!(); + println!(" [[triage.rules]]"); + println!(" pattern = \"feat\""); + println!(" label = \"type/feature\""); + println!(); + println!(" [[triage.rules]]"); + println!(" pattern = \"fix\""); + println!(" label = \"type/bug\""); + println!(" priority = \"priority/high\""); + } + } + return Ok(()); + } + + let remote_url = git::get_remote_url(repo).unwrap_or_default(); + let gh = match GitHubClient::new(&remote_url, &config)? { + Some(c) => c, + None => { + anyhow::bail!( + "no GitHub token found\n\ + caused by: GITHUB_TOKEN not set and no token in parsec config\n\ + help: run `gh auth login` or set GITHUB_TOKEN= in your environment" + ); + } + }; + + let issues = gh.list_open_issues(limit).await?; + + if issues.is_empty() { + match mode { + Mode::Json => println!("[]"), + Mode::Quiet => {} + Mode::Human => println!("No open issues found."), + } + return Ok(()); + } + + let entries: Vec = issues + .iter() + .filter_map(|(number, title, existing_labels)| { + build_entry(*number, title, existing_labels, &config.triage.rules) + }) + .collect(); + + if entries.is_empty() { + match mode { + Mode::Json => println!("[]"), + Mode::Quiet => {} + Mode::Human => println!( + "No issues matched any triage rule (checked {}).", + issues.len() + ), + } + return Ok(()); + } + + if apply { + for entry in &entries { + if !entry.labels.is_empty() { + gh.add_labels(entry.number, &entry.labels) + .await + .with_context(|| { + format!("failed to apply labels to issue #{}", entry.number) + })?; + } + } + } + + match mode { + Mode::Json => { + println!("{}", serde_json::to_string_pretty(&entries)?); + } + Mode::Quiet => { + for e in &entries { + println!("#{} → {}", e.number, e.proposed_labels); + } + } + Mode::Human => { + println!(); + println!( + "{}", + format!( + " parsec triage — {} ({} rule(s), {} issue(s) matched of {})", + if apply { "applied" } else { "dry-run" }, + config.triage.rules.len(), + entries.len(), + issues.len() + ) + .bold() + ); + println!(); + + // Build display rows + let rows: Vec = entries + .iter() + .map(|e| DisplayRow { + number: format!("#{}", e.number), + title: truncate(&e.title, 42), + matched_rule: e.matched_rule.clone(), + proposed_labels: e.proposed_labels.clone(), + trust: format!("{:.1}", e.trust_score), + }) + .collect(); + + let table = Table::new(rows).with(Style::rounded()).to_string(); + println!("{table}"); + println!(); + if !apply { + println!( + "{}", + "Tip: No labels were written. Pass --apply to apply.".dimmed() + ); + } + } + } + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/// Compute a triage entry for one issue. +/// +/// Returns `None` when no rule matches. +fn build_entry( + number: u64, + title: &str, + existing_labels: &[String], + rules: &[TriageRule], +) -> Option { + let lower = title.to_lowercase(); + let matched: Vec<&TriageRule> = rules + .iter() + .filter(|r| lower.contains(&r.pattern.to_lowercase())) + .collect(); + + if matched.is_empty() { + return None; + } + + // Trust score degrades with additional matches (first rule wins). + let trust_score: f32 = match matched.len() { + 1 => 1.0, + 2 => 0.7, + _ => 0.5, + }; + + // Collect all proposed labels (first match has highest priority). + let first = matched[0]; + let mut labels: Vec = vec![first.label.clone()]; + if let Some(ref p) = first.priority { + labels.push(p.clone()); + } + let proposed_labels = labels.join(", "); + let missing_labels: Vec = labels + .iter() + .filter(|proposed| { + !existing_labels + .iter() + .any(|existing| existing.eq_ignore_ascii_case(proposed)) + }) + .cloned() + .collect(); + + Some(TriageEntry { + number, + title: title.to_string(), + matched_rule: first.pattern.clone(), + proposed_labels, + already_labelled: missing_labels.is_empty(), + labels: missing_labels, + trust_score, + }) +} + +fn truncate(s: &str, max: usize) -> String { + let chars: Vec = s.chars().collect(); + if chars.len() <= max { + s.to_string() + } else { + format!("{}…", chars[..max - 1].iter().collect::()) + } +} + +// --------------------------------------------------------------------------- +// Data types +// --------------------------------------------------------------------------- + +/// One triage proposal — one issue matched one or more rules. +#[derive(Debug, serde::Serialize)] +pub struct TriageEntry { + pub number: u64, + pub title: String, + pub matched_rule: String, + pub proposed_labels: String, + #[serde(skip)] + labels: Vec, + pub trust_score: f32, + pub already_labelled: bool, +} + +/// Table row for `tabled` human output. +#[derive(Tabled)] +struct DisplayRow { + #[tabled(rename = "#")] + number: String, + #[tabled(rename = "Title")] + title: String, + #[tabled(rename = "Matched rule")] + matched_rule: String, + #[tabled(rename = "Proposed labels")] + proposed_labels: String, + #[tabled(rename = "Trust")] + trust: String, +} + +// --------------------------------------------------------------------------- +// Unit tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::TriageRule; + + fn rule(pattern: &str, label: &str) -> TriageRule { + TriageRule { + pattern: pattern.to_string(), + label: label.to_string(), + priority: None, + } + } + + fn rule_with_priority(pattern: &str, label: &str, priority: &str) -> TriageRule { + TriageRule { + pattern: pattern.to_string(), + label: label.to_string(), + priority: Some(priority.to_string()), + } + } + + #[test] + fn no_match_returns_none() { + let rules = vec![rule("feat", "type/feature")]; + assert!(build_entry(1, "chore: update deps", &[], &rules).is_none()); + } + + #[test] + fn single_match_trust_is_1() { + let rules = vec![rule("feat", "type/feature")]; + let e = build_entry(42, "feat: add triage command", &[], &rules).unwrap(); + assert_eq!(e.number, 42); + assert_eq!(e.matched_rule, "feat"); + assert_eq!(e.proposed_labels, "type/feature"); + assert!((e.trust_score - 1.0).abs() < f32::EPSILON); + } + + #[test] + fn two_matches_trust_is_0_7() { + let rules = vec![rule("feat", "type/feature"), rule("triage", "scope/triage")]; + let e = build_entry(1, "feat: add triage", &[], &rules).unwrap(); + assert!((e.trust_score - 0.7).abs() < f32::EPSILON); + } + + #[test] + fn three_plus_matches_trust_is_0_5() { + let rules = vec![ + rule("feat", "type/feature"), + rule("triage", "scope/triage"), + rule("add", "scope/addition"), + ]; + let e = build_entry(1, "feat: add triage support", &[], &rules).unwrap(); + assert!((e.trust_score - 0.5).abs() < f32::EPSILON); + } + + #[test] + fn priority_label_included() { + let rules = vec![rule_with_priority("fix", "type/bug", "priority/high")]; + let e = build_entry(7, "fix: critical crash", &[], &rules).unwrap(); + assert_eq!(e.proposed_labels, "type/bug, priority/high"); + } + + #[test] + fn case_insensitive_match() { + let rules = vec![rule("FEAT", "type/feature")]; + let e = build_entry(3, "feat: new feature", &[], &rules).unwrap(); + assert!(e.trust_score > 0.0); + } + + #[test] + fn truncate_long_title() { + let long = "a".repeat(50); + let out = truncate(&long, 42); + let chars: Vec = out.chars().collect(); + assert!(chars.len() <= 42); + assert!(out.ends_with('…')); + } + + #[test] + fn truncate_short_title_unchanged() { + let short = "short title"; + assert_eq!(truncate(short, 42), short); + } + + #[test] + fn already_labelled_flag() { + let rules = vec![rule("feat", "type/feature")]; + let e = build_entry(5, "feat: something", &["type/feature".to_string()], &rules).unwrap(); + assert!(e.already_labelled); + assert!(e.labels.is_empty()); + } + + #[test] + fn unrelated_existing_label_does_not_set_already_labelled() { + let rules = vec![rule("feat", "type/feature")]; + let e = build_entry(5, "feat: something", &["help wanted".to_string()], &rules).unwrap(); + assert!(!e.already_labelled); + assert_eq!(e.labels, ["type/feature"]); + } + + #[test] + fn only_missing_labels_are_applied_case_insensitively() { + let rules = vec![rule_with_priority("fix", "type/bug", "priority/high")]; + let e = build_entry(7, "fix: crash", &["TYPE/BUG".to_string()], &rules).unwrap(); + assert!(!e.already_labelled); + assert_eq!(e.labels, ["priority/high"]); + } +} diff --git a/src/cli/commands/update.rs b/src/cli/commands/update.rs new file mode 100644 index 0000000..ab9b745 --- /dev/null +++ b/src/cli/commands/update.rs @@ -0,0 +1,404 @@ +//! `parsec self-update` — check for a newer release and print upgrade instructions. +//! +//! # Phase 1 +//! Compares the running version against the latest GitHub release and prints +//! an upgrade command when a newer version is available. No binary download +//! or in-place replacement is performed. +//! +//! # Phase 2 +//! Startup version-check throttled to once every 24 h; cached in OS cache dir. +//! +//! # Phase 3 (this version) +//! - Opt-out via `[update] check_on_startup = false` in parsec config. +//! - Install-method detection: Homebrew / Cargo / prebuilt binary, each with +//! the appropriate upgrade command. + +use anyhow::Result; +use serde::{Deserialize, Serialize}; + +const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); +const GITHUB_REPO: &str = "erishforG/git-parsec"; +/// HTTP timeout for the GitHub releases API call (seconds). +const CHECK_TIMEOUT_SECS: u64 = 8; + +#[derive(Deserialize)] +struct GitHubRelease { + tag_name: String, + html_url: String, + body: Option, +} + +/// Compare two semver strings of the form `"X.Y.Z"` or `"vX.Y.Z"`. +/// +/// Each component is compared numerically so `"0.10.0"` sorts after `"0.9.0"`, +/// unlike a plain lexicographic comparison. +fn cmp_semver(a: &str, b: &str) -> std::cmp::Ordering { + let parse = |s: &str| -> (u64, u64, u64) { + let s = s.trim_start_matches('v'); + let mut it = s.splitn(3, '.').map(|p| p.parse::().unwrap_or(0)); + ( + it.next().unwrap_or(0), + it.next().unwrap_or(0), + it.next().unwrap_or(0), + ) + }; + parse(a).cmp(&parse(b)) +} + +/// Fetch the latest release metadata from the GitHub releases API. +async fn fetch_latest_release() -> anyhow::Result { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(CHECK_TIMEOUT_SECS)) + .user_agent(format!("parsec/{CURRENT_VERSION}")) + .build()?; + let url = format!("https://api.github.com/repos/{GITHUB_REPO}/releases/latest"); + let release = client + .get(&url) + .send() + .await? + .error_for_status()? + .json::() + .await?; + Ok(release) +} + +/// `parsec self-update` entry point. +/// +/// When `offline` is `true` (either `--offline` flag or config), the network +/// call is skipped and only the current version is printed. +pub async fn self_update(offline: bool) -> Result<()> { + let current = CURRENT_VERSION; + + if offline { + println!("parsec {current}"); + println!("note: version check skipped (--offline)"); + return Ok(()); + } + + use std::io::Write as _; + print!("parsec {current} → checking for updates… "); + let _ = std::io::stdout().flush(); + + match fetch_latest_release().await { + Err(e) => { + println!("(network unavailable: {e:#})"); + println!("Current version: parsec {current}"); + println!("See https://github.com/{GITHUB_REPO}/releases for the latest release."); + } + Ok(release) => { + let latest_tag = &release.tag_name; + let latest = latest_tag.trim_start_matches('v'); + match cmp_semver(latest, current) { + std::cmp::Ordering::Greater => { + println!("update available!\n"); + println!(" {current} → {latest}"); + println!(" {}", release.html_url); + // Show a brief excerpt of the release notes (up to 4 lines). + if let Some(notes) = &release.body { + let preview: String = notes.lines().take(4).collect::>().join("\n"); + if !preview.trim().is_empty() { + println!("\n Release notes (preview):"); + for line in preview.lines() { + println!(" {line}"); + } + } + } + println!("\nTo upgrade:"); + for line in upgrade_command().lines() { + println!(" {line}"); + } + } + std::cmp::Ordering::Equal => { + println!("✓ already up to date ({current})"); + } + std::cmp::Ordering::Less => { + // The user is running a dev build ahead of the published release. + println!( + "✓ {latest} is the latest published release \ + (you are ahead — development build)" + ); + } + } + } + } + Ok(()) +} + +// ── Install-method detection (Phase 3) ───────────────────────────────────── + +/// Detect how parsec was installed and return the appropriate upgrade command. +/// +/// Detection order: +/// 1. Homebrew — binary path contains `/homebrew/` or `/Cellar/`. +/// 2. Cargo — binary path contains `/.cargo/bin/`. +/// 3. Unknown / prebuilt — direct download from releases page. +pub fn upgrade_command() -> String { + let exe = std::env::current_exe() + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_default(); + + if exe.contains("/homebrew/") || exe.contains("/Cellar/") { + "brew upgrade parsec".to_string() + } else if exe.contains("/.cargo/bin/") { + format!("cargo install --git https://github.com/{GITHUB_REPO} --bin parsec --force") + } else { + format!( + "# Download the latest prebuilt binary from:\n\ + # https://github.com/{GITHUB_REPO}/releases/latest" + ) + } +} + +// ── Startup version check (Phase 2 / Phase 3) ─────────────────────────────── + +/// Cache filename stored in the OS cache directory (e.g. `~/.cache` on Linux). +const VERSION_CACHE_FILENAME: &str = ".parsec-version-check"; +/// Network timeout for a startup background check (2 s — must feel instant). +const STARTUP_CHECK_TIMEOUT_SECS: u64 = 2; + +/// Persisted state for the startup version-check throttle. +#[derive(Serialize, Deserialize, Default)] +struct VersionCheckCache { + /// Unix epoch seconds of the last check attempt (successful or not). + last_checked_secs: u64, + /// Latest release tag returned by GitHub (e.g. `"v0.5.1"`). + latest_tag: Option, +} + +fn version_cache_path() -> Option { + dirs::cache_dir().map(|d| d.join(VERSION_CACHE_FILENAME)) +} + +fn load_version_cache() -> VersionCheckCache { + version_cache_path() + .and_then(|p| std::fs::read_to_string(p).ok()) + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default() +} + +fn save_version_cache(cache: &VersionCheckCache) { + if let Some(path) = version_cache_path() { + if let Ok(json) = serde_json::to_string(cache) { + let _ = std::fs::write(path, json); + } + } +} + +fn now_secs() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +/// Returns `true` when `latest_tag` represents a version newer than the +/// running binary. Extracted as a pure function for testability. +fn should_show_update_hint(latest_tag: Option<&str>) -> bool { + latest_tag + .map(|tag| cmp_semver(tag.trim_start_matches('v'), CURRENT_VERSION)) + .map(|ord| ord == std::cmp::Ordering::Greater) + .unwrap_or(false) +} + +fn print_update_hint(latest: &str) { + eprintln!( + "\n ✦ parsec {latest} available (current: {CURRENT_VERSION})\ + \n Run `parsec self-update` for upgrade instructions.\n" + ); +} + +/// Print a one-line update hint to **stderr** if a newer release is available. +/// +/// Throttles the live GitHub API call using `check_interval_hours` (default 24 h) +/// by caching the result in [`version_cache_path()`]. Always a no-op when +/// `check_on_startup` is `false` or in offline mode; never panics. +/// +/// Should be called after the main command has finished so it does not +/// interleave with command output. Skipped in `--json` / `--quiet` mode +/// and for the `parsec self-update` command itself (see `src/cli/mod.rs`). +/// +/// # Phase 3 +/// Accepts `check_on_startup` (config opt-out) and `check_interval_hours`. +pub async fn startup_version_hint( + offline: bool, + check_on_startup: bool, + check_interval_hours: u64, +) { + if offline || !check_on_startup { + return; + } + + let throttle_secs = check_interval_hours.saturating_mul(3_600); + let mut cache = load_version_cache(); + let now = now_secs(); + let age_secs = now.saturating_sub(cache.last_checked_secs); + + if age_secs >= throttle_secs { + // Cache is stale — attempt a quick live refresh. + let client = match reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(STARTUP_CHECK_TIMEOUT_SECS)) + .user_agent(format!("parsec/{CURRENT_VERSION}")) + .build() + { + Ok(c) => c, + Err(_) => return, + }; + let url = format!("https://api.github.com/repos/{GITHUB_REPO}/releases/latest"); + match client.get(&url).send().await { + Ok(resp) => { + if let Ok(release) = resp.json::().await { + let tag = release.tag_name.clone(); + cache = VersionCheckCache { + last_checked_secs: now, + latest_tag: Some(tag.clone()), + }; + save_version_cache(&cache); + if should_show_update_hint(Some(&tag)) { + print_update_hint(tag.trim_start_matches('v')); + } + } + } + Err(_) => { + // Network unavailable — bump timestamp to avoid hammering + // the API on every run, but keep any cached latest_tag. + cache.last_checked_secs = now; + save_version_cache(&cache); + } + } + } else if let Some(ref tag) = cache.latest_tag.clone() { + // Use the cached result without a network call. + if should_show_update_hint(Some(tag)) { + print_update_hint(tag.trim_start_matches('v')); + } + } +} + +#[cfg(test)] +mod tests { + use super::{ + cmp_semver, now_secs, should_show_update_hint, VersionCheckCache, CURRENT_VERSION, + }; + use std::cmp::Ordering; + + #[test] + fn newer_patch() { + assert_eq!(cmp_semver("0.5.1", "0.5.0"), Ordering::Greater); + } + + #[test] + fn newer_minor_double_digit() { + // Numeric comparison: "0.10.0" > "0.9.0"; lexicographic would fail. + assert_eq!(cmp_semver("0.10.0", "0.9.0"), Ordering::Greater); + } + + #[test] + fn newer_major() { + assert_eq!(cmp_semver("1.0.0", "0.5.0"), Ordering::Greater); + } + + #[test] + fn equal_plain() { + assert_eq!(cmp_semver("0.5.0", "0.5.0"), Ordering::Equal); + } + + #[test] + fn v_prefix_stripped() { + assert_eq!(cmp_semver("v1.2.3", "1.2.3"), Ordering::Equal); + assert_eq!(cmp_semver("v2.0.0", "v1.9.9"), Ordering::Greater); + } + + #[test] + fn older() { + assert_eq!(cmp_semver("0.4.0", "0.5.0"), Ordering::Less); + } + + #[test] + fn multi_digit_major() { + assert_eq!(cmp_semver("10.0.0", "9.99.99"), Ordering::Greater); + } + + // ── Phase 2: startup version-check helpers ────────────────────────── + + #[test] + fn version_cache_serde_round_trip() { + let cache = VersionCheckCache { + last_checked_secs: 1_700_000_000, + latest_tag: Some("v0.5.1".into()), + }; + let json = serde_json::to_string(&cache).expect("serialize"); + let parsed: VersionCheckCache = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(parsed.last_checked_secs, 1_700_000_000); + assert_eq!(parsed.latest_tag.as_deref(), Some("v0.5.1")); + } + + #[test] + fn version_cache_default_is_stale() { + let cache = VersionCheckCache::default(); + let age = now_secs().saturating_sub(cache.last_checked_secs); + assert!( + age >= 86_400, // DEFAULT_CHECK_INTERVAL_SECS (24 h) + "default cache should be stale" + ); + } + + #[test] + fn should_show_hint_newer_version() { + // A tag strictly newer than the running CURRENT_VERSION should trigger a hint. + // We use a version guaranteed to be newer than any cargo package version. + assert!(should_show_update_hint(Some("v999.0.0"))); + } + + #[test] + fn should_show_hint_older_version() { + assert!(!should_show_update_hint(Some("v0.0.1"))); + } + + #[test] + fn should_show_hint_none() { + assert!(!should_show_update_hint(None)); + } + + #[test] + fn should_show_hint_equal_version() { + // Equal to current — no hint. + assert!(!should_show_update_hint(Some(CURRENT_VERSION))); + } + + // ── Phase 3: upgrade_command install-method detection ──────────────────────── + + #[test] + fn upgrade_command_homebrew() { + // Simulate a Homebrew-installed binary path. + // We can't actually change `current_exe`, so we test the detection + // logic indirectly by verifying it produces a non-empty string. + // A real path test would require PATH manipulation; skip for now and + // assert the fallback (cargo/prebuilt) is non-empty. + let cmd = super::upgrade_command(); + assert!( + !cmd.is_empty(), + "upgrade_command should always return a non-empty string" + ); + } + + #[test] + fn upgrade_command_contains_repo() { + // Whatever the install method, the command must reference the repo or + // releases page so the user knows where to go. + let cmd = super::upgrade_command(); + let has_ref = cmd.contains("erishforG/git-parsec") + || cmd.contains("brew upgrade") + || cmd.contains("releases"); + assert!( + has_ref, + "upgrade_command should reference the repo/releases: {cmd}" + ); + } + + #[test] + fn throttle_secs_from_hours() { + // Verify the hours-to-seconds conversion arithmetic used in startup_version_hint. + let hours: u64 = 48; + let expected_secs = 48 * 3_600u64; + assert_eq!(hours.saturating_mul(3_600), expected_secs); + } +} diff --git a/src/cli/commands/workspace.rs b/src/cli/commands/workspace.rs index 8aef3ed..f7ad873 100644 --- a/src/cli/commands/workspace.rs +++ b/src/cli/commands/workspace.rs @@ -315,7 +315,7 @@ pub async fn list(repo: &Path, no_pr: bool, full: bool, mode: Mode) -> Result<() // Fetch live PR status from GitHub if let Some(ref remote_url) = remote_url { if let Ok(Some(gh)) = github::GitHubClient::new(remote_url, &config) { - for (_ticket, (pr_num, state)) in pr_map.iter_mut() { + for (pr_num, state) in pr_map.values_mut() { if let Ok(status) = gh.get_pr_status(*pr_num).await { *state = status.state; } @@ -450,6 +450,9 @@ pub async fn switch(repo: &Path, ticket: Option<&str>, mode: Mode) -> Result<()> format!("{}{title}", w.ticket) }) .collect(); + if crate::env::is_agent() { + anyhow::bail!("Interactive workspace picker is not available in agent mode. Specify the ticket ID explicitly: `parsec switch `"); + } let selection = dialoguer::Select::new() .with_prompt("Switch to workspace") .items(&items) diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 4b21ff8..9e46f3f 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -71,6 +71,16 @@ pub enum Command { hook: Option, }, + /// Run parsec as an MCP server for AI clients. + /// + /// `parsec mcp serve` speaks newline-delimited JSON-RPC 2.0 over stdio. + /// The current phase supports initialize/tools-list transport smoke tests; + /// real tool execution is wired in a later MCP phase. + Mcp { + #[command(subcommand)] + action: McpAction, + }, + /// List all active worktrees /// /// Shows a table of all parsec-managed worktrees with ticket, branch, @@ -148,6 +158,16 @@ pub enum Command { /// Path to PR body template file #[arg(long)] template: Option, + + /// Generate PR description using AI + #[arg(long)] + ai_description: bool, + + /// Skip worktree removal after PR creation. + /// Overrides `ship.auto_cleanup = true` in config for this invocation. + /// Useful when making incremental commits to the same ticket across multiple ship calls. + #[arg(long)] + no_cleanup: bool, }, /// Remove merged or stale worktrees @@ -534,10 +554,29 @@ pub enum Command { new_ticket: String, }, + /// AI-generated commit message from staged changes + /// + /// Analyzes the staged diff and generates a commit message using an AI + /// provider (OpenAI or Anthropic). Auto-detects the ticket from the + /// current worktree and prefixes the message accordingly. + /// Use --conventional to enforce Conventional Commits format. + Commit { + /// Ticket identifier (auto-detects from current worktree if omitted) + ticket: Option, + + /// Force Conventional Commits format (type(scope): description) + #[arg(long)] + conventional: bool, + + /// Provide a manual commit message (skips AI generation) + #[arg(long, short)] + message: Option, + }, + /// Visualize active worktrees as a commit DAG (alias: sl) /// /// Lists every active worktree, the commits it adds on top of its base - /// branch, and (in later releases) PR/CI/review state. Issue #245. + /// branch, and its GitHub PR/CI/review state when available. Issue #245. #[command(alias = "sl")] Smartlog { /// Maximum commits per worktree (default: 10) @@ -641,6 +680,106 @@ pub enum Command { #[command(subcommand)] kind: CompleteKind, }, + + /// Check for a newer parsec release and print upgrade instructions. + /// + /// Queries the GitHub releases API to compare the running version with + /// the latest published release. Prints an upgrade command when a newer + /// version is available. + /// + /// Phase 1 — notification only; automatic binary replacement is Phase 2. + /// Use the global `--offline` flag to skip the network call. + SelfUpdate {}, + + /// Manage locally-saved crash reports. + /// + /// Crash reports are opt-in JSON files written by the panic hook when + /// `[crash_report] enabled = true` is set in your parsec config. + /// See `docs/crash-report.md` for the privacy policy. + /// + /// Phase 2 — list / show / clear subcommands for cache management. + CrashReport { + #[command(subcommand)] + action: CrashReportAction, + }, + + /// Save and list worktree snapshots (stash-based) + /// + /// Creates a named point-in-time snapshot of the current worktree — + /// including staged, unstaged, and untracked files — using git stash. + /// Snapshots are tagged with a `parsec-checkpoint:` prefix so they can + /// be distinguished from ordinary stash entries. + /// + /// Phase 1: create + list. Phase 2 will add restore and drop. + Checkpoint { + #[command(subcommand)] + action: CheckpointAction, + }, + + /// Rule-based issue auto-labelling (dry-run by default). + /// + /// Reads `[[triage.rules]]` from your parsec config, fetches open GitHub + /// issues, and prints a table of proposed labels with a trust score. + /// + /// Labels are only written when `--apply` is explicitly passed. + /// + /// Example config rule: + /// [[triage.rules]] + /// pattern = "feat" + /// label = "type/feature" + Triage { + /// Maximum number of open issues to inspect (default: 30). + #[arg(long, default_value = "30")] + limit: u8, + + /// Apply proposed labels to GitHub issues (default: dry-run). + #[arg(long)] + apply: bool, + }, +} + +/// Actions available under `parsec crash-report`. +#[derive(Subcommand)] +pub enum CrashReportAction { + /// List all crash reports with timestamp and panic preview. + List, + /// Show the full JSON of one crash report. + Show { + /// Report id (file stem, e.g. `crash-20260101T000000Z`) + id: String, + }, + /// Delete all crash reports from the cache directory. + Clear, +} + +/// Subcommands for `parsec checkpoint`. +#[derive(Subcommand)] +pub enum CheckpointAction { + /// Save the current worktree state as a named checkpoint + /// + /// Staged, unstaged changes, and untracked files are all captured. + /// If the working tree is clean, a friendly message is printed instead. + Create { + /// Optional name for the checkpoint (default: UTC timestamp) + name: Option, + }, + /// List all parsec-managed checkpoints in this repository + List, + /// Restore a named checkpoint back into the working tree + /// + /// Pops the matching git stash entry and removes it from the stash list. + /// Fails if the working tree is dirty (resolve conflicts first, then retry). + Restore { + /// Name of the checkpoint to restore + name: String, + }, + /// Permanently discard a named checkpoint + /// + /// Drops the matching git stash entry. This action is irreversible. + Drop { + /// Name of the checkpoint to drop + name: String, + }, } /// Candidate sets the dynamic completion subcommand can emit. @@ -652,6 +791,29 @@ pub enum CompleteKind { Branches, } +#[derive(Subcommand)] +pub enum McpAction { + /// Serve MCP JSON-RPC over stdio. + Serve, + /// Register git-parsec in a desktop MCP client config. + /// + /// Reads the target client's JSON config file (creating it if absent), + /// merges the `mcpServers.git-parsec` entry while preserving all other + /// keys, creates a timestamped backup of any existing file, then writes + /// the result. Use `--dry-run` to preview the planned JSON without + /// touching disk. + /// + /// Supported clients: `claude-desktop`, `cursor`. + Install { + /// Target MCP client (`claude-desktop` or `cursor`). + #[arg(long, value_name = "CLIENT")] + client: String, + /// Absolute path to the parsec binary (default: `parsec` on $PATH). + #[arg(long, value_name = "PATH")] + bin_path: Option, + }, +} + #[derive(Subcommand)] pub enum ConfigAction { /// Interactive configuration setup @@ -698,7 +860,7 @@ pub enum ConfigAction { pub async fn run(cli: Cli) -> Result<()> { let repo_path = cli.repo.unwrap_or_else(|| PathBuf::from(".")); - let output_mode = if cli.json { + let output_mode = if cli.json || crate::env::is_agent() { output::Mode::Json } else if cli.quiet { output::Mode::Quiet @@ -718,6 +880,7 @@ pub async fn run(cli: Cli) -> Result<()> { // Observability: extract command name and set up execution tracking let cmd_name = match &cli.command { Command::Start { .. } => "start", + Command::Mcp { .. } => "mcp", Command::List { .. } => "list", Command::Status { .. } => "status", Command::Ticket { .. } => "ticket", @@ -746,11 +909,16 @@ pub async fn run(cli: Cli) -> Result<()> { Command::Create { .. } => "create", Command::Rename { .. } => "rename", Command::Compress { .. } => "compress", + Command::Commit { .. } => "commit", Command::Smartlog { .. } => "smartlog", Command::Complete { .. } => "__complete", Command::Reviews { .. } => "reviews", Command::Dashboard { .. } => "dashboard", Command::Test { .. } => "test", + Command::SelfUpdate { .. } => "self-update", + Command::CrashReport { .. } => "crash-report", + Command::Checkpoint { .. } => "checkpoint", + Command::Triage { .. } => "triage", }; let exec_id = crate::execlog::new_execution_id(); let exec_started_at = chrono::Utc::now(); @@ -785,6 +953,13 @@ pub async fn run(cli: Cli) -> Result<()> { ) .await } + Command::Mcp { action } => match action { + McpAction::Serve => crate::mcp::serve_stdio(cli.dry_run), + McpAction::Install { client, bin_path } => { + let target: crate::mcp::install::McpClientTarget = client.parse()?; + crate::mcp::install::install(target, cli.dry_run, bin_path.as_deref()) + } + }, Command::List { no_pr, full } => commands::list(&repo_path, no_pr, full, output_mode).await, Command::Status { ticket } => { commands::status(&repo_path, ticket.as_deref(), output_mode).await @@ -802,14 +977,17 @@ pub async fn run(cli: Cli) -> Result<()> { reviewer, label, template, + ai_description, + no_cleanup, } => { if cli.dry_run { eprintln!( - "[dry-run] Would ship ticket '{}' (draft: {}, no_pr: {}, base: {})", + "[dry-run] Would ship ticket '{}' (draft: {}, no_pr: {}, base: {}, no_cleanup: {})", ticket, draft, no_pr, - base.as_deref().unwrap_or("auto") + base.as_deref().unwrap_or("auto"), + no_cleanup ); return Ok(()); } @@ -824,6 +1002,8 @@ pub async fn run(cli: Cli) -> Result<()> { reviewer, label, template, + ai_description, + no_cleanup, output_mode, ) .await @@ -1041,6 +1221,20 @@ pub async fn run(cli: Cli) -> Result<()> { Command::Compress { ticket, message } => { commands::compress(&repo_path, ticket.as_deref(), message, output_mode).await } + Command::Commit { + ticket, + conventional, + message, + } => { + commands::commit( + &repo_path, + ticket.as_deref(), + conventional, + message.as_deref(), + output_mode, + ) + .await + } Command::Smartlog { depth, no_overlay, @@ -1095,8 +1289,47 @@ pub async fn run(cli: Cli) -> Result<()> { .await } Command::Complete { kind } => commands::complete(&repo_path, kind).await, + Command::SelfUpdate {} => commands::self_update(offline).await, + Command::CrashReport { action } => match action { + CrashReportAction::List => { + commands::crash_report_list(output_mode == output::Mode::Json) + } + CrashReportAction::Show { id } => { + commands::crash_report_show(&id, output_mode == output::Mode::Json) + } + CrashReportAction::Clear => commands::crash_report_clear(cli.dry_run), + }, + Command::Checkpoint { action } => match action { + CheckpointAction::Create { name } => { + commands::checkpoint_create(&repo_path, name.as_deref(), output_mode) + } + CheckpointAction::List => commands::checkpoint_list(&repo_path, output_mode), + CheckpointAction::Restore { name } => { + commands::checkpoint_restore(&repo_path, &name, output_mode) + } + CheckpointAction::Drop { name } => { + commands::checkpoint_drop(&repo_path, &name, output_mode) + } + }, + Command::Triage { limit, apply } => { + let apply = apply && !cli.dry_run; + if apply && offline { + anyhow::bail!("cannot use --apply in offline mode"); + } + commands::triage(&repo_path, limit, apply, output_mode).await + } }; + // Startup version hint — one-line stderr notice when a newer release is cached. + // Skipped for self-update (redundant), --json, and --quiet modes. + // Phase 3: respects [update] check_on_startup / check_interval_hours from config. + if output_mode == output::Mode::Human && cmd_name != "self-update" { + let (check_on_startup, check_interval_hours) = crate::config::ParsecConfig::load() + .map(|c| (c.update.check_on_startup, c.update.check_interval_hours)) + .unwrap_or((true, 24)); + commands::startup_version_hint(offline, check_on_startup, check_interval_hours).await; + } + // Record execution entry (best-effort, never fail the command) let duration = exec_start.elapsed(); let steps = crate::execlog::take_steps(); diff --git a/src/config/mod.rs b/src/config/mod.rs index 9e4a140..5edc2ad 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,6 +1,8 @@ mod settings; +pub use settings::AiProvider; pub use settings::CacheStrategy; pub use settings::ParsecConfig; pub use settings::TrackerProvider; +pub use settings::TriageRule; pub use settings::WorktreeLayout; diff --git a/src/config/settings.rs b/src/config/settings.rs index 30bfb5c..bb389bb 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -385,6 +385,49 @@ impl PolicyConfig { } } +// --------------------------------------------------------------------------- +// AiConfig +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +#[serde(rename_all = "lowercase")] +pub enum AiProvider { + #[default] + OpenAi, + Anthropic, +} + +fn default_ai_model() -> String { + "gpt-4o-mini".to_string() +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AiConfig { + /// AI provider: openai or anthropic + #[serde(default)] + pub provider: AiProvider, + /// Model name (default: gpt-4o-mini for OpenAI) + #[serde(default = "default_ai_model")] + pub model: String, + /// API key (prefer env vars PARSEC_AI_API_KEY, OPENAI_API_KEY, ANTHROPIC_API_KEY) + #[serde(default)] + pub api_key: Option, + /// Automatically generate PR descriptions using AI on `parsec ship` + #[serde(default)] + pub auto_pr_description: bool, +} + +impl Default for AiConfig { + fn default() -> Self { + Self { + provider: AiProvider::default(), + model: default_ai_model(), + api_key: None, + auto_pr_description: false, + } + } +} + // --------------------------------------------------------------------------- // TestConfig // --------------------------------------------------------------------------- @@ -397,6 +440,10 @@ fn default_test_jobs() -> usize { 1 } +fn default_update_interval_hours() -> u64 { + 24 +} + /// Settings for the `parsec test` command (issue #247). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TestConfig { @@ -421,6 +468,96 @@ impl Default for TestConfig { } } +// --------------------------------------------------------------------------- +// UpdateConfig +// --------------------------------------------------------------------------- + +/// Settings for automatic version checking (`parsec self-update` / startup hint). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateConfig { + /// When `false`, suppresses the startup "update available" hint on every run. + /// `parsec self-update` is never suppressed. + #[serde(default = "default_true")] + pub check_on_startup: bool, + /// Minimum hours between live GitHub API polls for a new release (default: 24). + #[serde(default = "default_update_interval_hours")] + pub check_interval_hours: u64, +} + +impl Default for UpdateConfig { + fn default() -> Self { + Self { + check_on_startup: true, + check_interval_hours: default_update_interval_hours(), + } + } +} + +// --------------------------------------------------------------------------- +// CrashReportConfig +// --------------------------------------------------------------------------- + +/// Controls opt-in crash report collection (#298). +/// +/// No data is transmitted automatically. When `enabled = true`, a JSON +/// report is written to `/parsec/crash-.json` on panic. The +/// user must choose to share it. See `docs/crash-report.md`. +/// +/// # Example (`~/.config/parsec/config.toml`) +/// ```toml +/// [crash_report] +/// enabled = true +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct CrashReportConfig { + /// When `true`, a JSON crash report is saved locally on panic. + /// Default: `false` (opt-in). + #[serde(default)] + pub enabled: bool, +} + +// --------------------------------------------------------------------------- +// TriageConfig +// --------------------------------------------------------------------------- + +/// One label-assignment rule: when `pattern` (case-insensitive substring) +/// appears in an issue/PR title, `label` is proposed and optionally a +/// `priority` label is added. +/// +/// Example (`~/.config/parsec/config.toml`): +/// ```toml +/// [[triage.rules]] +/// pattern = "feat" +/// label = "type/feature" +/// +/// [[triage.rules]] +/// pattern = "fix" +/// label = "type/bug" +/// priority = "priority/high" +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TriageRule { + /// Case-insensitive substring that is matched against the issue/PR title. + pub pattern: String, + /// Label to propose when `pattern` matches. + pub label: String, + /// Optional priority label to propose alongside `label`. + #[serde(default)] + pub priority: Option, +} + +/// `[triage]` section of the parsec config — rule-based auto-labelling (#302). +/// +/// Rules are evaluated in order; the **first** matching rule wins (highest +/// confidence). Subsequent rules that also match lower the trust score. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct TriageConfig { + /// Ordered list of triage rules. + pub rules: Vec, +} + // --------------------------------------------------------------------------- // ParsecConfig // --------------------------------------------------------------------------- @@ -442,7 +579,17 @@ pub struct ParsecConfig { #[serde(default)] pub policy: PolicyConfig, #[serde(default)] + pub ai: AiConfig, + #[serde(default)] pub test: TestConfig, + #[serde(default)] + pub update: UpdateConfig, + /// Opt-in crash report collection. + #[serde(default)] + pub crash_report: CrashReportConfig, + /// Rule-based issue/PR auto-triage (#302). + #[serde(default)] + pub triage: TriageConfig, /// Per-host GitHub tokens. Keys are hostnames like "github.com" or /// "github.example.com". Serializes as `[github."hostname"]` in TOML. #[serde(default)] @@ -557,6 +704,10 @@ impl ParsecConfig { /// Interactively prompt the user to configure parsec and return the resulting config. pub fn init_interactive() -> Result { + if crate::env::is_agent() { + anyhow::bail!("Interactive config init is not available in agent mode. Use `parsec config show` or set config values directly."); + } + let mut config = Self::default(); // ---- Tracker provider ------------------------------------------------ diff --git a/src/env.rs b/src/env.rs index 73df3bf..128ab61 100644 --- a/src/env.rs +++ b/src/env.rs @@ -99,6 +99,40 @@ pub fn gitlab_token() -> Option { None } +// --------------------------------------------------------------------------- +// Agent mode +// --------------------------------------------------------------------------- + +pub const PARSEC_AGENT: &str = "PARSEC_AGENT"; + +/// Check if agent mode is active (via PARSEC_AGENT env var). +/// In agent mode: JSON output is forced, interactive prompts are skipped. +pub fn is_agent() -> bool { + std::env::var(PARSEC_AGENT) + .map(|v| v == "1" || v == "true") + .unwrap_or(false) +} + +// --------------------------------------------------------------------------- +// AI +// --------------------------------------------------------------------------- + +pub const PARSEC_AI_API_KEY: &str = "PARSEC_AI_API_KEY"; +pub const OPENAI_API_KEY: &str = "OPENAI_API_KEY"; +pub const ANTHROPIC_API_KEY: &str = "ANTHROPIC_API_KEY"; + +/// Resolve AI API key. Priority: PARSEC_AI_API_KEY > provider-specific > config +pub fn ai_api_key(config_key: Option<&str>) -> Option { + for var in [PARSEC_AI_API_KEY, OPENAI_API_KEY, ANTHROPIC_API_KEY] { + if let Ok(key) = std::env::var(var) { + if !key.is_empty() { + return Some(key); + } + } + } + config_key.filter(|k| !k.is_empty()).map(|k| k.to_string()) +} + // --------------------------------------------------------------------------- // Bitbucket // --------------------------------------------------------------------------- @@ -129,6 +163,28 @@ pub fn bitbucket_api_base() -> Option { .map(|v| v.trim_end_matches('/').to_string()) } +// --------------------------------------------------------------------------- +// GitHub API base URL override +// --------------------------------------------------------------------------- + +/// Override the GitHub API base URL (no trailing slash). +/// +/// Primarily used in tests to route API calls to a mock server without +/// changing the git remote URL. Also useful for GitHub Enterprise instances +/// whose API resides at a custom path. +/// +/// When unset, [`GitHubRemote::api_base`] derives the URL from the remote host: +/// `github.com` → `https://api.github.com`, GHE → `https://{host}/api/v3`. +pub const PARSEC_GITHUB_API_BASE: &str = "PARSEC_GITHUB_API_BASE"; + +/// Return the GitHub API base URL override when [`PARSEC_GITHUB_API_BASE`] is set. +pub fn github_api_base() -> Option { + std::env::var(PARSEC_GITHUB_API_BASE) + .ok() + .filter(|v| !v.is_empty()) + .map(|v| v.trim_end_matches('/').to_string()) +} + // --------------------------------------------------------------------------- // Offline mode // --------------------------------------------------------------------------- @@ -201,13 +257,14 @@ mod tests { } } - /// 우선순위 + 빈값 fallback + 모두 미설정 시나리오를 한 함수에서 sequential 검사. - /// `env_lock()` 으로 process-wide 직렬화 (cargo test 병렬 실행 환경에서 sibling - /// 테스트가 env 를 클로버하지 않도록). Windows CI 에서 race 발견 (#289). + /// Checks token priority, empty-value fallback, and fully unset scenarios + /// in sequence. `env_lock()` serializes the process-wide environment so + /// sibling cargo tests cannot clobber these values; Windows CI exposed this + /// race in #289. #[test] fn github_token_priority_order_and_fallback() { let _guard = env_lock().lock().unwrap_or_else(|p| p.into_inner()); - // 1. PARSEC_GITHUB_TOKEN 우선 + // 1. PARSEC_GITHUB_TOKEN wins. { let g = EnvGuard::new(&[PARSEC_GITHUB_TOKEN, GITHUB_TOKEN, GH_TOKEN]); g.set(PARSEC_GITHUB_TOKEN, "p"); @@ -216,7 +273,7 @@ mod tests { assert_eq!(github_token().as_deref(), Some("p")); drop(g); } - // 2. PARSEC_GITHUB_TOKEN 미설정 → GITHUB_TOKEN + // 2. Unset PARSEC_GITHUB_TOKEN falls back to GITHUB_TOKEN. { let g = EnvGuard::new(&[PARSEC_GITHUB_TOKEN, GITHUB_TOKEN, GH_TOKEN]); g.set(GITHUB_TOKEN, "g"); @@ -224,14 +281,14 @@ mod tests { assert_eq!(github_token().as_deref(), Some("g")); drop(g); } - // 3. PARSEC_GITHUB_TOKEN / GITHUB_TOKEN 미설정 → GH_TOKEN + // 3. Unset PARSEC_GITHUB_TOKEN and GITHUB_TOKEN fall back to GH_TOKEN. { let g = EnvGuard::new(&[PARSEC_GITHUB_TOKEN, GITHUB_TOKEN, GH_TOKEN]); g.set(GH_TOKEN, "h"); assert_eq!(github_token().as_deref(), Some("h")); drop(g); } - // 4. 빈 PARSEC_GITHUB_TOKEN 은 무시 → GITHUB_TOKEN + // 4. Empty PARSEC_GITHUB_TOKEN is ignored and falls back to GITHUB_TOKEN. { let g = EnvGuard::new(&[PARSEC_GITHUB_TOKEN, GITHUB_TOKEN, GH_TOKEN]); g.set(PARSEC_GITHUB_TOKEN, ""); @@ -239,8 +296,8 @@ mod tests { assert_eq!(github_token().as_deref(), Some("g")); drop(g); } - // 5. 모두 미설정 + gh 실패 → None. CI 환경 (gh 로그인 X) 이 일반. - // local dev 에서 gh auth login 돼있으면 Some(token) 도 허용 (smoke). + // 5. All env vars unset and gh unavailable returns None, which is + // common in CI without gh login. Local gh auth may return a token. { let g = EnvGuard::new(&[PARSEC_GITHUB_TOKEN, GITHUB_TOKEN, GH_TOKEN]); match github_token() { @@ -256,8 +313,9 @@ mod tests { #[test] fn gh_auth_token_returns_option_string_or_none() { - // 외부 gh binary 에 의존 — CI 환경 (로그인 X) 에서는 None 기대. - // local dev 에서 gh auth login 돼있으면 Some(token). 둘 다 허용 (smoke check only). + // Depends on the external gh binary: CI without login should return + // None, while local dev with gh auth may return a token. This is only a + // smoke check, so both outcomes are valid. match gh_auth_token() { None => {} Some(t) => { diff --git a/src/git/mod.rs b/src/git/mod.rs index 6f7ee4d..84c3884 100644 --- a/src/git/mod.rs +++ b/src/git/mod.rs @@ -290,6 +290,11 @@ pub fn get_merge_base(repo: &Path, a: &str, b: &str) -> Result { run_output(repo, &["merge-base", a, b]) } +/// Get the full diff between two branches (base...head). +pub fn diff_against(repo: &Path, base: &str, head: &str) -> Result { + run_output(repo, &["diff", &format!("{base}...{head}")]) +} + /// Force-delete a local branch. pub fn delete_branch(repo: &Path, branch: &str) -> Result<()> { run(repo, &["branch", "-D", branch]) diff --git a/src/github/mod.rs b/src/github/mod.rs index d153471..ec812f1 100644 --- a/src/github/mod.rs +++ b/src/github/mod.rs @@ -359,7 +359,8 @@ impl GitHubClient { None => return Ok(None), }; - let api_base = remote.api_base(); + // Allow test overrides (and GHE custom endpoints) via env var. + let api_base = crate::env::github_api_base().unwrap_or_else(|| remote.api_base()); let client = http_client()?; Ok(Some(Self { @@ -547,6 +548,56 @@ impl GitHubClient { }) } + /// Fetch check runs directly for a commit SHA (no PR lookup). + /// + /// Used by `smartlog` Phase 3 of issue #310 to show CI status for + /// worktree branches that have no open PR yet. The result is identical + /// in shape to [`get_check_runs`]; `pr_number` is set to 0 as a sentinel + /// (the caller uses the struct fields, not `pr_number`). + pub async fn get_check_runs_by_sha(&self, sha: &str) -> Result { + let rp = self.repo_path(); + let checks_resp: ApiCheckRunsResponse = + send_with_retry(self.get(&format!("{}/commits/{}/check-runs", rp, sha))) + .await? + .json() + .await?; + + let checks: Vec = checks_resp + .check_runs + .into_iter() + .map(|c| CheckRun { + name: c.name, + status: c.status, + conclusion: c.conclusion, + started_at: c.started_at, + completed_at: c.completed_at, + html_url: c.html_url, + }) + .collect(); + + let overall = if checks.is_empty() { + "no checks".to_string() + } else if checks + .iter() + .any(|c| c.conclusion.as_deref() == Some("failure")) + { + "failing".to_string() + } else if checks.iter().all(|c| { + c.conclusion.as_deref() == Some("success") || c.conclusion.as_deref() == Some("skipped") + }) { + "passing".to_string() + } else { + "pending".to_string() + }; + + Ok(CiStatus { + pr_number: 0, + head_sha: sha.to_string(), + overall, + checks, + }) + } + /// Find an open PR by branch name. /// Returns the PR number if found. pub async fn find_pr_by_branch(&self, branch: &str) -> Result> { @@ -897,6 +948,54 @@ impl GitHubClient { } /// Add labels to a PR/issue. + /// Fetch open issues for the current repo (excludes pull requests). + /// + /// Returns up to `limit` issues sorted by creation date (newest first). + /// Each item is `(number, title, existing_labels)`. + pub async fn list_open_issues(&self, limit: u8) -> Result)>> { + #[derive(serde::Deserialize)] + struct IssueLabel { + name: String, + } + #[derive(serde::Deserialize)] + struct IssueItem { + number: u64, + title: String, + labels: Vec, + /// GitHub includes a `pull_request` field only on PR items; use + /// its absence to distinguish real issues from PRs. + pull_request: Option, + } + + let per_page = limit.min(100); + let path = format!( + "{}/issues?state=open&per_page={per_page}&sort=created&direction=desc", + self.repo_path() + ); + let response = self + .get(&path) + .send() + .await + .context("Failed to list open issues")?; + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + bail!("Failed to list open issues: {} {}", status, body); + } + let items: Vec = response + .json() + .await + .context("Failed to parse issue list")?; + Ok(items + .into_iter() + .filter(|i| i.pull_request.is_none()) // skip PRs + .map(|i| { + let labels: Vec = i.labels.into_iter().map(|l| l.name).collect(); + (i.number, i.title, labels) + }) + .collect()) + } + pub async fn add_labels(&self, issue_number: u64, labels: &[String]) -> Result<()> { if labels.is_empty() { return Ok(()); diff --git a/src/main.rs b/src/main.rs index 61b0d8b..e7454ac 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ #[macro_use] mod errors; +mod ai; mod bitbucket; mod cli; mod config; @@ -10,8 +11,10 @@ mod execlog; mod git; mod github; mod gitlab; +mod mcp; mod oplog; mod output; +mod panic_handler; mod tracker; mod worktree; @@ -20,6 +23,13 @@ use cli::Cli; #[tokio::main] async fn main() { + // ── Panic hook (opt-in crash report, #298) ───────────────────────────── + // Load config once solely to read the crash_report setting; failures are + // silently ignored (hook still installs with enabled=false). + let crash_report_enabled = config::ParsecConfig::load() + .map(|c| c.crash_report.enabled) + .unwrap_or(false); + panic_handler::setup(crash_report_enabled); let cli = Cli::parse(); let json_mode = cli.json; diff --git a/src/mcp/install.rs b/src/mcp/install.rs new file mode 100644 index 0000000..66e762f --- /dev/null +++ b/src/mcp/install.rs @@ -0,0 +1,306 @@ +//! MCP client config installer — `parsec mcp install --client=`. +//! +//! Writes or updates the `mcpServers.git-parsec` entry in the target client's +//! JSON config file without disturbing other keys. +//! +//! # Installer Contract (from `docs/mcp/clients.md`) +//! - Parse the config file as JSON before writing. +//! - Preserve unrelated top-level keys and unrelated `mcpServers` entries. +//! - Replace only the `mcpServers.git-parsec` entry. +//! - Refuse to modify if the file contains non-standard JSON syntax. +//! - Create a timestamped backup before modifying an existing config file. +//! - Support `--dry-run` (prints the target path and planned JSON without writing). +//! - Never embed `GITHUB_TOKEN`, `GH_TOKEN`, or other credentials in config. +//! +//! # Phase Status +//! Phase 33 (#293): automated installer hook. + +use anyhow::{bail, Context, Result}; +use serde_json::Value; +use std::path::{Path, PathBuf}; +use std::str::FromStr; + +/// Supported MCP desktop clients. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum McpClientTarget { + ClaudeDesktop, + Cursor, +} + +impl std::fmt::Display for McpClientTarget { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::ClaudeDesktop => write!(f, "claude-desktop"), + Self::Cursor => write!(f, "cursor"), + } + } +} + +impl FromStr for McpClientTarget { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + match s { + "claude-desktop" => Ok(Self::ClaudeDesktop), + "cursor" => Ok(Self::Cursor), + other => bail!( + "unknown MCP client '{}'; valid choices: claude-desktop, cursor", + other + ), + } + } +} + +/// Returns the platform-specific config file path for the given client. +/// +/// Paths match the table in `docs/mcp/clients.md`. +pub fn config_path(client: McpClientTarget) -> Result { + match client { + McpClientTarget::ClaudeDesktop => claude_desktop_config_path(), + McpClientTarget::Cursor => cursor_config_path(), + } +} + +fn claude_desktop_config_path() -> Result { + let home = dirs::home_dir().context("could not determine home directory")?; + #[cfg(target_os = "macos")] + { + Ok(home.join("Library/Application Support/Claude/claude_desktop_config.json")) + } + #[cfg(target_os = "windows")] + { + let appdata = dirs::data_dir().context("could not determine AppData directory")?; + Ok(appdata.join("Claude/claude_desktop_config.json")) + } + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + let _ = home; + bail!("Claude Desktop is only supported on macOS and Windows; detected other OS") + } +} + +fn cursor_config_path() -> Result { + let home = dirs::home_dir().context("could not determine home directory")?; + Ok(home.join(".cursor/mcp.json")) +} + +/// Builds the `mcpServers.git-parsec` entry value. +/// +/// When `bin_path` is `None`, the entry uses `"parsec"` relying on `$PATH`. +pub fn build_server_entry(bin_path: Option<&str>) -> Value { + let command = bin_path.unwrap_or("parsec"); + serde_json::json!({ + "command": command, + "args": ["mcp", "serve"] + }) +} + +/// Merges the `mcpServers.git-parsec` entry into an existing config value. +/// +/// - All other top-level keys are preserved unchanged. +/// - All other `mcpServers` entries are preserved unchanged. +/// - If `mcpServers` does not exist, it is created. +/// - The `mcpServers.git-parsec` entry is inserted or replaced. +pub fn merge_server_entry(mut config: Value, entry: Value) -> Value { + // Ensure the root is an object (guard against malformed top-level JSON). + if !config.is_object() { + config = Value::Object(serde_json::Map::new()); + } + + let obj = config.as_object_mut().expect("just ensured object"); + + match obj.get_mut("mcpServers") { + Some(Value::Object(servers)) => { + servers.insert("git-parsec".to_string(), entry); + } + _ => { + let mut servers = serde_json::Map::new(); + servers.insert("git-parsec".to_string(), entry); + obj.insert("mcpServers".to_string(), Value::Object(servers)); + } + } + + config +} + +/// Creates a timestamped `.bak-` copy of `path`. +/// +/// Returns the backup path on success. +fn timestamped_backup(path: &Path) -> Result { + let ts = chrono::Local::now().format("%Y%m%dT%H%M%S"); + let ext = format!("bak-{ts}"); + let backup = path.with_extension(ext); + std::fs::copy(path, &backup).with_context(|| format!("failed to backup {}", path.display()))?; + Ok(backup) +} + +/// Run the MCP client config installer. +/// +/// Reads the target client's config file, merges the `mcpServers.git-parsec` +/// entry (preserving all other keys), and writes the result. +/// +/// When `dry_run` is `true` the planned JSON is printed to stdout and no files +/// are touched on disk. +pub fn install(client: McpClientTarget, dry_run: bool, bin_path: Option<&str>) -> Result<()> { + let path = config_path(client)?; + let entry = build_server_entry(bin_path); + + // Read and parse existing config, or start with an empty JSON object. + let existing: Value = if path.exists() { + let raw = std::fs::read_to_string(&path) + .with_context(|| format!("failed to read {}", path.display()))?; + serde_json::from_str(&raw).with_context(|| { + format!( + "could not parse {} as JSON — it may contain comments or \ + trailing commas; fix the file manually and retry", + path.display() + ) + })? + } else { + Value::Object(serde_json::Map::new()) + }; + + let merged = merge_server_entry(existing, entry); + let output = + serde_json::to_string_pretty(&merged).context("failed to serialise merged config")?; + + if dry_run { + println!("# dry-run — no files written"); + println!("# target: {}", path.display()); + println!("{output}"); + return Ok(()); + } + + // Backup existing file before overwriting. + if path.exists() { + let backup = timestamped_backup(&path)?; + eprintln!("info: backup written to {}", backup.display()); + } + + // Create parent directory if it does not yet exist. + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("failed to create directory {}", parent.display()))?; + } + + std::fs::write(&path, &output) + .with_context(|| format!("failed to write {}", path.display()))?; + + println!("✓ git-parsec registered in {} config", client); + println!(" {}", path.display()); + Ok(()) +} + +// ── tests ─────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn merge_into_empty_object() { + let entry = json!({"command": "parsec", "args": ["mcp", "serve"]}); + let result = merge_server_entry(json!({}), entry.clone()); + assert_eq!(result["mcpServers"]["git-parsec"], entry); + } + + #[test] + fn merge_preserves_other_mcp_servers() { + let existing = json!({ + "mcpServers": { + "other-tool": {"command": "other", "args": []} + } + }); + let entry = json!({"command": "parsec", "args": ["mcp", "serve"]}); + let result = merge_server_entry(existing, entry.clone()); + assert_eq!(result["mcpServers"]["git-parsec"], entry); + // Unrelated server entry must be preserved. + assert_eq!(result["mcpServers"]["other-tool"]["command"], "other"); + } + + #[test] + fn merge_replaces_existing_entry() { + let existing = json!({ + "mcpServers": { + "git-parsec": {"command": "old-parsec", "args": ["serve"]} + } + }); + let entry = json!({"command": "parsec", "args": ["mcp", "serve"]}); + let result = merge_server_entry(existing, entry); + assert_eq!(result["mcpServers"]["git-parsec"]["command"], "parsec"); + assert_eq!(result["mcpServers"]["git-parsec"]["args"][0], "mcp"); + assert_eq!(result["mcpServers"]["git-parsec"]["args"][1], "serve"); + } + + #[test] + fn merge_preserves_top_level_keys() { + let existing = json!({"theme": "dark", "mcpServers": {}}); + let entry = json!({"command": "parsec", "args": ["mcp", "serve"]}); + let result = merge_server_entry(existing, entry); + assert_eq!(result["theme"], "dark"); + assert!(result["mcpServers"]["git-parsec"].is_object()); + } + + #[test] + fn merge_handles_non_object_mcp_servers() { + // If mcpServers is unexpectedly a non-object, replace it cleanly. + let existing = json!({"mcpServers": "invalid"}); + let entry = json!({"command": "parsec", "args": ["mcp", "serve"]}); + let result = merge_server_entry(existing, entry.clone()); + assert_eq!(result["mcpServers"]["git-parsec"], entry); + } + + #[test] + fn build_entry_default_bin() { + let entry = build_server_entry(None); + assert_eq!(entry["command"], "parsec"); + assert_eq!(entry["args"], json!(["mcp", "serve"])); + } + + #[test] + fn build_entry_custom_bin() { + let entry = build_server_entry(Some("/usr/local/bin/parsec")); + assert_eq!(entry["command"], "/usr/local/bin/parsec"); + assert_eq!(entry["args"], json!(["mcp", "serve"])); + } + + #[test] + fn client_target_fromstr_valid() { + assert_eq!( + "claude-desktop".parse::().unwrap(), + McpClientTarget::ClaudeDesktop + ); + assert_eq!( + "cursor".parse::().unwrap(), + McpClientTarget::Cursor + ); + } + + #[test] + fn client_target_fromstr_invalid() { + assert!("vscode".parse::().is_err()); + assert!("".parse::().is_err()); + assert!("CLAUDE-DESKTOP".parse::().is_err()); + } + + #[test] + fn client_target_display() { + assert_eq!(McpClientTarget::ClaudeDesktop.to_string(), "claude-desktop"); + assert_eq!(McpClientTarget::Cursor.to_string(), "cursor"); + } + + #[test] + fn dry_run_prints_to_stdout() { + // Smoke check: dry_run with a temp dir so no real config is modified. + // We only verify it returns Ok — stdout capture is left to integration tests. + // config_path is platform-specific, so exercise just the merge+serialise path. + let existing = json!({"mcpServers": {"other": {"command": "x"}}}); + let entry = build_server_entry(None); + let merged = merge_server_entry(existing, entry); + let serialised = serde_json::to_string_pretty(&merged).unwrap(); + assert!(serialised.contains("git-parsec")); + assert!(serialised.contains("parsec")); + assert!(serialised.contains("other")); // preserved + } +} diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs new file mode 100644 index 0000000..46b149b --- /dev/null +++ b/src/mcp/mod.rs @@ -0,0 +1,2276 @@ +//! MCP (Model Context Protocol) server implementation for git-parsec. +//! +//! This module exposes parsec's worktree-native git workflow as an MCP server +//! over stdio JSON-RPC 2.0, allowing AI agents (Claude Desktop, Cursor, etc.) +//! to manage worktrees programmatically. +//! +//! ## Architecture +//! +//! ```text +//! parsec mcp serve +//! └─ McpServer (stdio JSON-RPC loop) +//! └─ ToolRegistry +//! ├─ worktree_list → tools::worktree::list +//! ├─ worktree_start → tools::worktree::start +//! ├─ worktree_status → tools::worktree::status +//! ├─ worktree_ship → tools::worktree::ship +//! ├─ smartlog → tools::smartlog::run +//! ├─ ci_status → tools::ci::status +//! ├─ pr_status → tools::pr::status +//! ├─ health_check → tools::health::check +//! ├─ reviews → tools::reviews::list +//! └─ sync → tools::sync::run +//! ``` +//! +//! See `docs/mcp/spec.md` for the full tool catalogue and JSON schemas. +//! +//! ## Phases +//! +//! - **Phase 1**: module skeleton + tool registry shape. +//! - **Phase 2** (#293): stdio JSON-RPC skeleton (`parsec mcp serve`). +//! - **Phase 25** (#293): `smartlog` wired — sync DAG collection (no GitHub overlay). +//! - **Phase 26** (#293): `pr_status` wired via `gh pr view`. +//! - **Phase 27** (#293): `reviews` wired via `gh pr list`. +//! - **Phase 28** (#293): `ci_status` wired via `gh pr view --json statusCheckRollup`. +//! - **Phase 29** (#293): `sync` wired — rebase/merge worktree against base branch (dry_run + confirm gates). +//! - **Phase 30** (#293): `worktree_start` wired — dry_run preview + confirmed worktree creation. +//! - **Phase 31** (#293): `worktree_ship` wired — push + `gh pr create` + optional cleanup. +//! - All 10 tools wired. +//! - **Phase 35** (#294): `PARSEC_GITHUB_TOKEN` env var read in `serve_stdio` → `McpContext.github_token` +//! (non-interactive host support; distinct from ambient `GITHUB_TOKEN`/`GH_TOKEN`). +//! - **Phase 36** (#294): `~/.config/parsec/mcp.toml` config-file support — token + optional scopes; +//! env var wins over config file; `PARSEC_MCP_CONFIG` overrides default path. +//! - **Phase 37** (#294): config-file → scope-gate enforcement pipeline integration tests; +//! 4 new fixture records covering write-scope promotion, `INSUFFICIENT_SCOPE` edge cases. +//! - **Phase 38** (#295): `mcp.toml` config-file auth source + env-var precedence e2e — +//! 3 new fixture records + token-redaction assertions. +//! - **Phase 39** (#293): `docs/mcp-quickstart.md` end-to-end user quickstart — +//! prerequisites, one-command install, auth setup, common AI agent workflows, tool catalogue, +//! manual server invocation, and troubleshooting guide. + +// All 10 tools are wired (Phase 31). The allow(dead_code) below covers +// internal helpers (e.g. AuditOutcome variants) that are constructed only +// in tests or via the public API, which rustc cannot see across crate +// boundaries without a full LTO pass. +#![allow(dead_code)] + +pub mod install; +pub mod tools; + +use std::io::{BufRead, Write}; + +/// GitHub API capability requested by an MCP tool. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum GithubScope { + /// Read pull request metadata, reviews, and mergeability state. + PullRequestRead, + /// Read GitHub Actions check runs and workflow status. + ChecksRead, + /// Push branches and create or update pull requests. + PullRequestWrite, +} + +impl GithubScope { + /// Stable identifier used by MCP metadata and future auth errors. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::PullRequestRead => "pull_request:read", + Self::ChecksRead => "checks:read", + Self::PullRequestWrite => "pull_request:write", + } + } + + /// Parse a scope string produced by [`as_str`](Self::as_str). + /// Returns `None` for unrecognised strings so unknown scopes are ignored + /// rather than causing a hard failure when loading a config file. + fn from_str_opt(s: &str) -> Option { + match s { + "pull_request:read" => Some(Self::PullRequestRead), + "checks:read" => Some(Self::ChecksRead), + "pull_request:write" => Some(Self::PullRequestWrite), + _ => None, + } + } +} + +/// TOML schema for `~/.config/parsec/mcp.toml` (or the path given by +/// `PARSEC_MCP_CONFIG`). Unknown top-level keys are silently ignored. +#[derive(Debug, serde::Deserialize, Default)] +struct McpConfigFile { + auth: Option, +} + +/// `[auth]` section of the MCP config file. +#[derive(Debug, serde::Deserialize)] +struct McpConfigAuth { + /// GitHub personal-access token to delegate to every tool call. + token: Option, + /// Declared scope strings (e.g. `["pull_request:read", "checks:read"]`). + /// When present, scope checks are applied per-tool; when absent, all scopes + /// are assumed available (the same behaviour as env-var delegation). + scopes: Option>, +} + +/// Returns the default MCP config path (`~/.config/parsec/mcp.toml` on +/// Linux/macOS; `%APPDATA%\parsec\mcp.toml` on Windows). +fn default_mcp_config_path() -> Option { + dirs::config_dir().map(|d| d.join("parsec").join("mcp.toml")) +} + +/// Delegated GitHub token that redacts its secret in debug output. +#[derive(Clone, Eq, PartialEq)] +pub struct DelegatedGithubToken { + token: String, +} + +impl DelegatedGithubToken { + /// Create a delegated token from client-provided session auth. + #[must_use] + pub fn new(token: impl Into) -> Self { + Self { + token: token.into(), + } + } + + /// Borrow the raw token for the GitHub client boundary. + #[must_use] + pub fn expose_secret(&self) -> &str { + &self.token + } +} + +impl std::fmt::Debug for DelegatedGithubToken { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("DelegatedGithubToken") + .field("token", &"") + .finish() + } +} + +/// Context passed to every MCP tool handler. +/// +/// Carries repository path, auth tokens (delegated by the caller), and +/// runtime flags like `dry_run`. Tools must not read auth from environment +/// directly; they must use this context. +#[derive(Debug, Clone)] +pub struct McpContext { + /// Absolute path to the git repository root. + pub repo_path: std::path::PathBuf, + + /// GitHub Personal Access Token delegated by the MCP caller. + /// `None` when the tool does not require GitHub API access. + pub github_token: Option, + + /// Declared scopes for the delegated token. + /// `None` means the MCP host did not provide scoped metadata yet. + pub github_scopes: Option>, + + /// When `true`, all mutating operations are previewed without + /// side effects. Tools must check this before any state change. + pub dry_run: bool, +} + +impl McpContext { + /// Create a context from the current working directory. + pub fn from_cwd(dry_run: bool) -> anyhow::Result { + let repo_path = std::env::current_dir()?; + Ok(Self { + repo_path, + github_token: None, + github_scopes: None, + dry_run, + }) + } + + /// Create a context from the current working directory, also reading + /// `PARSEC_GITHUB_TOKEN` from the environment **or** from the MCP config + /// file if it is set and non-empty. + /// + /// **Priority** (highest wins): + /// 1. `PARSEC_GITHUB_TOKEN` environment variable — token only, no declared scopes. + /// 2. Config file at `PARSEC_MCP_CONFIG` or `~/.config/parsec/mcp.toml` — + /// token + optional `scopes` list. + /// + /// This is the entry point for `parsec mcp serve` on non-interactive hosts + /// where the MCP client cannot forward a PAT inline (e.g., Claude Desktop, + /// Cursor, systemd service). + /// + /// `PARSEC_GITHUB_TOKEN` is intentionally distinct from `GITHUB_TOKEN` / + /// `GH_TOKEN` so this server is not implicitly activated by ambient CI + /// tokens on workstations or CI runners. + pub fn from_env(dry_run: bool) -> anyhow::Result { + Self::from_env_and_config_lookup( + dry_run, + |key| std::env::var(key).ok(), + |path| std::fs::read_to_string(path).ok(), + ) + } + + /// Like [`from_env`] but accepts an env-lookup closure for unit testing + /// without mutating process-global state. Does **not** consult the config + /// file; use [`from_env_and_config_lookup`] when both paths are needed. + fn from_env_lookup(dry_run: bool, lookup: F) -> anyhow::Result + where + F: Fn(&str) -> Option, + { + let mut ctx = Self::from_cwd(dry_run)?; + if let Some(token) = lookup("PARSEC_GITHUB_TOKEN") { + if !token.is_empty() { + ctx = ctx.with_github_token(token); + } + } + Ok(ctx) + } + + /// Full resolution with both env and config-file injection, using injectable + /// closures so tests never touch the real filesystem or process environment. + /// + /// - `env_lookup`: returns the value of an env var by name. + /// - `file_read`: returns the UTF-8 contents of a path, or `None` when the + /// file does not exist or cannot be read. + fn from_env_and_config_lookup( + dry_run: bool, + env_lookup: E, + file_read: R, + ) -> anyhow::Result + where + E: Fn(&str) -> Option, + R: Fn(&std::path::Path) -> Option, + { + let mut ctx = Self::from_cwd(dry_run)?; + + // Priority 1: PARSEC_GITHUB_TOKEN env var (highest — no declared scopes). + if let Some(token) = env_lookup("PARSEC_GITHUB_TOKEN") { + if !token.is_empty() { + ctx = ctx.with_github_token(token); + return Ok(ctx); // env var wins; skip config file. + } + } + + // Priority 2: config file (PARSEC_MCP_CONFIG override or default path). + let config_path = match env_lookup("PARSEC_MCP_CONFIG") { + Some(p) if !p.is_empty() => Some(std::path::PathBuf::from(p)), + _ => default_mcp_config_path(), + }; + + if let Some(path) = config_path { + if let Some(contents) = file_read(&path) { + if let Ok(config) = toml::from_str::(&contents) { + if let Some(auth) = config.auth { + if let Some(token) = auth.token.filter(|t| !t.is_empty()) { + let scopes: Vec = auth + .scopes + .unwrap_or_default() + .iter() + .filter_map(|s| GithubScope::from_str_opt(s)) + .collect(); + if scopes.is_empty() { + ctx = ctx.with_github_token(token); + } else { + ctx = ctx.with_github_auth(token, scopes); + } + } + } + } + } + } + + Ok(ctx) + } + + /// Attach a GitHub PAT to the context. + #[must_use] + pub fn with_github_token(mut self, token: impl Into) -> Self { + self.github_token = Some(DelegatedGithubToken::new(token)); + self + } + + /// Attach a GitHub PAT and its declared scopes to the context. + #[must_use] + pub fn with_github_auth(mut self, token: impl Into, scopes: Vec) -> Self { + self.github_token = Some(DelegatedGithubToken::new(token)); + self.github_scopes = Some(scopes); + self + } +} + +/// A registered MCP tool: name, description, and a handler stub. +/// +/// This carries the stable metadata that `tools/list` needs before the +/// JSON-RPC dispatcher is wired. Handler functions remain in `src/mcp/tools/`. +pub struct ToolDef { + /// Machine-readable tool name (snake_case, matches spec). + pub name: &'static str, + /// Human-readable description returned in `tools/list` responses. + pub description: &'static str, + /// JSON Schema draft-07 input schema for this tool. + pub input_schema: &'static str, + /// Whether this tool can mutate repository or remote state. + pub mutating: bool, + /// Whether this tool requires GitHub API credentials for normal operation. + pub requires_github: bool, + /// Minimum GitHub API scopes needed before dispatch. + pub github_scopes: &'static [GithubScope], +} + +/// Privacy-safe outcome recorded for an MCP tool call. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +enum AuditOutcome { + Allowed, + Denied, + ToolError, +} + +/// Schema version emitted with every structured audit event. +const AUDIT_EVENT_VERSION: u64 = 1; + +impl AuditOutcome { + const fn as_str(self) -> &'static str { + match self { + Self::Allowed => "allowed", + Self::Denied => "denied", + Self::ToolError => "tool_error", + } + } +} + +/// Build one structured event for the stderr audit sink. +/// +/// Arguments, repository paths, credentials, and error messages are +/// deliberately excluded so audit output cannot persist caller secrets. +fn audit_event( + tool: &ToolDef, + outcome: AuditOutcome, + dry_run: bool, + request_id: &serde_json::Value, +) -> serde_json::Value { + let mut event = serde_json::json!({ + "event": "mcp.tool_call", + "version": AUDIT_EVENT_VERSION, + "tool": tool.name, + "outcome": outcome.as_str(), + "mutating": tool.mutating, + "dryRun": dry_run, + }); + if let Some(correlation_id) = audit_correlation_id(request_id) { + event["correlationId"] = correlation_id; + } + event +} + +fn audit_correlation_id(request_id: &serde_json::Value) -> Option { + match request_id { + serde_json::Value::Number(_) => Some(request_id.clone()), + serde_json::Value::String(value) + if !value.is_empty() + && value.len() <= 64 + && value.chars().all(|character| { + character.is_ascii_alphanumeric() || "-_.".contains(character) + }) => + { + Some(request_id.clone()) + } + _ => None, + } +} + +fn emit_audit_event( + tool: &ToolDef, + outcome: AuditOutcome, + dry_run: bool, + request_id: &serde_json::Value, +) { + let stderr = std::io::stderr(); + let mut sink = stderr.lock(); + emit_audit_event_to(&mut sink, tool, outcome, dry_run, request_id); +} + +fn emit_audit_event_to( + sink: &mut impl std::io::Write, + tool: &ToolDef, + outcome: AuditOutcome, + dry_run: bool, + request_id: &serde_json::Value, +) { + // Audit failures must not change the JSON-RPC response or expose call data. + let _ = write_audit_event(sink, tool, outcome, dry_run, request_id); +} + +fn write_audit_event( + sink: &mut impl std::io::Write, + tool: &ToolDef, + outcome: AuditOutcome, + dry_run: bool, + request_id: &serde_json::Value, +) -> std::io::Result<()> { + writeln!(sink, "{}", audit_event(tool, outcome, dry_run, request_id)) +} + +/// All tools exposed by the parsec MCP server. +/// +/// This slice is the single source of truth for `tools/list` responses. +/// Add new tools here **and** implement them in `src/mcp/tools/`. +pub const TOOLS: &[ToolDef] = &[ + ToolDef { + name: "worktree_list", + description: "List all active parsec worktrees with ticket, branch, PR, and CI status.", + input_schema: r#"{"type":"object","properties":{"repo":{"type":"string"},"include_pr":{"type":"boolean","default":true},"include_ci":{"type":"boolean","default":false}},"required":[]}"#, + mutating: false, + requires_github: false, + github_scopes: &[GithubScope::PullRequestRead, GithubScope::ChecksRead], + }, + ToolDef { + name: "worktree_start", + description: "Create an isolated git worktree for a ticket.", + input_schema: r#"{"type":"object","properties":{"ticket":{"type":"string"},"repo":{"type":"string"},"base":{"type":"string"},"title":{"type":"string"},"on":{"type":"string"},"dry_run":{"type":"boolean","default":false},"confirm":{"type":"boolean","default":false}},"required":["ticket"]}"#, + mutating: true, + requires_github: false, + github_scopes: &[], + }, + ToolDef { + name: "worktree_status", + description: + "Show detailed status of a worktree: uncommitted changes, ahead/behind, PR, CI.", + input_schema: r#"{"type":"object","properties":{"ticket":{"type":"string"},"repo":{"type":"string"}},"required":["ticket"]}"#, + mutating: false, + requires_github: false, + github_scopes: &[GithubScope::PullRequestRead], + }, + ToolDef { + name: "worktree_ship", + description: + "Push the worktree branch to origin, create/update a GitHub PR, and optionally clean up.", + input_schema: r#"{"type":"object","properties":{"ticket":{"type":"string"},"repo":{"type":"string"},"draft":{"type":"boolean","default":false},"no_cleanup":{"type":"boolean","default":false},"dry_run":{"type":"boolean","default":false},"confirm":{"type":"boolean","default":false}},"required":["ticket"]}"#, + mutating: true, + requires_github: true, + github_scopes: &[GithubScope::PullRequestWrite], + }, + ToolDef { + name: "smartlog", + description: + "Render the smartlog DAG annotated with worktree branches, PR state, and CI status.", + input_schema: r#"{"type":"object","properties":{"repo":{"type":"string"},"ticket":{"type":"string"},"limit":{"type":"integer","default":50,"minimum":1,"maximum":500},"no_color":{"type":"boolean","default":true}},"required":[]}"#, + mutating: false, + requires_github: false, + github_scopes: &[GithubScope::PullRequestRead, GithubScope::ChecksRead], + }, + ToolDef { + name: "ci_status", + description: "Fetch GitHub Actions CI check-run status for a worktree branch.", + input_schema: r#"{"type":"object","properties":{"ticket":{"type":"string"},"repo":{"type":"string"},"limit":{"type":"integer","default":10}},"required":["ticket"]}"#, + mutating: false, + requires_github: true, + github_scopes: &[GithubScope::ChecksRead], + }, + ToolDef { + name: "pr_status", + description: + "Return GitHub PR state, review approvals, merge readiness, and review comments.", + input_schema: r#"{"type":"object","properties":{"ticket":{"type":"string"},"repo":{"type":"string"}},"required":["ticket"]}"#, + mutating: false, + requires_github: true, + github_scopes: &[GithubScope::PullRequestRead], + }, + ToolDef { + name: "health_check", + description: + "Run worktree health diagnostics: lock files, uncommitted changes, stale branches.", + input_schema: r#"{"type":"object","properties":{"ticket":{"type":"string"},"repo":{"type":"string"},"include_ci":{"type":"boolean","default":false}},"required":[]}"#, + mutating: false, + requires_github: false, + github_scopes: &[GithubScope::ChecksRead], + }, + ToolDef { + name: "reviews", + description: + "List PRs where the user is a requested reviewer or their own PRs awaiting review.", + input_schema: r#"{"type":"object","properties":{"repo":{"type":"string"},"filter":{"type":"string","enum":["incoming","outgoing","all"],"default":"all"},"limit":{"type":"integer","default":20}},"required":[]}"#, + mutating: false, + requires_github: true, + github_scopes: &[GithubScope::PullRequestRead], + }, + ToolDef { + name: "sync", + description: "Rebase or merge-update stale worktrees against the current base branch.", + input_schema: r#"{"type":"object","properties":{"ticket":{"type":"string"},"repo":{"type":"string"},"strategy":{"type":"string","enum":["rebase","merge"],"default":"rebase"},"dry_run":{"type":"boolean","default":true},"confirm":{"type":"boolean","default":false},"stale_days":{"type":"integer","default":5}},"required":[]}"#, + mutating: true, + requires_github: false, + github_scopes: &[], + }, +]; + +/// Return the registered definition for a tool name. +#[must_use] +pub fn tool_by_name(name: &str) -> Option<&'static ToolDef> { + TOOLS.iter().find(|tool| tool.name == name) +} + +/// Render tool metadata in the shape required by MCP `tools/list`. +/// +/// # Errors +/// Returns an error if a checked-in schema string is invalid JSON. +pub fn tools_list_payload() -> anyhow::Result { + let tools = TOOLS + .iter() + .map(|tool| { + let input_schema: serde_json::Value = serde_json::from_str(tool.input_schema)?; + Ok(serde_json::json!({ + "name": tool.name, + "description": tool.description, + "inputSchema": input_schema, + "annotations": { + "mutating": tool.mutating, + "requiresGithub": tool.requires_github, + "githubScopes": tool.github_scopes + .iter() + .map(|scope| scope.as_str()) + .collect::>(), + }, + })) + }) + .collect::>>()?; + + Ok(serde_json::json!({ "tools": tools })) +} + +/// Render parsec-specific capability metadata for MCP initialization. +#[must_use] +pub fn initialize_capabilities_payload() -> serde_json::Value { + serde_json::json!({ + "tools": {}, + "gitParsec": { + "githubScopes": [ + GithubScope::PullRequestRead.as_str(), + GithubScope::ChecksRead.as_str(), + GithubScope::PullRequestWrite.as_str(), + ], + }, + }) +} + +/// Serve newline-delimited JSON-RPC 2.0 messages over stdio. +/// +/// This Phase 2 skeleton establishes the transport boundary used by MCP +/// clients. It supports `initialize`, `ping`, `tools/list`, `tools/call`, and an +/// explicit `echo` method for smoke tests. +pub fn serve_stdio(dry_run: bool) -> anyhow::Result<()> { + serve(std::io::stdin().lock(), std::io::stdout().lock(), dry_run) +} + +fn serve(reader: R, writer: W, dry_run: bool) -> anyhow::Result<()> +where + R: BufRead, + W: Write, +{ + let ctx = McpContext::from_env(dry_run)?; + let mut writer = writer; + + for line in reader.lines() { + let line = line?; + if line.trim().is_empty() { + continue; + } + + let response = match serde_json::from_str::(&line) { + Ok(request) => dispatch_json_rpc_with_context(request, &ctx), + Err(err) => Some(json_rpc_error( + serde_json::Value::Null, + -32700, + "Parse error", + err, + )), + }; + + if let Some(response) = response { + serde_json::to_writer(&mut writer, &response)?; + writeln!(writer)?; + writer.flush()?; + } + } + + Ok(()) +} + +fn dispatch_json_rpc(request: serde_json::Value) -> Option { + let ctx = McpContext::from_cwd(false).ok()?; + dispatch_json_rpc_with_context(request, &ctx) +} + +fn dispatch_json_rpc_with_context( + request: serde_json::Value, + ctx: &McpContext, +) -> Option { + let Some(object) = request.as_object() else { + return Some(json_rpc_error( + serde_json::Value::Null, + -32600, + "Invalid Request", + "JSON-RPC request must be an object", + )); + }; + + let id = object.get("id").cloned().unwrap_or(serde_json::Value::Null); + if !is_valid_json_rpc_id(&id) { + return Some(json_rpc_error( + serde_json::Value::Null, + -32600, + "Invalid Request", + "JSON-RPC id must be a string, number, or null", + )); + } + + if object.get("jsonrpc").and_then(serde_json::Value::as_str) != Some("2.0") { + return Some(json_rpc_error( + id, + -32600, + "Invalid Request", + "JSON-RPC version must be exactly '2.0'", + )); + } + + let Some(method) = object.get("method").and_then(serde_json::Value::as_str) else { + return Some(json_rpc_error( + id, + -32600, + "Invalid Request", + "JSON-RPC method must be a string", + )); + }; + + if is_notification_object(object) { + return None; + } + + match method { + "initialize" => Some(json_rpc_result( + id, + serde_json::json!({ + "protocolVersion": "2025-03-26", + "serverInfo": { + "name": "git-parsec", + "version": env!("CARGO_PKG_VERSION"), + }, + "capabilities": initialize_capabilities_payload(), + }), + )), + "ping" => Some(json_rpc_result(id, serde_json::json!({}))), + "shutdown" => Some(json_rpc_result(id, serde_json::Value::Null)), + "tools/list" => match tools_list_payload() { + Ok(payload) => Some(json_rpc_result(id, payload)), + Err(err) => Some(json_rpc_error(id, -32603, "Internal error", err)), + }, + "echo" => Some(json_rpc_result( + id, + request + .get("params") + .cloned() + .unwrap_or(serde_json::Value::Null), + )), + "tools/call" => Some(handle_tools_call(id, request.get("params"), ctx)), + _ => Some(json_rpc_error(id, -32601, "Method not found", method)), + } +} + +fn is_notification_object(object: &serde_json::Map) -> bool { + !object.contains_key("id") +} + +fn is_valid_json_rpc_id(id: &serde_json::Value) -> bool { + id.is_null() || id.is_string() || id.is_number() +} + +fn json_rpc_result(id: serde_json::Value, result: serde_json::Value) -> serde_json::Value { + serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "result": result, + }) +} + +fn json_rpc_error( + id: serde_json::Value, + code: i64, + message: &'static str, + data: impl std::fmt::Display, +) -> serde_json::Value { + serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "error": { + "code": code, + "message": message, + "data": data.to_string(), + }, + }) +} + +fn handle_tools_call( + id: serde_json::Value, + params: Option<&serde_json::Value>, + ctx: &McpContext, +) -> serde_json::Value { + let Some(params) = params.and_then(serde_json::Value::as_object) else { + return json_rpc_error( + id, + -32602, + "Invalid params", + "tools/call params must be an object", + ); + }; + let Some(name) = params.get("name").and_then(serde_json::Value::as_str) else { + return json_rpc_error( + id, + -32602, + "Invalid params", + "tools/call params.name must be a string", + ); + }; + if !params + .get("arguments") + .is_none_or(serde_json::Value::is_object) + { + return json_rpc_error( + id, + -32602, + "Invalid params", + "tools/call params.arguments must be an object when present", + ); + } + if tool_by_name(name).is_none() { + return json_rpc_error( + id, + -32602, + "Invalid params", + format!("unknown MCP tool '{name}'"), + ); + } + let tool = tool_by_name(name).expect("tool was checked above"); + + let arguments = params + .get("arguments") + .cloned() + .unwrap_or_else(|| serde_json::json!({})); + + let dry_run = argument_enabled(&arguments, "dry_run") || ctx.dry_run; + + if let Some(error) = preflight_tool_call(tool, &arguments, ctx) { + emit_audit_event(tool, AuditOutcome::Denied, dry_run, &id); + return json_rpc_result(id, mcp_content_envelope(error, true)); + } + + match tools::dispatch(name, ctx, arguments) { + Ok(payload) => { + emit_audit_event(tool, AuditOutcome::Allowed, dry_run, &id); + json_rpc_result(id, mcp_content_envelope(payload, false)) + } + Err(err) => { + emit_audit_event(tool, AuditOutcome::ToolError, dry_run, &id); + json_rpc_result( + id, + mcp_content_envelope( + serde_json::json!({ + "error": { + "code": "tool_error", + "message": err.to_string(), + "tool": name, + } + }), + true, + ), + ) + } + } +} + +fn preflight_tool_call( + tool: &ToolDef, + arguments: &serde_json::Value, + ctx: &McpContext, +) -> Option { + if !repo_argument_is_within_boundary(arguments, &ctx.repo_path) { + return Some(tool_error_payload( + "SANDBOX_VIOLATION", + format!( + "Tool '{}' requested a repository outside the MCP session boundary.", + tool.name + ), + "Use the repository bound to this MCP server session.".to_owned(), + tool.name, + )); + } + + let dry_run = argument_enabled(arguments, "dry_run") || ctx.dry_run; + if tool.mutating && !dry_run && !argument_enabled(arguments, "confirm") { + return Some(tool_error_payload( + "CONFIRMATION_REQUIRED", + format!("Tool '{}' requires explicit confirmation.", tool.name), + "Preview with dry_run=true, then retry with confirm=true.".to_owned(), + tool.name, + )); + } + + if tool.requires_github && ctx.github_token.is_none() { + return Some(tool_error_payload( + "AUTH_REQUIRED", + format!("Tool '{}' requires a delegated GitHub token.", tool.name), + format!( + "Pass a session token with scopes: {}.", + format_github_scopes(tool.github_scopes) + ), + tool.name, + )); + } + + if ctx.github_token.is_some() { + let required_scopes = requested_github_scopes(tool, arguments); + if !token_has_scopes(ctx.github_scopes.as_deref(), &required_scopes) { + return Some(tool_error_payload( + "INSUFFICIENT_SCOPE", + format!( + "Tool '{}' requires delegated GitHub scopes that were not provided.", + tool.name + ), + format!( + "Pass a session token with scopes: {}.", + format_github_scopes(&required_scopes) + ), + tool.name, + )); + } + } + + if ctx.github_token.is_none() { + let requested_scopes = requested_optional_github_scopes(tool, arguments); + if !requested_scopes.is_empty() { + return Some(tool_error_payload( + "AUTH_REQUIRED", + format!( + "Tool '{}' requested a GitHub-backed overlay without a delegated token.", + tool.name + ), + format!( + "Pass a session token with scopes: {}.", + format_github_scopes(&requested_scopes) + ), + tool.name, + )); + } + } + + None +} + +fn repo_argument_is_within_boundary( + arguments: &serde_json::Value, + boundary: &std::path::Path, +) -> bool { + let Some(requested) = arguments.get("repo").and_then(serde_json::Value::as_str) else { + return true; + }; + + let requested = std::path::Path::new(requested); + let requested = if requested.is_absolute() { + requested.to_path_buf() + } else { + boundary.join(requested) + }; + + match (boundary.canonicalize(), requested.canonicalize()) { + (Ok(boundary), Ok(requested)) => requested.starts_with(boundary), + _ => false, + } +} + +fn requested_github_scopes(tool: &ToolDef, arguments: &serde_json::Value) -> Vec { + let mut scopes = tool.github_scopes.to_vec(); + for scope in requested_optional_github_scopes(tool, arguments) { + if !scopes.contains(&scope) { + scopes.push(scope); + } + } + scopes +} + +fn requested_optional_github_scopes( + tool: &ToolDef, + arguments: &serde_json::Value, +) -> Vec { + let mut scopes = Vec::new(); + + if tool.github_scopes.contains(&GithubScope::PullRequestRead) + && argument_enabled(arguments, "include_pr") + { + scopes.push(GithubScope::PullRequestRead); + } + if tool.github_scopes.contains(&GithubScope::ChecksRead) + && argument_enabled(arguments, "include_ci") + { + scopes.push(GithubScope::ChecksRead); + } + + scopes +} + +fn token_has_scopes(delegated: Option<&[GithubScope]>, required: &[GithubScope]) -> bool { + let Some(delegated) = delegated else { + return true; + }; + + required.iter().all(|scope| { + delegated.contains(scope) + || (*scope == GithubScope::PullRequestRead + && delegated.contains(&GithubScope::PullRequestWrite)) + }) +} + +fn argument_enabled(arguments: &serde_json::Value, name: &str) -> bool { + arguments + .get(name) + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) +} + +fn tool_error_payload( + code: &'static str, + message: String, + detail: String, + tool: &'static str, +) -> serde_json::Value { + serde_json::json!({ + "error": { + "code": code, + "message": message, + "detail": detail, + "tool": tool, + } + }) +} + +fn format_github_scopes(scopes: &[GithubScope]) -> String { + if scopes.is_empty() { + "none".to_owned() + } else { + scopes + .iter() + .map(|scope| scope.as_str()) + .collect::>() + .join(", ") + } +} + +fn mcp_content_envelope(payload: serde_json::Value, is_error: bool) -> serde_json::Value { + serde_json::json!({ + "content": [{ + "type": "text", + "text": payload.to_string(), + }], + "isError": is_error, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + struct FailingAuditSink; + + impl std::io::Write for FailingAuditSink { + fn write(&mut self, _buffer: &[u8]) -> std::io::Result { + Err(std::io::Error::other("audit sink unavailable")) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + #[test] + fn tools_list_is_non_empty() { + assert!(!TOOLS.is_empty(), "TOOLS registry must not be empty"); + } + + #[test] + fn audit_event_contract_excludes_sensitive_call_data() { + let tool = tool_by_name("worktree_ship").expect("registered tool"); + let event = audit_event( + tool, + AuditOutcome::Denied, + false, + &serde_json::json!("call-42"), + ); + let serialized = event.to_string(); + + assert_eq!(event["tool"], "worktree_ship"); + assert_eq!(event["outcome"], "denied"); + assert_eq!(event["correlationId"], "call-42"); + for forbidden in ["token", "arguments", "repo_path", "message"] { + assert!(!serialized.contains(forbidden)); + } + } + + #[test] + fn audit_event_matches_versioned_compatibility_fixture() { + let tool = tool_by_name("worktree_ship").expect("registered tool"); + let actual = audit_event( + tool, + AuditOutcome::Denied, + false, + &serde_json::json!("call-42"), + ); + let expected: serde_json::Value = + serde_json::from_str(include_str!("../../tests/mcp/fixtures/audit_event_v1.json")) + .expect("valid audit compatibility fixture"); + + assert_eq!(actual, expected); + assert_eq!(actual["version"], AUDIT_EVENT_VERSION); + } + + #[test] + fn audit_writer_emits_one_newline_delimited_event() { + let tool = tool_by_name("worktree_ship").expect("registered tool"); + let mut sink = Vec::new(); + + write_audit_event( + &mut sink, + tool, + AuditOutcome::Allowed, + true, + &serde_json::json!(42), + ) + .expect("in-memory audit write"); + + let output = String::from_utf8(sink).expect("UTF-8 audit event"); + assert_eq!(output.lines().count(), 1); + assert!(output.ends_with('\n')); + let event: serde_json::Value = + serde_json::from_str(output.trim_end()).expect("valid JSON audit event"); + assert_eq!(event["outcome"], "allowed"); + assert_eq!(event["dryRun"], true); + assert_eq!(event["correlationId"], 42); + } + + #[test] + fn audit_sink_failure_is_best_effort() { + let tool = tool_by_name("worktree_ship").expect("registered tool"); + let mut sink = FailingAuditSink; + + emit_audit_event_to( + &mut sink, + tool, + AuditOutcome::Allowed, + false, + &serde_json::json!(42), + ); + } + + #[test] + fn audit_correlation_id_rejects_sensitive_or_oversized_strings() { + assert!(audit_correlation_id(&serde_json::json!(42)).is_some()); + assert!(audit_correlation_id(&serde_json::json!("call_42.a")).is_some()); + assert!(audit_correlation_id(&serde_json::json!("token secret")).is_none()); + assert!(audit_correlation_id(&serde_json::json!("x".repeat(65))).is_none()); + assert!(audit_correlation_id(&serde_json::Value::Null).is_none()); + } + + #[test] + fn tool_names_are_snake_case_and_unique() { + let mut seen = std::collections::HashSet::new(); + for tool in TOOLS { + // snake_case: only lowercase letters, digits, underscores + assert!( + tool.name + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_'), + "Tool name '{}' is not snake_case", + tool.name + ); + assert!( + seen.insert(tool.name), + "Duplicate tool name: '{}'", + tool.name + ); + } + } + + #[test] + fn all_tools_have_descriptions() { + for tool in TOOLS { + assert!( + !tool.description.is_empty(), + "Tool '{}' has an empty description", + tool.name + ); + } + } + + #[test] + fn all_tool_schemas_are_valid_json_objects() { + for tool in TOOLS { + let schema: serde_json::Value = serde_json::from_str(tool.input_schema) + .unwrap_or_else(|err| panic!("Tool '{}' has invalid schema: {err}", tool.name)); + assert_eq!( + schema.get("type").and_then(serde_json::Value::as_str), + Some("object"), + "Tool '{}' schema must be an object", + tool.name + ); + assert!( + schema.get("properties").is_some(), + "Tool '{}' schema must declare properties", + tool.name + ); + assert!( + schema.get("required").is_some(), + "Tool '{}' schema must declare required fields", + tool.name + ); + } + } + + #[test] + fn tools_list_payload_matches_registry() { + let payload = tools_list_payload().expect("tools/list payload should render"); + let tools = payload + .get("tools") + .and_then(serde_json::Value::as_array) + .expect("tools/list payload must contain an array"); + + assert_eq!(tools.len(), TOOLS.len()); + for (payload_tool, registry_tool) in tools.iter().zip(TOOLS) { + assert_eq!(payload_tool["name"], registry_tool.name); + assert_eq!(payload_tool["description"], registry_tool.description); + assert!( + payload_tool.get("inputSchema").is_some(), + "Tool '{}' must include inputSchema", + registry_tool.name + ); + } + } + + #[test] + fn mutating_tools_expose_dry_run() { + for tool in TOOLS.iter().filter(|tool| tool.mutating) { + let schema: serde_json::Value = serde_json::from_str(tool.input_schema).unwrap(); + assert!( + schema + .pointer("/properties/dry_run") + .and_then(|value| value.get("type")) + .and_then(serde_json::Value::as_str) + == Some("boolean"), + "Mutating tool '{}' must expose dry_run", + tool.name + ); + } + } + + #[test] + fn mutating_tools_expose_confirmation() { + for tool in TOOLS.iter().filter(|tool| tool.mutating) { + let schema: serde_json::Value = + serde_json::from_str(tool.input_schema).expect("valid tool schema"); + assert_eq!( + schema.pointer("/properties/confirm/type"), + Some(&serde_json::json!("boolean")), + "Mutating tool '{}' must expose confirm", + tool.name + ); + } + } + + #[test] + fn mutation_requires_confirmation_after_preview() { + let ctx = McpContext::from_cwd(false).expect("context"); + let tool = tool_by_name("worktree_start").expect("registered tool"); + + let error = preflight_tool_call(tool, &serde_json::json!({"ticket": "ABC-123"}), &ctx) + .expect("unconfirmed mutation should fail"); + assert_eq!(error["error"]["code"], "CONFIRMATION_REQUIRED"); + + assert!(preflight_tool_call( + tool, + &serde_json::json!({"ticket": "ABC-123", "dry_run": true}), + &ctx + ) + .is_none()); + assert!(preflight_tool_call( + tool, + &serde_json::json!({"ticket": "ABC-123", "confirm": true}), + &ctx + ) + .is_none()); + } + + #[test] + fn github_tools_are_marked() { + let github_tools: std::collections::HashSet<_> = TOOLS + .iter() + .filter(|tool| tool.requires_github) + .map(|tool| tool.name) + .collect(); + + assert!(github_tools.contains("worktree_ship")); + assert!(github_tools.contains("ci_status")); + assert!(github_tools.contains("pr_status")); + assert!(github_tools.contains("reviews")); + } + + #[test] + fn github_tools_declare_scope_metadata() { + for tool in TOOLS.iter().filter(|tool| tool.requires_github) { + assert!( + !tool.github_scopes.is_empty(), + "GitHub tool '{}' must declare minimum scopes", + tool.name + ); + } + } + + #[test] + fn tools_list_exposes_auth_annotations() { + let payload = tools_list_payload().expect("tools/list payload should render"); + let tools = payload["tools"] + .as_array() + .expect("tools/list payload must contain an array"); + let pr_status = tools + .iter() + .find(|tool| tool["name"] == "pr_status") + .expect("pr_status should be listed"); + + assert_eq!(pr_status["annotations"]["requiresGithub"], true); + assert_eq!( + pr_status["annotations"]["githubScopes"], + serde_json::json!(["pull_request:read"]) + ); + } + + #[test] + fn initialize_capabilities_advertise_github_scopes() { + let payload = initialize_capabilities_payload(); + + assert!(payload["tools"].is_object()); + assert_eq!( + payload["gitParsec"]["githubScopes"], + serde_json::json!(["pull_request:read", "checks:read", "pull_request:write"]) + ); + } + + #[test] + fn mcp_context_from_cwd() { + let ctx = McpContext::from_cwd(false).expect("should build context from cwd"); + assert!(!ctx.dry_run); + assert!(ctx.github_token.is_none()); + } + + #[test] + fn mcp_context_with_token() { + let ctx = McpContext::from_cwd(true) + .unwrap() + .with_github_token("ghp_test"); + assert!(ctx.dry_run); + assert!(ctx.github_scopes.is_none()); + assert_eq!( + ctx.github_token + .as_ref() + .map(DelegatedGithubToken::expose_secret), + Some("ghp_test") + ); + } + + #[test] + fn mcp_context_with_scoped_token() { + let ctx = McpContext::from_cwd(false) + .unwrap() + .with_github_auth("ghp_test", vec![GithubScope::PullRequestRead]); + + assert_eq!(ctx.github_scopes, Some(vec![GithubScope::PullRequestRead])); + } + + #[test] + fn mcp_context_from_env_picks_up_token() { + // Use the testable lookup closure to avoid mutating process-global env. + let ctx = McpContext::from_env_lookup(false, |key| { + if key == "PARSEC_GITHUB_TOKEN" { + Some("ghp_env_test".to_owned()) + } else { + None + } + }) + .expect("from_env_lookup"); + + assert!( + ctx.github_token.is_some(), + "token should be populated from PARSEC_GITHUB_TOKEN" + ); + assert_eq!( + ctx.github_token + .as_ref() + .map(DelegatedGithubToken::expose_secret), + Some("ghp_env_test") + ); + // No declared scopes when loaded from env (all scope checks pass via + // the None-means-allow shortcut in token_has_scopes). + assert!( + ctx.github_scopes.is_none(), + "env-loaded token should have no declared scopes" + ); + } + + #[test] + fn mcp_context_from_env_ignores_missing_var() { + let ctx = McpContext::from_env_lookup(false, |_| None).expect("from_env_lookup no var"); + assert!( + ctx.github_token.is_none(), + "no env var → token should remain None" + ); + } + + #[test] + fn mcp_context_from_env_ignores_empty_var() { + let ctx = McpContext::from_env_lookup(false, |key| { + if key == "PARSEC_GITHUB_TOKEN" { + Some(String::new()) + } else { + None + } + }) + .expect("from_env_lookup empty"); + assert!( + ctx.github_token.is_none(), + "empty env var should not populate token" + ); + } + + // ------------------------------------------------------------------ // + // Phase 36: from_env_and_config_lookup tests + // ------------------------------------------------------------------ // + + #[test] + fn mcp_config_file_token_loaded_when_no_env_var() { + let toml_contents = "[auth]\ntoken = \"ghp_config_test\"\n"; + let ctx = McpContext::from_env_and_config_lookup( + false, + |_| None, // no env vars set + |_| Some(toml_contents.to_owned()), + ) + .expect("from_env_and_config_lookup"); + + assert!( + ctx.github_token.is_some(), + "token should be populated from config file" + ); + assert_eq!( + ctx.github_token + .as_ref() + .map(DelegatedGithubToken::expose_secret), + Some("ghp_config_test") + ); + // No declared scopes when the config omits the `scopes` key. + assert!( + ctx.github_scopes.is_none(), + "missing scopes key should leave github_scopes as None" + ); + } + + #[test] + fn mcp_config_file_token_with_scopes() { + let toml_contents = + "[auth]\ntoken = \"ghp_scoped\"\nscopes = [\"pull_request:read\", \"checks:read\"]\n"; + let ctx = McpContext::from_env_and_config_lookup( + false, + |_| None, + |_| Some(toml_contents.to_owned()), + ) + .expect("from_env_and_config_lookup with scopes"); + + let scopes = ctx + .github_scopes + .as_deref() + .expect("scopes should be populated"); + assert!(scopes.contains(&GithubScope::PullRequestRead)); + assert!(scopes.contains(&GithubScope::ChecksRead)); + assert!(!scopes.contains(&GithubScope::PullRequestWrite)); + } + + #[test] + fn env_var_wins_over_config_file() { + // Env var must take priority; config file provides a different token. + let toml_contents = "[auth]\ntoken = \"ghp_from_config\"\n"; + let ctx = McpContext::from_env_and_config_lookup( + false, + |key| { + if key == "PARSEC_GITHUB_TOKEN" { + Some("ghp_from_env".to_owned()) + } else { + None + } + }, + |_| Some(toml_contents.to_owned()), + ) + .expect("env wins over config"); + + assert_eq!( + ctx.github_token + .as_ref() + .map(DelegatedGithubToken::expose_secret), + Some("ghp_from_env"), + "env var token must win over config-file token" + ); + } + + #[test] + fn parsec_mcp_config_overrides_default_path() { + // When PARSEC_MCP_CONFIG is set we should pass that exact path to + // the file reader; verify via path tracking. + use std::cell::Cell; + let custom_path = std::path::Path::new("/custom/path/mcp.toml"); + let path_seen = Cell::new(false); + + let _ctx = McpContext::from_env_and_config_lookup( + false, + |key| { + if key == "PARSEC_MCP_CONFIG" { + Some("/custom/path/mcp.toml".to_owned()) + } else { + None + } + }, + |path| { + if path == custom_path { + path_seen.set(true); + } + None // file does not exist; that is fine + }, + ) + .expect("config path override"); + + assert!( + path_seen.get(), + "PARSEC_MCP_CONFIG should route file_read to the custom path" + ); + } + + #[test] + fn mcp_config_missing_file_leaves_token_none() { + // Simulates a fresh machine with no config file present. + let ctx = McpContext::from_env_and_config_lookup( + false, + |_| None, + |_| None, // file does not exist + ) + .expect("missing config file is not an error"); + + assert!( + ctx.github_token.is_none(), + "missing config file should leave token as None" + ); + } + + #[test] + fn mcp_config_unknown_scopes_are_skipped() { + // Unknown scope strings must not panic and must be filtered out. + let toml_contents = + "[auth]\ntoken = \"ghp_scoped\"\nscopes = [\"unknown:future\", \"pull_request:read\"]\n"; + let ctx = McpContext::from_env_and_config_lookup( + false, + |_| None, + |_| Some(toml_contents.to_owned()), + ) + .expect("unknown scope is skipped"); + + let scopes = ctx + .github_scopes + .as_deref() + .expect("known scope should survive filtering"); + assert_eq!(scopes.len(), 1); + assert!(scopes.contains(&GithubScope::PullRequestRead)); + } + + #[test] + fn delegated_token_debug_is_redacted() { + let token = DelegatedGithubToken::new("ghp_do_not_log"); + + let debug = format!("{token:?}"); + + assert!(debug.contains("")); + assert!(!debug.contains("ghp_do_not_log")); + } + + #[test] + fn initialize_returns_server_capabilities() { + let request = serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": {} + }); + + let response = dispatch_json_rpc(request).expect("initialize should produce a response"); + + assert_eq!(response["jsonrpc"], "2.0"); + assert_eq!(response["id"], 1); + assert_eq!(response["result"]["serverInfo"]["name"], "git-parsec"); + assert!(response["result"]["capabilities"]["tools"].is_object()); + assert_eq!( + response["result"]["capabilities"]["gitParsec"]["githubScopes"], + serde_json::json!(["pull_request:read", "checks:read", "pull_request:write"]) + ); + } + + #[test] + fn tools_list_method_uses_registry_payload() { + let request = serde_json::json!({ + "jsonrpc": "2.0", + "id": "tools", + "method": "tools/list" + }); + + let response = dispatch_json_rpc(request).expect("tools/list should produce a response"); + let tools = response["result"]["tools"] + .as_array() + .expect("tools/list should return an array"); + + assert_eq!(tools.len(), TOOLS.len()); + assert_eq!(tools[0]["name"], TOOLS[0].name); + } + + #[test] + fn ping_returns_empty_result_for_client_health_checks() { + let request = serde_json::json!({ + "jsonrpc": "2.0", + "id": "ping", + "method": "ping" + }); + + let response = dispatch_json_rpc(request).expect("ping should produce a response"); + + assert_eq!(response["jsonrpc"], "2.0"); + assert_eq!(response["id"], "ping"); + assert_eq!(response["result"], serde_json::json!({})); + } + + #[test] + fn shutdown_returns_null_result_for_client_lifecycle() { + let request = serde_json::json!({ + "jsonrpc": "2.0", + "id": "shutdown", + "method": "shutdown" + }); + + let response = dispatch_json_rpc(request).expect("shutdown should produce a response"); + + assert_eq!(response["jsonrpc"], "2.0"); + assert_eq!(response["id"], "shutdown"); + assert_eq!(response["result"], serde_json::Value::Null); + } + + #[test] + fn echo_round_trips_params_for_stdio_smoke_tests() { + let request = serde_json::json!({ + "jsonrpc": "2.0", + "id": 7, + "method": "echo", + "params": {"ok": true} + }); + + let response = dispatch_json_rpc(request).expect("echo should produce a response"); + + assert_eq!(response["result"], serde_json::json!({"ok": true})); + } + + #[test] + fn unknown_method_returns_json_rpc_error() { + let request = serde_json::json!({ + "jsonrpc": "2.0", + "id": 9, + "method": "parsec/nope" + }); + + let response = + dispatch_json_rpc(request).expect("unknown method should produce a response"); + + assert_eq!(response["error"]["code"], -32601); + assert_eq!(response["error"]["message"], "Method not found"); + } + + #[test] + fn malformed_request_shape_returns_invalid_request() { + let response = + dispatch_json_rpc(serde_json::json!([])).expect("invalid request should respond"); + + assert_eq!(response["id"], serde_json::Value::Null); + assert_eq!(response["error"]["code"], -32600); + assert_eq!(response["error"]["message"], "Invalid Request"); + } + + #[test] + fn malformed_request_id_returns_invalid_request() { + let request = serde_json::json!({ + "jsonrpc": "2.0", + "id": {"nested": true}, + "method": "initialize" + }); + + let response = dispatch_json_rpc(request).expect("invalid id should respond"); + + assert_eq!(response["id"], serde_json::Value::Null); + assert_eq!(response["error"]["code"], -32600); + assert_eq!(response["error"]["message"], "Invalid Request"); + } + + #[test] + fn malformed_json_rpc_version_returns_invalid_request() { + let request = serde_json::json!({ + "jsonrpc": "1.0", + "id": "bad-version", + "method": "initialize" + }); + + let response = dispatch_json_rpc(request).expect("invalid version should respond"); + + assert_eq!(response["id"], "bad-version"); + assert_eq!(response["error"]["code"], -32600); + assert_eq!(response["error"]["message"], "Invalid Request"); + assert!(response["error"]["data"] + .as_str() + .is_some_and(|text| text.contains("JSON-RPC version"))); + } + + #[test] + fn malformed_request_method_returns_invalid_request() { + let request = serde_json::json!({ + "jsonrpc": "2.0", + "id": "bad-method", + "method": 123 + }); + + let response = dispatch_json_rpc(request).expect("invalid method should respond"); + + assert_eq!(response["id"], "bad-method"); + assert_eq!(response["error"]["code"], -32600); + assert_eq!(response["error"]["message"], "Invalid Request"); + } + + #[test] + fn tools_call_validates_registered_tool_name() { + let request = serde_json::json!({ + "jsonrpc": "2.0", + "id": "call", + "method": "tools/call", + "params": { + "name": "nope", + "arguments": {} + } + }); + + let response = dispatch_json_rpc(request).expect("tools/call should produce a response"); + + assert_eq!(response["error"]["code"], -32602); + assert_eq!(response["error"]["message"], "Invalid params"); + } + + #[test] + fn tools_call_returns_mcp_error_envelope_until_dispatch_lands() { + let request = serde_json::json!({ + "jsonrpc": "2.0", + "id": "call", + "method": "tools/call", + "params": { + "name": "worktree_status", + "arguments": {"ticket": "ABC-123"} + } + }); + + let response = dispatch_json_rpc(request).expect("tools/call should produce a response"); + + assert_eq!(response["id"], "call"); + assert_eq!(response["result"]["isError"], true); + assert_eq!(response["result"]["content"][0]["type"], "text"); + assert!(response["result"]["content"][0]["text"] + .as_str() + .is_some_and(|text| text.contains("\"tool\":\"worktree_status\""))); + } + + #[test] + fn optional_github_overlay_requires_token_when_requested() { + let request = serde_json::json!({ + "jsonrpc": "2.0", + "id": "overlay-auth", + "method": "tools/call", + "params": { + "name": "worktree_list", + "arguments": {"include_pr": true, "include_ci": true} + } + }); + + let response = dispatch_json_rpc(request).expect("tools/call should produce a response"); + let text = response["result"]["content"][0]["text"] + .as_str() + .expect("MCP error envelope should contain text"); + + assert_eq!(response["id"], "overlay-auth"); + assert_eq!(response["result"]["isError"], true); + assert!(text.contains("AUTH_REQUIRED")); + assert!(text.contains("pull_request:read")); + assert!(text.contains("checks:read")); + } + + #[test] + fn optional_github_overlay_allows_delegated_token() { + let ctx = McpContext::from_cwd(false) + .expect("context") + .with_github_token("ghp_test"); + let request = serde_json::json!({ + "jsonrpc": "2.0", + "id": "overlay-auth-ok", + "method": "tools/call", + "params": { + "name": "worktree_list", + "arguments": {"include_pr": true} + } + }); + + let response = dispatch_json_rpc_with_context(request, &ctx) + .expect("tools/call should produce a response"); + + assert_eq!(response["id"], "overlay-auth-ok"); + assert!( + response.get("error").is_none(), + "delegated token should pass overlay preflight" + ); + } + + #[test] + fn scoped_delegated_token_rejects_missing_scope() { + let ctx = McpContext::from_cwd(false) + .expect("context") + .with_github_auth("ghp_test", vec![GithubScope::PullRequestRead]); + let request = serde_json::json!({ + "jsonrpc": "2.0", + "id": "ci-scope", + "method": "tools/call", + "params": { + "name": "ci_status", + "arguments": {"ticket": "ABC-123"} + } + }); + + let response = dispatch_json_rpc_with_context(request, &ctx) + .expect("tools/call should produce a response"); + let text = response["result"]["content"][0]["text"] + .as_str() + .expect("MCP error envelope should contain text"); + + assert_eq!(response["id"], "ci-scope"); + assert_eq!(response["result"]["isError"], true); + assert!(text.contains("INSUFFICIENT_SCOPE")); + assert!(text.contains("checks:read")); + } + + #[test] + fn pull_request_write_scope_satisfies_read_requirement() { + let ctx = McpContext::from_cwd(false) + .expect("context") + .with_github_auth("ghp_test", vec![GithubScope::PullRequestWrite]); + let request = serde_json::json!({ + "jsonrpc": "2.0", + "id": "pr-scope", + "method": "tools/call", + "params": { + "name": "pr_status", + "arguments": {"ticket": "ABC-123"} + } + }); + + let response = dispatch_json_rpc_with_context(request, &ctx) + .expect("tools/call should produce a response"); + + assert_eq!(response["id"], "pr-scope"); + assert!( + response["result"]["content"][0]["text"] + .as_str() + .is_some_and(|text| !text.contains("INSUFFICIENT_SCOPE")), + "write scope should satisfy pull request read preflight" + ); + } + + #[test] + fn repository_argument_cannot_escape_session_boundary() { + let boundary = tempfile::tempdir().expect("temporary repository boundary"); + let outside = tempfile::tempdir().expect("outside directory"); + let ctx = McpContext { + repo_path: boundary.path().to_path_buf(), + github_token: None, + github_scopes: None, + dry_run: false, + }; + let request = serde_json::json!({ + "jsonrpc": "2.0", + "id": "sandbox", + "method": "tools/call", + "params": { + "name": "worktree_list", + "arguments": {"repo": outside.path()} + } + }); + + let response = dispatch_json_rpc_with_context(request, &ctx) + .expect("tools/call should produce a response"); + let text = response["result"]["content"][0]["text"] + .as_str() + .expect("MCP error envelope should contain text"); + + assert_eq!(response["result"]["isError"], true); + assert!(text.contains("SANDBOX_VIOLATION")); + assert!(!text.contains(&outside.path().display().to_string())); + } + + #[test] + fn repository_argument_allows_descendant_of_session_boundary() { + let boundary = tempfile::tempdir().expect("temporary repository boundary"); + let child = boundary.path().join("worktree"); + std::fs::create_dir(&child).expect("child directory"); + + assert!(repo_argument_is_within_boundary( + &serde_json::json!({"repo": child}), + boundary.path(), + )); + } + + #[test] + fn notifications_do_not_produce_responses() { + let request = serde_json::json!({ + "jsonrpc": "2.0", + "method": "notifications/initialized", + "params": {} + }); + + assert!(dispatch_json_rpc(request).is_none()); + } + + #[test] + fn stdio_recording_fixtures_match_dispatcher() { + let fixture_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/mcp/fixtures/stdio_smoke.jsonl"); + let fixture = std::fs::read_to_string(&fixture_path) + .unwrap_or_else(|err| panic!("failed to read {}: {err}", fixture_path.display())); + let mut fixture_names = std::collections::HashSet::new(); + + for (line_no, line) in fixture.lines().enumerate() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + + let record: serde_json::Value = serde_json::from_str(trimmed) + .unwrap_or_else(|err| panic!("invalid fixture line {}: {err}", line_no + 1)); + let name = record + .get("name") + .and_then(serde_json::Value::as_str) + .unwrap_or(""); + validate_stdio_fixture_record(name, &record, line_no + 1, &mut fixture_names); + let request = record + .get("request") + .cloned() + .unwrap_or_else(|| panic!("fixture '{name}' missing request")); + let ctx = context_from_stdio_fixture_record(name, &record); + let response = dispatch_json_rpc_with_context(request, &ctx); + if record + .get("no_response") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) + { + assert!( + response.is_none(), + "fixture '{name}' expected no JSON-RPC response" + ); + continue; + } + + let response = + response.unwrap_or_else(|| panic!("fixture '{name}' expected a JSON-RPC response")); + let assertions = record + .get("assertions") + .and_then(serde_json::Value::as_array) + .unwrap_or_else(|| panic!("fixture '{name}' missing assertions array")); + + for assertion in assertions { + assert_fixture_assertion(name, &response, assertion); + } + } + } + + #[test] + fn stdio_recording_fixtures_are_redacted() { + let fixture_dir = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/mcp/fixtures"); + let forbidden_fragments = [ + "ghp_", + "github_pat_", + "Bearer ", + "Authorization", + "/Users/", + "/home/", + "C:\\Users\\", + ]; + + for entry in std::fs::read_dir(&fixture_dir) + .unwrap_or_else(|err| panic!("failed to read {}: {err}", fixture_dir.display())) + { + let path = entry + .expect("fixture directory entry should be readable") + .path(); + if path.extension().and_then(std::ffi::OsStr::to_str) != Some("jsonl") { + continue; + } + + let fixture = std::fs::read_to_string(&path) + .unwrap_or_else(|err| panic!("failed to read {}: {err}", path.display())); + for fragment in forbidden_fragments { + assert!( + !fixture.contains(fragment), + "fixture {} contains unredacted sensitive fragment '{}'", + path.display(), + fragment + ); + } + } + } + + fn validate_stdio_fixture_record( + name: &str, + record: &serde_json::Value, + line_no: usize, + fixture_names: &mut std::collections::HashSet, + ) { + assert_ne!( + name, "", + "fixture line {line_no} must declare a string name" + ); + assert!( + fixture_names.insert(name.to_owned()), + "fixture name '{name}' is duplicated" + ); + assert!( + record.get("request").is_some(), + "fixture '{name}' must declare a request" + ); + if let Some(context) = record.get("context") { + assert!( + context.is_object(), + "fixture '{name}' context must be an object" + ); + } + + let expects_no_response = record + .get("no_response") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + let assertions = record + .get("assertions") + .and_then(serde_json::Value::as_array); + + if expects_no_response { + assert!( + assertions.is_none(), + "fixture '{name}' cannot combine no_response with assertions" + ); + } else { + assert!( + assertions.is_some_and(|items| !items.is_empty()), + "fixture '{name}' must declare at least one assertion" + ); + } + } + + fn context_from_stdio_fixture_record(name: &str, record: &serde_json::Value) -> McpContext { + let mut ctx = McpContext::from_cwd(false).expect("fixture context should load cwd"); + let Some(github) = record + .get("context") + .and_then(|context| context.get("github")) + else { + return ctx; + }; + + let token = github + .get("token") + .and_then(serde_json::Value::as_str) + .unwrap_or_else(|| panic!("fixture '{name}' github context missing token")); + let scopes = github + .get("scopes") + .and_then(serde_json::Value::as_array) + .unwrap_or_else(|| panic!("fixture '{name}' github context missing scopes")) + .iter() + .map(|scope| { + let scope = scope + .as_str() + .unwrap_or_else(|| panic!("fixture '{name}' github scope must be a string")); + github_scope_from_fixture(name, scope) + }) + .collect(); + + ctx.github_token = Some(DelegatedGithubToken::new(token)); + ctx.github_scopes = Some(scopes); + ctx + } + + fn github_scope_from_fixture(name: &str, scope: &str) -> GithubScope { + match scope { + "pull_request:read" => GithubScope::PullRequestRead, + "checks:read" => GithubScope::ChecksRead, + "pull_request:write" => GithubScope::PullRequestWrite, + _ => panic!("fixture '{name}' has unsupported GitHub scope '{scope}'"), + } + } + + fn assert_fixture_assertion( + name: &str, + response: &serde_json::Value, + assertion: &serde_json::Value, + ) { + let pointer = assertion + .get("pointer") + .and_then(serde_json::Value::as_str) + .unwrap_or_else(|| panic!("fixture '{name}' assertion missing pointer")); + let actual = response + .pointer(pointer) + .unwrap_or_else(|| panic!("fixture '{name}' pointer '{pointer}' did not match")); + let has_matcher = assertion.get("equals").is_some() + || assertion.get("min_len").is_some() + || assertion.get("kind").is_some() + || assertion.get("contains_text").is_some() + || assertion.get("contains_tool").is_some(); + assert!( + has_matcher, + "fixture '{name}' assertion for {pointer} must declare a matcher" + ); + + if let Some(expected) = assertion.get("equals") { + assert_eq!( + actual, expected, + "fixture '{name}' expected {pointer} to equal {expected}" + ); + } + + if let Some(min_len) = assertion.get("min_len").and_then(serde_json::Value::as_u64) { + let actual_len = actual + .as_array() + .unwrap_or_else(|| panic!("fixture '{name}' pointer '{pointer}' is not an array")) + .len() as u64; + assert!( + actual_len >= min_len, + "fixture '{name}' expected {pointer} length >= {min_len}, got {actual_len}" + ); + } + + if let Some(kind) = assertion.get("kind").and_then(serde_json::Value::as_str) { + let matches = match kind { + "array" => actual.is_array(), + "boolean" => actual.is_boolean(), + "null" => actual.is_null(), + "number" => actual.is_number(), + "object" => actual.is_object(), + "string" => actual.is_string(), + _ => panic!("fixture '{name}' has unsupported kind '{kind}'"), + }; + assert!( + matches, + "fixture '{name}' expected {pointer} to be {kind}, got {actual}" + ); + } + + if let Some(needle) = assertion + .get("contains_text") + .and_then(serde_json::Value::as_str) + { + let actual_text = actual + .as_str() + .unwrap_or_else(|| panic!("fixture '{name}' pointer '{pointer}' is not a string")); + assert!( + actual_text.contains(needle), + "fixture '{name}' expected {pointer} to contain '{needle}', got '{actual_text}'" + ); + } + + if let Some(tool_name) = assertion + .get("contains_tool") + .and_then(serde_json::Value::as_str) + { + let tools = actual + .as_array() + .unwrap_or_else(|| panic!("fixture '{name}' pointer '{pointer}' is not an array")); + assert!( + tools + .iter() + .any(|tool| tool.get("name").and_then(serde_json::Value::as_str) + == Some(tool_name)), + "fixture '{name}' expected {pointer} to contain tool '{tool_name}'" + ); + } + } + + // ------------------------------------------------------------------ // + // Phase 37: config-file → scope-gate enforcement pipeline tests + // + // These tests exercise the full pipeline: + // from_env_and_config_lookup (config TOML with scopes) + // → McpContext (github_token + github_scopes) + // → dispatch_json_rpc_with_context + // → preflight_tool_call (scope enforcement) + // → INSUFFICIENT_SCOPE / tool_error + // + // Phase 36 tests verified loading; Phase 37 tests verify gating. + // ------------------------------------------------------------------ // + + /// Config file declares only `pull_request:read`; `ci_status` needs + /// `checks:read` → scope gate must deny with INSUFFICIENT_SCOPE. + #[test] + fn config_file_limited_scope_enforces_insufficient_scope_on_tool_call() { + let toml = "[auth]\ntoken = \"ghp_limited\"\nscopes = [\"pull_request:read\"]\n"; + let ctx = + McpContext::from_env_and_config_lookup(false, |_| None, |_| Some(toml.to_owned())) + .expect("context with limited scopes"); + + // Confirm the context has the expected scopes. + assert_eq!( + ctx.github_scopes.as_deref(), + Some(&[GithubScope::PullRequestRead][..]), + ); + + let request = serde_json::json!({ + "jsonrpc": "2.0", + "id": "ph37-ci-scope", + "method": "tools/call", + "params": {"name": "ci_status", "arguments": {"ticket": "PROJ-1"}} + }); + let response = + dispatch_json_rpc_with_context(request, &ctx).expect("tools/call should respond"); + + let text = response["result"]["content"][0]["text"] + .as_str() + .expect("MCP error envelope text"); + assert_eq!(response["result"]["isError"], true); + assert!( + text.contains("INSUFFICIENT_SCOPE"), + "expected INSUFFICIENT_SCOPE, got: {text}" + ); + assert!( + text.contains("checks:read"), + "error must name missing scope, got: {text}" + ); + } + + /// Config file declares `pull_request:write`; `reviews` needs + /// `pull_request:read` — write implies read, so preflight passes; + /// the response should be `tool_error` (from gh CLI), not INSUFFICIENT_SCOPE. + #[test] + fn config_file_write_scope_implies_read_for_reviews_preflight() { + let toml = "[auth]\ntoken = \"ghp_writer\"\nscopes = [\"pull_request:write\"]\n"; + let ctx = + McpContext::from_env_and_config_lookup(false, |_| None, |_| Some(toml.to_owned())) + .expect("context with write scope"); + + let request = serde_json::json!({ + "jsonrpc": "2.0", + "id": "ph37-reviews-write", + "method": "tools/call", + "params": {"name": "reviews", "arguments": {}} + }); + let response = + dispatch_json_rpc_with_context(request, &ctx).expect("tools/call should respond"); + + let text = response["result"]["content"][0]["text"] + .as_str() + .expect("MCP envelope text"); + // Preflight must pass; tool fails with tool_error (gh CLI not available). + assert!( + !text.contains("INSUFFICIENT_SCOPE"), + "write scope should satisfy read for reviews preflight, got: {text}" + ); + assert!( + text.contains("tool_error"), + "expected tool_error from gh CLI, got: {text}" + ); + } + + /// Config file declares all three scopes; `worktree_ship` with + /// `dry_run=true` should pass the scope gate and the confirmation gate, + /// returning a `tool_error` from the underlying gh/git execution. + #[test] + fn config_file_full_scopes_pass_preflight_for_mutating_tool() { + let toml = [ + "[auth]", + "token = \"ghp_full\"", + "scopes = [\"pull_request:read\", \"checks:read\", \"pull_request:write\"]", + ] + .join("\n"); + let ctx = McpContext::from_env_and_config_lookup(false, |_| None, |_| Some(toml.clone())) + .expect("context with full scopes"); + + // Verify scope set. + let scopes = ctx.github_scopes.as_deref().expect("scopes populated"); + assert!(scopes.contains(&GithubScope::PullRequestRead)); + assert!(scopes.contains(&GithubScope::ChecksRead)); + assert!(scopes.contains(&GithubScope::PullRequestWrite)); + + // worktree_ship with dry_run=true passes confirmation gate too. + let request = serde_json::json!({ + "jsonrpc": "2.0", + "id": "ph37-ship-full", + "method": "tools/call", + "params": {"name": "worktree_ship", "arguments": {"ticket": "PROJ-1", "dry_run": true}} + }); + let response = + dispatch_json_rpc_with_context(request, &ctx).expect("tools/call should respond"); + + let text = response["result"]["content"][0]["text"] + .as_str() + .expect("MCP envelope text"); + // Must not fail at preflight. + assert!( + !text.contains("INSUFFICIENT_SCOPE"), + "full scopes should satisfy worktree_ship preflight, got: {text}" + ); + assert!( + !text.contains("AUTH_REQUIRED"), + "full scopes should satisfy auth gate, got: {text}" + ); + assert!( + !text.contains("CONFIRMATION_REQUIRED"), + "dry_run=true should satisfy confirmation gate, got: {text}" + ); + } + + /// Config file has `pull_request:read` only; `worktree_ship` needs + /// `pull_request:write` → INSUFFICIENT_SCOPE even with dry_run=true. + #[test] + fn config_file_read_only_scope_blocks_worktree_ship() { + let toml = "[auth]\ntoken = \"ghp_readonly\"\nscopes = [\"pull_request:read\"]\n"; + let ctx = + McpContext::from_env_and_config_lookup(false, |_| None, |_| Some(toml.to_owned())) + .expect("context with read-only scope"); + + let request = serde_json::json!({ + "jsonrpc": "2.0", + "id": "ph37-ship-readonly", + "method": "tools/call", + "params": {"name": "worktree_ship", "arguments": {"ticket": "PROJ-1", "dry_run": true}} + }); + let response = + dispatch_json_rpc_with_context(request, &ctx).expect("tools/call should respond"); + + let text = response["result"]["content"][0]["text"] + .as_str() + .expect("MCP envelope text"); + assert_eq!(response["result"]["isError"], true); + assert!( + text.contains("INSUFFICIENT_SCOPE"), + "read-only scope must not satisfy write requirement, got: {text}" + ); + assert!( + text.contains("pull_request:write"), + "error must name the missing scope, got: {text}" + ); + } +} diff --git a/src/mcp/tools/ci.rs b/src/mcp/tools/ci.rs new file mode 100644 index 0000000..cc7d177 --- /dev/null +++ b/src/mcp/tools/ci.rs @@ -0,0 +1,439 @@ +//! MCP tool handler for `ci_status`. +//! +//! Phase 28 (#293): wires `ci_status` to real GitHub Actions check-run data +//! via `gh pr view --json statusCheckRollup`. Read-only; requires `gh` on +//! PATH with valid credentials (keyring or `GITHUB_TOKEN` / `GH_TOKEN`). +//! +//! Auth tokens injected via `McpContext.github_token` are forwarded as +//! `GH_TOKEN` — see `docs/mcp/auth.md`. +//! +//! ## Input +//! +//! ```json +//! { "ticket": "PROJ-123", "repo": "/optional/path", "limit": 10 } +//! ``` +//! +//! ## Output shape +//! +//! ```json +//! { +//! "ticket": "PROJ-123", +//! "pr_number": 42, +//! "branch": "feat/PROJ-123", +//! "overall": "passing", +//! "check_count": 3, +//! "checks": [ +//! { +//! "name": "CI / build", +//! "status": "completed", +//! "conclusion": "success", +//! "started_at": "2026-08-21T00:35:00Z", +//! "completed_at": "2026-08-21T00:37:00Z", +//! "details_url": "https://github.com/..." +//! } +//! ] +//! } +//! ``` +//! +//! `overall` is one of `"passing"`, `"failing"`, `"pending"`, `"no_checks"`. +//! +//! Both GraphQL `CheckRun` (has `status`/`conclusion`) and `StatusContext` +//! (has `state`) objects are handled uniformly. + +use anyhow::{bail, Context, Result}; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use crate::config::ParsecConfig; +use crate::mcp::McpContext; +use crate::worktree::WorktreeManager; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const DEFAULT_LIMIT: usize = 10; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn repo_path(ctx: &McpContext, input: &Value) -> PathBuf { + input + .get("repo") + .and_then(Value::as_str) + .map(PathBuf::from) + .unwrap_or_else(|| ctx.repo_path.clone()) +} + +// --------------------------------------------------------------------------- +// gh CLI types +// --------------------------------------------------------------------------- + +/// Raw response from `gh pr view --json number,statusCheckRollup`. +#[derive(Debug, Deserialize)] +struct GhPrCiView { + number: u64, + /// Mixed `CheckRun` + `StatusContext` entries from the GraphQL rollup. + #[serde(rename = "statusCheckRollup", default)] + status_check_rollup: Vec, +} + +/// Unified deserialization for both GraphQL `CheckRun` and `StatusContext` +/// objects that appear in `statusCheckRollup`. +/// +/// - `CheckRun` carries `name`, `status`, `conclusion`, `startedAt`, +/// `completedAt`, `detailsUrl`. +/// - `StatusContext` carries `context` (display name), `state` +/// (terminal result), and `targetUrl`. +#[derive(Debug, Deserialize)] +struct GhCheckEntry { + /// Display name (CheckRun). Empty for StatusContext. + #[serde(default)] + name: String, + /// Context key used as display name for StatusContext. Empty for CheckRun. + #[serde(default)] + context: String, + /// CheckRun lifecycle: "COMPLETED", "IN_PROGRESS", "QUEUED", … + #[serde(default)] + status: String, + /// CheckRun outcome: "SUCCESS", "FAILURE", "CANCELLED", "SKIPPED", … + conclusion: Option, + /// StatusContext terminal state: "SUCCESS", "FAILURE", "PENDING", "ERROR". + state: Option, + #[serde(rename = "startedAt")] + started_at: Option, + #[serde(rename = "completedAt")] + completed_at: Option, + #[serde(rename = "detailsUrl")] + details_url: Option, + #[serde(rename = "targetUrl")] + target_url: Option, +} + +impl GhCheckEntry { + fn display_name(&self) -> &str { + if !self.name.is_empty() { + &self.name + } else { + &self.context + } + } + + /// Normalised lifecycle status (lowercase). + fn normalised_status(&self) -> &'static str { + if !self.status.is_empty() { + normalise_check_status(&self.status) + } else { + // StatusContext: derive lifecycle from terminal state. + match self.state.as_deref() { + Some("SUCCESS") | Some("FAILURE") | Some("ERROR") => "completed", + Some("PENDING") => "pending", + _ => "unknown", + } + } + } + + /// Normalised conclusion string (lowercase), if available. + fn normalised_conclusion(&self) -> Option { + if let Some(c) = &self.conclusion { + Some(c.to_ascii_lowercase()) + } else { + // StatusContext: map terminal state to a conclusion-like value. + match self.state.as_deref() { + Some("SUCCESS") => Some("success".to_owned()), + Some("FAILURE") | Some("ERROR") => Some("failure".to_owned()), + _ => None, + } + } + } + + fn url(&self) -> Option<&str> { + self.details_url.as_deref().or(self.target_url.as_deref()) + } +} + +/// Map a CheckRun `status` string to a normalised lowercase value. +fn normalise_check_status(s: &str) -> &'static str { + let upper = s.to_ascii_uppercase(); + match upper.as_str() { + "COMPLETED" => "completed", + "IN_PROGRESS" => "in_progress", + "QUEUED" | "PENDING" | "REQUESTED" | "WAITING" => "pending", + _ => "unknown", + } +} + +/// Derive the overall CI status from all rollup entries. +/// +/// - Any failure/timeout → `"failing"` +/// - All concluded successfully (success/neutral/skipped) → `"passing"` +/// - Some still running or queued → `"pending"` +/// - No entries → `"no_checks"` +fn derive_overall(checks: &[GhCheckEntry]) -> &'static str { + if checks.is_empty() { + return "no_checks"; + } + if checks.iter().any(|c| { + matches!( + c.normalised_conclusion().as_deref(), + Some("failure") | Some("timed_out") | Some("action_required") | Some("error") + ) + }) { + return "failing"; + } + if checks.iter().all(|c| { + matches!( + c.normalised_conclusion().as_deref(), + Some("success") | Some("neutral") | Some("skipped") + ) + }) { + return "passing"; + } + "pending" +} + +// --------------------------------------------------------------------------- +// gh CLI call +// --------------------------------------------------------------------------- + +fn fetch_ci(repo: &Path, branch: &str, token: Option<&str>) -> Result { + let mut cmd = Command::new("gh"); + cmd.args(["pr", "view", branch, "--json", "number,statusCheckRollup"]) + .current_dir(repo); + + if let Some(t) = token { + cmd.env("GH_TOKEN", t); + } + + let output = cmd.output().context("failed to spawn 'gh pr view'")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + bail!( + "gh pr view failed for branch '{}': {}", + branch, + stderr.trim() + ); + } + + let raw = String::from_utf8(output.stdout).context("gh pr view produced non-UTF-8 output")?; + serde_json::from_str::(&raw).context("failed to parse gh pr view JSON") +} + +// --------------------------------------------------------------------------- +// Tool handler +// --------------------------------------------------------------------------- + +/// `ci_status` — GitHub Actions check-run status for a worktree branch. +/// +/// # Errors +/// +/// Returns an error when: +/// - `ticket` is missing or fails validation. +/// - No worktree is registered for the given ticket. +/// - `gh` is not on PATH or has no valid credentials. +/// - The branch has no open PR on GitHub. +pub fn status(ctx: &McpContext, input: Value) -> Result { + let ticket = input + .get("ticket") + .and_then(Value::as_str) + .context("ci_status requires 'ticket'")?; + + crate::worktree::validate_ticket_id(ticket)?; + + let limit = input + .get("limit") + .and_then(Value::as_u64) + .map(|v| v as usize) + .unwrap_or(DEFAULT_LIMIT) + .clamp(1, 100); + + let repo = repo_path(ctx, &input); + let config = ParsecConfig::load().context("failed to load parsec config")?; + let manager = WorktreeManager::new(&repo, &config)?; + let workspaces = manager.list()?; + + let ws = workspaces + .iter() + .find(|w| w.ticket.eq_ignore_ascii_case(ticket)) + .with_context(|| format!("no worktree registered for ticket '{ticket}'"))?; + + let token = ctx.github_token.as_ref().map(|t| t.expose_secret()); + let mut view = fetch_ci(&repo, &ws.branch, token)?; + + // Apply caller-supplied limit before serialisation. + view.status_check_rollup.truncate(limit); + + let overall = derive_overall(&view.status_check_rollup); + let checks: Vec = view + .status_check_rollup + .iter() + .map(|c| { + json!({ + "name": c.display_name(), + "status": c.normalised_status(), + "conclusion": c.normalised_conclusion(), + "started_at": c.started_at, + "completed_at": c.completed_at, + "details_url": c.url(), + }) + }) + .collect(); + + Ok(json!({ + "ticket": ticket, + "pr_number": view.number, + "branch": ws.branch, + "overall": overall, + "check_count": checks.len(), + "checks": checks, + })) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::mcp::McpContext; + + fn make_check(name: &str, status: &str, conclusion: Option<&str>) -> GhCheckEntry { + GhCheckEntry { + name: name.to_owned(), + context: String::new(), + status: status.to_owned(), + conclusion: conclusion.map(str::to_owned), + state: None, + started_at: None, + completed_at: None, + details_url: None, + target_url: None, + } + } + + fn make_status_context(context: &str, state: &str) -> GhCheckEntry { + GhCheckEntry { + name: String::new(), + context: context.to_owned(), + status: String::new(), + conclusion: None, + state: Some(state.to_owned()), + started_at: None, + completed_at: None, + details_url: None, + target_url: None, + } + } + + #[test] + fn derive_overall_no_checks() { + assert_eq!(derive_overall(&[]), "no_checks"); + } + + #[test] + fn derive_overall_all_passing() { + let checks = vec![ + make_check("build", "COMPLETED", Some("SUCCESS")), + make_check("test", "COMPLETED", Some("SUCCESS")), + ]; + assert_eq!(derive_overall(&checks), "passing"); + } + + #[test] + fn derive_overall_any_failure() { + let checks = vec![ + make_check("build", "COMPLETED", Some("SUCCESS")), + make_check("test", "COMPLETED", Some("FAILURE")), + ]; + assert_eq!(derive_overall(&checks), "failing"); + } + + #[test] + fn derive_overall_in_progress_is_pending() { + let checks = vec![ + make_check("build", "COMPLETED", Some("SUCCESS")), + make_check("test", "IN_PROGRESS", None), + ]; + assert_eq!(derive_overall(&checks), "pending"); + } + + #[test] + fn derive_overall_skipped_counts_as_success() { + let checks = vec![ + make_check("build", "COMPLETED", Some("SUCCESS")), + make_check("lint", "COMPLETED", Some("SKIPPED")), + ]; + assert_eq!(derive_overall(&checks), "passing"); + } + + #[test] + fn status_context_failure_maps_to_failing() { + let checks = vec![make_status_context("travis-ci", "FAILURE")]; + assert_eq!(derive_overall(&checks), "failing"); + } + + #[test] + fn status_context_success_maps_to_passing() { + let checks = vec![make_status_context("travis-ci", "SUCCESS")]; + assert_eq!(derive_overall(&checks), "passing"); + } + + #[test] + fn normalise_check_status_variants() { + assert_eq!(normalise_check_status("COMPLETED"), "completed"); + assert_eq!(normalise_check_status("IN_PROGRESS"), "in_progress"); + assert_eq!(normalise_check_status("QUEUED"), "pending"); + assert_eq!(normalise_check_status("WAITING"), "pending"); + assert_eq!(normalise_check_status("REQUESTED"), "pending"); + assert_eq!(normalise_check_status("UNKNOWN_VAL"), "unknown"); + } + + #[test] + fn ci_status_rejects_missing_ticket() { + let ctx = McpContext::from_cwd(false).expect("context"); + let err = status(&ctx, json!({})).expect_err("missing ticket should fail"); + assert!( + err.to_string().contains("ticket"), + "error should mention 'ticket': {err}" + ); + } + + #[test] + fn ci_status_rejects_invalid_ticket() { + let ctx = McpContext::from_cwd(false).expect("context"); + let err = + status(&ctx, json!({"ticket": "bad;ticket"})).expect_err("invalid ticket should fail"); + assert!( + err.to_string().contains("ticket") || err.to_string().contains("unsafe"), + "error should mention validation failure: {err}" + ); + } + + #[test] + fn ci_status_fails_for_nonexistent_worktree() { + let dir = tempfile::tempdir().expect("temp dir"); + crate::git::run(dir.path(), &["init"]).expect("git init"); + crate::worktree::ParsecState::default() + .save(dir.path()) + .expect("state save"); + + let ctx = McpContext { + repo_path: dir.path().to_path_buf(), + github_token: None, + github_scopes: None, + dry_run: false, + }; + + let err = + status(&ctx, json!({"ticket": "PROJ-1"})).expect_err("non-existent ticket should fail"); + assert!( + err.to_string().contains("PROJ-1") || err.to_string().contains("worktree"), + "error should mention missing worktree: {err}" + ); + } +} diff --git a/src/mcp/tools/health.rs b/src/mcp/tools/health.rs new file mode 100644 index 0000000..fbbc4eb --- /dev/null +++ b/src/mcp/tools/health.rs @@ -0,0 +1,246 @@ +//! MCP tool handler for `health_check`. +//! +//! Phase 24 (#293): wires the synchronous health-check data (lock files, +//! uncommitted changes, stale branches) to the MCP dispatcher. CI-status +//! overlay (`include_ci`) is deferred to a later phase that can bridge the +//! async GitHub client. +//! +//! ## Output shape +//! +//! ```json +//! { +//! "repo": "/abs/path", +//! "count": 2, +//! "records": [ +//! { +//! "ticket": "PROJ-123", +//! "healthy": true, +//! "has_lock": false, +//! "uncommitted": 0, +//! "stale_days": 1, +//! "stale_threshold_days": 7, +//! "ci_status": null +//! } +//! ], +//! "ci_overlay": false +//! } +//! ``` + +use anyhow::{Context, Result}; +use serde_json::json; +use std::path::{Path, PathBuf}; + +use crate::config::ParsecConfig; +use crate::git; +use crate::mcp::McpContext; +use crate::worktree::WorktreeManager; + +/// Default stale-branch threshold in days. +const DEFAULT_STALE_DAYS: i64 = 7; + +fn repo_path(ctx: &McpContext, input: &serde_json::Value) -> PathBuf { + input + .get("repo") + .and_then(serde_json::Value::as_str) + .map(PathBuf::from) + .unwrap_or_else(|| ctx.repo_path.clone()) +} + +/// Resolve the stale threshold from the caller's `stale_days` argument. +fn stale_threshold(input: &serde_json::Value) -> i64 { + input + .get("stale_days") + .and_then(serde_json::Value::as_i64) + .filter(|&d| d > 0) + .unwrap_or(DEFAULT_STALE_DAYS) +} + +/// Compute days-since-last-commit for a worktree path. +/// +/// Returns `None` when git history is unreadable (empty repo, detached HEAD). +fn days_since_last_commit(path: &Path) -> Option { + let ts = git::run_output(path, &["log", "-1", "--format=%ct"]) + .ok() + .and_then(|s| s.trim().parse::().ok())?; + let now = chrono::Utc::now().timestamp(); + Some((now - ts) / 86_400) +} + +/// Check whether a worktree has an `.git/index.lock` file. +/// +/// Handles both regular and worktree-style `.git` files (the `gitdir:` pointer +/// format used by `git worktree add`). +fn has_index_lock(worktree_path: &Path) -> bool { + let git_entry = worktree_path.join(".git"); + let lock_path = if git_entry.is_file() { + // Worktree-style: `.git` is a file containing "gitdir: " + std::fs::read_to_string(&git_entry) + .ok() + .and_then(|s| s.strip_prefix("gitdir: ").map(|p| PathBuf::from(p.trim()))) + .unwrap_or_else(|| git_entry.clone()) + .join("index.lock") + } else { + git_entry.join("index.lock") + }; + lock_path.exists() +} + +/// Build a single health record JSON value for one worktree. +fn health_record(ws: &crate::worktree::Workspace, threshold: i64) -> serde_json::Value { + let has_lock = has_index_lock(&ws.path); + let uncommitted = git::get_uncommitted_files(&ws.path) + .unwrap_or_default() + .len(); + let stale_days = days_since_last_commit(&ws.path); + let is_stale = stale_days.is_some_and(|d| d > threshold); + let healthy = !has_lock && uncommitted == 0 && !is_stale; + + json!({ + "ticket": ws.ticket, + "healthy": healthy, + "has_lock": has_lock, + "uncommitted": uncommitted, + "stale_days": stale_days, + "stale_threshold_days": threshold, + "ci_status": null, + }) +} + +/// `health_check` — run worktree health diagnostics. +/// +/// Checks every active worktree (or a single one when `ticket` is supplied) +/// for lock files, uncommitted changes, and stale branches. CI-status overlay +/// is not yet wired; `ci_overlay` is always `false` until Phase 25. +/// +/// # Errors +/// Returns an error if the git repository cannot be read or the ticket does not +/// exist. +pub fn check(ctx: &McpContext, input: serde_json::Value) -> Result { + let config = ParsecConfig::load().context("failed to load parsec config")?; + let repo = repo_path(ctx, &input); + let manager = WorktreeManager::new(&repo, &config)?; + let threshold = stale_threshold(&input); + + let workspaces = if let Some(ticket) = input + .get("ticket") + .and_then(serde_json::Value::as_str) + .filter(|t| !t.is_empty()) + { + crate::worktree::validate_ticket_id(ticket)?; + vec![manager.get(ticket)?] + } else { + manager.list()? + }; + + let records: Vec = workspaces + .iter() + .map(|ws| health_record(ws, threshold)) + .collect(); + + Ok(json!({ + "repo": manager.repo_root().display().to_string(), + "count": records.len(), + "records": records, + "ci_overlay": false, + })) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::worktree::{ParsecState, Workspace, WorkspaceStatus}; + use chrono::TimeZone; + + fn fixture_repo() -> (tempfile::TempDir, McpContext) { + let dir = tempfile::tempdir().expect("temp repo"); + crate::git::run(dir.path(), &["init"]).expect("git init"); + + let mut state = ParsecState::default(); + state.add_workspace(Workspace { + ticket: "PROJ-1".to_owned(), + path: dir.path().join("../git-parsec.PROJ-1"), + branch: "feat/PROJ-1".to_owned(), + base_branch: "main".to_owned(), + created_at: chrono::Utc.with_ymd_and_hms(2026, 6, 1, 0, 0, 0).unwrap(), + ticket_title: Some("Health check MCP".to_owned()), + status: WorkspaceStatus::Active, + parent_ticket: None, + }); + state.save(dir.path()).expect("state save"); + + let ctx = McpContext { + repo_path: dir.path().to_path_buf(), + github_token: None, + github_scopes: None, + dry_run: false, + }; + (dir, ctx) + } + + #[test] + fn health_check_returns_all_worktrees_when_no_ticket() { + let (_dir, ctx) = fixture_repo(); + + let payload = check(&ctx, json!({})).expect("health_check all"); + + assert_eq!(payload["count"], 1); + assert!(payload["records"].as_array().is_some()); + assert_eq!(payload["records"][0]["ticket"], "PROJ-1"); + assert_eq!(payload["ci_overlay"], false); + assert!(payload["records"][0].get("ci_status").is_some()); + } + + #[test] + fn health_check_returns_empty_when_no_worktrees() { + let dir = tempfile::tempdir().expect("temp repo"); + crate::git::run(dir.path(), &["init"]).expect("git init"); + // No worktrees registered + ParsecState::default().save(dir.path()).expect("state save"); + + let ctx = McpContext { + repo_path: dir.path().to_path_buf(), + github_token: None, + github_scopes: None, + dry_run: false, + }; + + let payload = check(&ctx, json!({})).expect("health_check empty"); + + assert_eq!(payload["count"], 0); + assert_eq!(payload["records"].as_array().map(Vec::len), Some(0)); + assert_eq!(payload["ci_overlay"], false); + } + + #[test] + fn health_record_no_lock_no_uncommitted_is_healthy() { + let (_dir, ctx) = fixture_repo(); + + let payload = check(&ctx, json!({})).expect("health_check payload"); + let record = &payload["records"][0]; + + // The worktree path doesn't exist as a real dir, so lock = false, + // uncommitted = 0 (get_uncommitted_files fails safely). + assert_eq!(record["has_lock"], false); + assert_eq!(record["uncommitted"], 0); + assert_eq!(record["stale_threshold_days"], DEFAULT_STALE_DAYS); + } + + #[test] + fn health_check_respects_custom_stale_days() { + let (_dir, ctx) = fixture_repo(); + + let payload = check(&ctx, json!({"stale_days": 30})).expect("health_check custom stale"); + + assert_eq!(payload["records"][0]["stale_threshold_days"], 30); + } + + #[test] + fn health_check_requires_valid_ticket_when_supplied() { + let (_dir, ctx) = fixture_repo(); + + let err = + check(&ctx, json!({"ticket": "bad ticket!"})).expect_err("invalid ticket should fail"); + + assert!(err.to_string().contains("ticket") || err.to_string().contains("invalid")); + } +} diff --git a/src/mcp/tools/mod.rs b/src/mcp/tools/mod.rs new file mode 100644 index 0000000..f05d154 --- /dev/null +++ b/src/mcp/tools/mod.rs @@ -0,0 +1,69 @@ +//! MCP tool handler modules. +//! +//! Each sub-module corresponds to one or more tools in the catalogue defined +//! in `docs/mcp/spec.md`. Handlers are intentionally thin while the MCP +//! transport matures; concrete CLI integrations land behind this dispatch +//! boundary in later phases (issue #293). +//! +//! ## Adding a new tool +//! +//! 1. Add a `ToolDef` entry to `crate::mcp::TOOLS`. +//! 2. Create (or extend) a sub-module here. +//! 3. Implement `pub fn handle(ctx: &McpContext, input: serde_json::Value) -> anyhow::Result`. +//! 4. Register the handler in [`dispatch`]. + +use crate::mcp::McpContext; + +pub mod ci; +pub mod health; +pub mod pr; +pub mod reviews; +pub mod smartlog; +pub mod sync; +pub mod worktree; + +/// Shared MCP tool handler signature. +pub type ToolHandler = fn(&McpContext, serde_json::Value) -> anyhow::Result; + +/// Return the executable handler for a registered MCP tool name. +#[must_use] +pub fn handler_for(name: &str) -> Option { + match name { + "worktree_list" => Some(worktree::list), + "worktree_start" => Some(worktree::start), + "worktree_status" => Some(worktree::status), + "worktree_ship" => Some(worktree::ship), + "smartlog" => Some(smartlog::run), + "ci_status" => Some(ci::status), + "pr_status" => Some(pr::status), + "health_check" => Some(health::check), + "reviews" => Some(reviews::list), + "sync" => Some(sync::run), + _ => None, + } +} + +/// Dispatch an MCP tool call to its module-level handler. +/// +/// # Errors +/// Returns the handler error when the tool is registered but its implementation +/// cannot complete. Unknown tools return an explicit error for defensive use. +pub fn dispatch( + name: &str, + ctx: &McpContext, + input: serde_json::Value, +) -> anyhow::Result { + let Some(handler) = handler_for(name) else { + anyhow::bail!("unknown MCP tool '{name}'"); + }; + + handler(ctx, input) +} + +/// Produce a structured error for tools that are registered but not yet wired. +/// +/// All 10 built-in parsec tools are wired as of Phase 31. Use this helper +/// when adding a new tool before its handler is ready. +pub(crate) fn not_implemented(tool: &str) -> anyhow::Error { + anyhow::anyhow!("{tool}: handler is not yet wired (see docs/mcp/spec.md for phase status)") +} diff --git a/src/mcp/tools/pr.rs b/src/mcp/tools/pr.rs new file mode 100644 index 0000000..39a1428 --- /dev/null +++ b/src/mcp/tools/pr.rs @@ -0,0 +1,245 @@ +//! MCP tool handler for `pr_status`. +//! +//! Phase 26 (#293): wires `pr_status` to the real GitHub PR state via the +//! `gh` CLI. Read-only; requires `gh` on PATH with valid credentials (the +//! keyring or `GITHUB_TOKEN` / `GH_TOKEN` env vars). +//! +//! Auth tokens may also be injected via `McpContext.github_token` (delegated +//! PAT from the caller) — see `docs/mcp/auth.md`. When a delegated token is +//! present it is forwarded to `gh` via `GH_TOKEN` so the tool stays +//! credential-agnostic. +//! +//! ## Output shape +//! +//! ```json +//! { +//! "ticket": "PROJ-123", +//! "pr_number": 42, +//! "pr_url": "https://github.com/org/repo/pull/42", +//! "state": "open", +//! "draft": false, +//! "mergeable": true, +//! "review_state": "approved", +//! "approvals": 2, +//! "change_requests": 0, +//! "ci_overall": null +//! } +//! ``` +//! +//! `ci_overall` is reserved for a future phase that bridges the async GitHub +//! check-runs client; it is always `null` in Phase 26. + +use anyhow::{bail, Context, Result}; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use crate::config::ParsecConfig; +use crate::mcp::McpContext; +use crate::worktree::WorktreeManager; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn repo_path(ctx: &McpContext, input: &Value) -> PathBuf { + input + .get("repo") + .and_then(Value::as_str) + .map(PathBuf::from) + .unwrap_or_else(|| ctx.repo_path.clone()) +} + +/// Raw PR fields returned by `gh pr view --json`. +#[derive(Debug, Deserialize)] +struct GhPrView { + number: u64, + url: String, + state: String, + #[serde(rename = "isDraft")] + is_draft: bool, + mergeable: String, + #[serde(rename = "reviewDecision")] + review_decision: Option, + reviews: Vec, +} + +/// A single GitHub PR review entry. +#[derive(Debug, Deserialize)] +struct GhReview { + state: String, +} + +/// Call `gh pr view` for the given branch. +/// +/// When `token` is supplied it is forwarded as `GH_TOKEN` so the delegated +/// PAT takes precedence over any ambient credential. +fn fetch_pr(repo: &Path, branch: &str, token: Option<&str>) -> Result { + let mut cmd = Command::new("gh"); + cmd.args([ + "pr", + "view", + branch, + "--json", + "number,url,state,isDraft,mergeable,reviewDecision,reviews", + ]) + .current_dir(repo); + + if let Some(t) = token { + cmd.env("GH_TOKEN", t); + } + + let output = cmd.output().context("failed to spawn 'gh pr view'")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + bail!( + "gh pr view failed for branch '{}': {}", + branch, + stderr.trim() + ); + } + + let raw = String::from_utf8(output.stdout).context("gh pr view produced non-UTF-8 output")?; + serde_json::from_str::(&raw).context("failed to parse gh pr view JSON") +} + +/// Map the `mergeable` string from the GitHub GraphQL enum to a bool. +/// +/// `MERGEABLE` → `true`; `CONFLICTING` / `UNKNOWN` → `false`. +fn parse_mergeable(s: &str) -> bool { + s.eq_ignore_ascii_case("MERGEABLE") +} + +// --------------------------------------------------------------------------- +// Tool handler +// --------------------------------------------------------------------------- + +/// `pr_status` — GitHub PR state, review approvals, and merge readiness. +/// +/// # Errors +/// +/// Returns an error when: +/// - `ticket` is missing or fails validation. +/// - No worktree is registered for the given ticket. +/// - `gh` is not on PATH or has no valid credentials. +/// - The branch has no open PR on GitHub. +pub fn status(ctx: &McpContext, input: Value) -> Result { + let ticket = input + .get("ticket") + .and_then(Value::as_str) + .context("pr_status requires 'ticket'")?; + + crate::worktree::validate_ticket_id(ticket)?; + + let repo = repo_path(ctx, &input); + let config = ParsecConfig::load().context("failed to load parsec config")?; + let manager = WorktreeManager::new(&repo, &config)?; + let workspaces = manager.list()?; + + let ws = workspaces + .iter() + .find(|w| w.ticket.eq_ignore_ascii_case(ticket)) + .with_context(|| format!("no worktree registered for ticket '{ticket}'"))?; + + let token = ctx.github_token.as_ref().map(|t| t.expose_secret()); + let pr = fetch_pr(&repo, &ws.branch, token)?; + + let approvals = pr + .reviews + .iter() + .filter(|r| r.state.eq_ignore_ascii_case("APPROVED")) + .count() as u64; + + let change_requests = pr + .reviews + .iter() + .filter(|r| r.state.eq_ignore_ascii_case("CHANGES_REQUESTED")) + .count() as u64; + + // Normalise reviewDecision: APPROVED → "approved", CHANGES_REQUESTED → + // "changes_requested", null/absent → "pending". + let review_state = pr + .review_decision + .as_deref() + .map(|s| s.to_lowercase().replace('-', "_")) + .unwrap_or_else(|| "pending".to_string()); + + Ok(json!({ + "ticket": ticket, + "pr_number": pr.number, + "pr_url": pr.url, + "state": pr.state.to_lowercase(), + "draft": pr.is_draft, + "mergeable": parse_mergeable(&pr.mergeable), + "review_state": review_state, + "approvals": approvals, + "change_requests": change_requests, + "ci_overall": null, + })) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_mergeable_handles_all_variants() { + assert!(parse_mergeable("MERGEABLE")); + assert!(parse_mergeable("mergeable")); // case-insensitive + assert!(!parse_mergeable("CONFLICTING")); + assert!(!parse_mergeable("UNKNOWN")); + assert!(!parse_mergeable("")); + } + + #[test] + fn pr_status_rejects_missing_ticket() { + let ctx = McpContext::from_cwd(false).expect("context"); + let err = status(&ctx, json!({})).expect_err("should fail without ticket"); + assert!( + err.to_string().contains("ticket"), + "error should mention 'ticket': {err}" + ); + } + + #[test] + fn pr_status_rejects_invalid_ticket() { + let ctx = McpContext::from_cwd(false).expect("context"); + // Shell meta-character in ticket should be rejected by validate_ticket_id. + let err = status(&ctx, json!({"ticket": "bad;ticket"})) + .expect_err("shell meta in ticket should fail"); + assert!( + err.to_string().contains("ticket") || err.to_string().contains("unsafe"), + "error should mention validation failure: {err}" + ); + } + + #[test] + fn pr_status_fails_for_nonexistent_worktree() { + // Use a temp git repo with no registered worktrees. + let dir = tempfile::tempdir().expect("temp dir"); + crate::git::run(dir.path(), &["init"]).expect("git init"); + crate::worktree::ParsecState::default() + .save(dir.path()) + .expect("state save"); + + let ctx = McpContext { + repo_path: dir.path().to_path_buf(), + github_token: None, + github_scopes: None, + dry_run: false, + }; + + let err = + status(&ctx, json!({"ticket": "PROJ-1"})).expect_err("non-existent ticket should fail"); + assert!( + err.to_string().contains("PROJ-1") || err.to_string().contains("worktree"), + "error should mention missing worktree: {err}" + ); + } +} diff --git a/src/mcp/tools/reviews.rs b/src/mcp/tools/reviews.rs new file mode 100644 index 0000000..08ab87b --- /dev/null +++ b/src/mcp/tools/reviews.rs @@ -0,0 +1,319 @@ +//! MCP tool handler for `reviews`. +//! +//! Phase 27 (#293): wires the `reviews` tool to real GitHub PR data via the +//! `gh` CLI. Read-only; requires `gh` on PATH with valid credentials (the +//! keyring or `GITHUB_TOKEN` / `GH_TOKEN` env vars). +//! +//! Auth tokens may also be injected via `McpContext.github_token` (delegated +//! PAT from the caller) — see `docs/mcp/auth.md`. +//! +//! ## Input +//! +//! ```json +//! { +//! "repo": "/optional/abs/path", +//! "mode": "authored" | "requested" | "all" +//! } +//! ``` +//! +//! `mode` defaults to `"all"`. `"authored"` shows open PRs created by `@me`; +//! `"requested"` shows open PRs where `@me` is a requested reviewer. +//! +//! ## Output shape +//! +//! ```json +//! { +//! "mode": "all", +//! "total_count": 2, +//! "authored_count": 1, +//! "requested_count": 1, +//! "entries": [ +//! { +//! "source": "authored", +//! "pr_number": 42, +//! "title": "feat: something", +//! "state": "open", +//! "review_decision": "approved", +//! "approvals": 2, +//! "changes_requested": 0, +//! "url": "https://github.com/org/repo/pull/42", +//! "draft": false +//! } +//! ] +//! } +//! ``` + +use anyhow::{Context, Result}; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use crate::mcp::McpContext; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn repo_path(ctx: &McpContext, input: &Value) -> PathBuf { + input + .get("repo") + .and_then(Value::as_str) + .map(PathBuf::from) + .unwrap_or_else(|| ctx.repo_path.clone()) +} + +/// Raw PR list entry returned by `gh pr list --json`. +#[derive(Debug, Deserialize)] +struct GhPrEntry { + number: u64, + title: String, + url: String, + state: String, + #[serde(rename = "isDraft")] + is_draft: bool, + #[serde(rename = "reviewDecision")] + review_decision: Option, + reviews: Vec, +} + +/// A single review on a PR. +#[derive(Debug, Deserialize)] +struct GhReview { + state: String, +} + +const PR_FIELDS: &str = "number,title,url,state,isDraft,reviewDecision,reviews"; + +/// Run `gh pr list` with caller-supplied extra args and return parsed entries. +/// +/// When `token` is supplied it is forwarded as `GH_TOKEN` so the delegated +/// PAT takes precedence over any ambient credential. +fn gh_pr_list(repo: &Path, extra_args: &[&str], token: Option<&str>) -> Result> { + let mut cmd = Command::new("gh"); + cmd.args(["pr", "list", "--state", "open", "--json", PR_FIELDS]) + .args(extra_args) + .current_dir(repo); + + if let Some(t) = token { + cmd.env("GH_TOKEN", t); + } + + let output = cmd.output().context("failed to spawn 'gh pr list'")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("gh pr list failed: {}", stderr.trim()); + } + + let raw = String::from_utf8(output.stdout).context("gh pr list produced non-UTF-8 output")?; + serde_json::from_str::>(&raw).context("failed to parse gh pr list JSON") +} + +/// Count APPROVED and CHANGES_REQUESTED reviews from a list. +fn count_reviews(reviews: &[GhReview]) -> (u64, u64) { + let approvals = reviews + .iter() + .filter(|r| r.state.eq_ignore_ascii_case("APPROVED")) + .count() as u64; + let changes = reviews + .iter() + .filter(|r| r.state.eq_ignore_ascii_case("CHANGES_REQUESTED")) + .count() as u64; + (approvals, changes) +} + +/// Convert a `GhPrEntry` to the unified output object tagged with `source`. +fn entry_json(pr: GhPrEntry, source: &str) -> Value { + let (approvals, changes_requested) = count_reviews(&pr.reviews); + let review_decision = pr + .review_decision + .as_deref() + .map(|s| s.to_lowercase().replace('-', "_")) + .unwrap_or_else(|| "pending".to_string()); + + json!({ + "source": source, + "pr_number": pr.number, + "title": pr.title, + "state": pr.state.to_lowercase(), + "review_decision": review_decision, + "approvals": approvals, + "changes_requested": changes_requested, + "url": pr.url, + "draft": pr.is_draft, + }) +} + +// --------------------------------------------------------------------------- +// Tool handler +// --------------------------------------------------------------------------- + +/// `reviews` — list authored and/or incoming review requests via GitHub. +/// +/// # Errors +/// +/// Returns an error when: +/// - `mode` is not one of `"authored"`, `"requested"`, or `"all"`. +/// - `gh` is not on PATH or has no valid credentials. +pub fn list(ctx: &McpContext, input: Value) -> Result { + let repo = repo_path(ctx, &input); + let mode = input.get("mode").and_then(Value::as_str).unwrap_or("all"); + + if !matches!(mode, "authored" | "requested" | "all") { + anyhow::bail!( + "reviews: 'mode' must be \"authored\", \"requested\", or \"all\" (got {mode:?})" + ); + } + + let token = ctx.github_token.as_ref().map(|t| t.expose_secret()); + + // Authored: open PRs created by the authenticated user. + let authored: Vec = if matches!(mode, "authored" | "all") { + gh_pr_list(&repo, &["--author", "@me"], token)? + .into_iter() + .map(|pr| entry_json(pr, "authored")) + .collect() + } else { + vec![] + }; + + // Requested: open PRs where the authenticated user is a requested reviewer. + let requested: Vec = if matches!(mode, "requested" | "all") { + gh_pr_list(&repo, &["--search", "review-requested:@me"], token)? + .into_iter() + .map(|pr| entry_json(pr, "requested")) + .collect() + } else { + vec![] + }; + + let authored_count = authored.len(); + let requested_count = requested.len(); + + let mut entries = authored; + entries.extend(requested); + + Ok(json!({ + "mode": mode, + "total_count": entries.len(), + "authored_count": authored_count, + "requested_count": requested_count, + "entries": entries, + })) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn reviews_rejects_invalid_mode() { + let ctx = McpContext::from_cwd(false).expect("context"); + let err = list(&ctx, json!({"mode": "bogus"})).expect_err("invalid mode should fail"); + assert!( + err.to_string().contains("mode"), + "error should mention 'mode': {err}" + ); + } + + #[test] + fn reviews_accepts_valid_mode_authored() { + // Mode validation passes — the result is either Ok (empty list) or + // a gh error. Either way the error must NOT be a mode-validation error. + let ctx = McpContext::from_cwd(false).expect("context"); + match list(&ctx, json!({"mode": "authored"})) { + Ok(v) => assert_eq!(v["mode"], "authored"), + Err(e) => assert!( + !e.to_string().contains("must be"), + "error should not be a mode-validation error: {e}" + ), + } + } + + #[test] + fn reviews_defaults_to_all_mode() { + // No `mode` → defaults to "all". + let ctx = McpContext::from_cwd(false).expect("context"); + match list(&ctx, json!({})) { + Ok(v) => assert_eq!(v["mode"], "all"), + Err(e) => assert!( + !e.to_string().contains("must be"), + "error should not be a mode-validation error: {e}" + ), + } + } + + #[test] + fn count_reviews_empty() { + let (approvals, changes) = count_reviews(&[]); + assert_eq!(approvals, 0); + assert_eq!(changes, 0); + } + + #[test] + fn count_reviews_mixed() { + let reviews = vec![ + GhReview { + state: "APPROVED".to_string(), + }, + GhReview { + state: "APPROVED".to_string(), + }, + GhReview { + state: "CHANGES_REQUESTED".to_string(), + }, + GhReview { + state: "COMMENTED".to_string(), + }, + ]; + let (approvals, changes) = count_reviews(&reviews); + assert_eq!(approvals, 2); + assert_eq!(changes, 1); + } + + #[test] + fn entry_json_maps_all_fields() { + let pr = GhPrEntry { + number: 42, + title: "feat: something".to_string(), + url: "https://github.com/org/repo/pull/42".to_string(), + state: "OPEN".to_string(), + is_draft: false, + review_decision: Some("APPROVED".to_string()), + reviews: vec![GhReview { + state: "APPROVED".to_string(), + }], + }; + let entry = entry_json(pr, "authored"); + assert_eq!(entry["source"], "authored"); + assert_eq!(entry["pr_number"], 42); + assert_eq!(entry["state"], "open"); + assert_eq!(entry["review_decision"], "approved"); + assert_eq!(entry["approvals"], 1_u64); + assert_eq!(entry["changes_requested"], 0_u64); + assert_eq!(entry["draft"], false); + } + + #[test] + fn entry_json_pending_when_no_review_decision() { + let pr = GhPrEntry { + number: 7, + title: "feat: wip".to_string(), + url: "https://github.com/org/repo/pull/7".to_string(), + state: "open".to_string(), + is_draft: true, + review_decision: None, + reviews: vec![], + }; + let entry = entry_json(pr, "requested"); + assert_eq!(entry["review_decision"], "pending"); + assert_eq!(entry["draft"], true); + assert_eq!(entry["source"], "requested"); + } +} diff --git a/src/mcp/tools/smartlog.rs b/src/mcp/tools/smartlog.rs new file mode 100644 index 0000000..3d46e50 --- /dev/null +++ b/src/mcp/tools/smartlog.rs @@ -0,0 +1,247 @@ +//! MCP tool handler for `smartlog`. +//! +//! Phase 25 (#293): wires the synchronous DAG collection (worktrees + commits, +//! no GitHub overlay) to the MCP dispatcher. PR/CI overlays require an async +//! GitHub client and are deferred to a future phase. +//! +//! ## Output shape +//! +//! ```json +//! { +//! "repo": "/abs/path", +//! "count": 2, +//! "nodes": [ +//! { +//! "ticket": "PROJ-123", +//! "branch": "feat/proj-123", +//! "base_branch": "main", +//! "commits": [ +//! { +//! "sha": "abc1234", +//! "subject": "feat: initial", +//! "author": "Alice", +//! "date": "2026-08-01T00:00:00+00:00" +//! } +//! ], +//! "pr": null, +//! "ci": null +//! } +//! ], +//! "pr_overlay": false, +//! "ci_overlay": false +//! } +//! ``` + +use anyhow::{Context, Result}; +use serde_json::json; +use std::path::{Path, PathBuf}; + +use crate::config::ParsecConfig; +use crate::git; +use crate::mcp::McpContext; +use crate::worktree::WorktreeManager; + +/// Default commit depth per worktree node (mirrors `src/cli/commands/smartlog.rs`). +const DEFAULT_DEPTH: usize = 10; + +fn repo_path(ctx: &McpContext, input: &serde_json::Value) -> PathBuf { + input + .get("repo") + .and_then(serde_json::Value::as_str) + .map(PathBuf::from) + .unwrap_or_else(|| ctx.repo_path.clone()) +} + +/// Collect commits for `base_branch..branch` from `worktree_path`. +/// +/// Uses the same `git log` format as `src/cli/commands/smartlog.rs`: +/// tab-separated `%h\t%s\t%an\t%aI` with `splitn(4, '\t')` so subjects +/// containing tabs are preserved correctly. +/// +/// Returns an empty `Vec` on any git error so one broken worktree does not +/// abort the entire DAG response. +fn collect_commits( + worktree_path: &Path, + base_branch: &str, + branch: &str, + depth: usize, +) -> Vec { + let range = format!("{base_branch}..{branch}"); + let limit = format!("-n{depth}"); + let raw = match git::run_output( + worktree_path, + &["log", &range, "--pretty=format:%h\t%s\t%an\t%aI", &limit], + ) { + Ok(output) => output, + Err(_) => return Vec::new(), + }; + + raw.lines() + .filter(|line| !line.is_empty()) + .filter_map(|line| { + let mut parts = line.splitn(4, '\t'); + let sha = parts.next()?.trim().to_string(); + let subject = parts.next()?.to_string(); + let author = parts.next()?.to_string(); + let date = parts.next()?.trim().to_string(); + if sha.is_empty() { + return None; + } + Some(json!({ + "sha": sha, + "subject": subject, + "author": author, + "date": date, + })) + }) + .collect() +} + +/// `smartlog` — render the commit DAG with worktree branches. +/// +/// Phase 25 provides the synchronous DAG: worktrees + commits per node. +/// The `ticket` argument filters nodes by ticket ID or branch name +/// (case-insensitive substring). `limit` caps commits per node (default 10). +/// +/// PR and CI overlays are set to `false`/`null` for now; a later phase will +/// bridge the async GitHub client. +/// +/// # Errors +/// Returns an error if the parsec config or git repository cannot be read. +pub fn run(ctx: &McpContext, input: serde_json::Value) -> Result { + let repo = repo_path(ctx, &input); + let depth = input + .get("limit") + .and_then(serde_json::Value::as_u64) + .map(|d| d as usize) + .unwrap_or(DEFAULT_DEPTH); + let filter = input + .get("ticket") + .and_then(serde_json::Value::as_str) + .map(|f| f.to_lowercase()); + + let config = ParsecConfig::load().context("failed to load parsec config")?; + let manager = WorktreeManager::new(&repo, &config)?; + let workspaces = manager.list()?; + + let nodes: Vec = workspaces + .iter() + .filter(|ws| { + filter.as_deref().is_none_or(|pat| { + ws.ticket.to_lowercase().contains(pat) || ws.branch.to_lowercase().contains(pat) + }) + }) + .map(|ws| { + let commits = collect_commits(&ws.path, &ws.base_branch, &ws.branch, depth); + json!({ + "ticket": ws.ticket, + "branch": ws.branch, + "base_branch": ws.base_branch, + "commits": commits, + "pr": null, + "ci": null, + }) + }) + .collect(); + + Ok(json!({ + "repo": repo.display().to_string(), + "count": nodes.len(), + "nodes": nodes, + "pr_overlay": false, + "ci_overlay": false, + })) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mcp::McpContext; + + #[test] + fn collect_commits_returns_empty_for_nonexistent_range() { + let tmp = tempfile::tempdir().expect("temp dir"); + let commits = collect_commits(tmp.path(), "main", "nonexistent-branch", 10); + assert!( + commits.is_empty(), + "a bad git range should return empty, not panic" + ); + } + + #[test] + fn smartlog_returns_dag_envelope() { + let ctx = McpContext::from_cwd(false).expect("context"); + let result = run(&ctx, serde_json::json!({})).expect("smartlog should not error"); + + assert!( + result.get("repo").is_some(), + "result must include repo path" + ); + assert!( + result.get("count").is_some(), + "result must include node count" + ); + assert!( + result + .get("nodes") + .and_then(serde_json::Value::as_array) + .is_some(), + "result must include nodes array" + ); + assert_eq!(result["pr_overlay"], false, "PR overlay is not yet wired"); + assert_eq!(result["ci_overlay"], false, "CI overlay is not yet wired"); + } + + #[test] + fn smartlog_ticket_filter_returns_subset() { + let ctx = McpContext::from_cwd(false).expect("context"); + let unfiltered = run(&ctx, serde_json::json!({})).expect("unfiltered smartlog"); + let filtered = run( + &ctx, + serde_json::json!({"ticket": "zzz-nonexistent-ticket"}), + ) + .expect("filtered smartlog should not error"); + + let total = unfiltered["count"].as_u64().unwrap_or(0); + let subset = filtered["count"].as_u64().unwrap_or(0); + assert!( + subset <= total, + "filtered count ({subset}) must not exceed total ({total})" + ); + assert_eq!( + subset, 0, + "no worktrees should match 'zzz-nonexistent-ticket'" + ); + } + + #[test] + fn smartlog_nodes_have_required_fields() { + let ctx = McpContext::from_cwd(false).expect("context"); + let result = run(&ctx, serde_json::json!({})).expect("smartlog should not error"); + let nodes = result["nodes"].as_array().expect("nodes must be an array"); + + for node in nodes { + assert!(node.get("ticket").is_some(), "node must have ticket"); + assert!(node.get("branch").is_some(), "node must have branch"); + assert!( + node.get("base_branch").is_some(), + "node must have base_branch" + ); + assert!( + node.get("commits") + .and_then(serde_json::Value::as_array) + .is_some(), + "node must have commits array" + ); + } + } + + #[test] + fn smartlog_limit_argument_is_respected() { + let ctx = McpContext::from_cwd(false).expect("context"); + // limit=1 must not cause an error even when there are fewer commits + let result = + run(&ctx, serde_json::json!({"limit": 1})).expect("smartlog with limit=1 should work"); + assert!(result.get("nodes").is_some()); + } +} diff --git a/src/mcp/tools/sync.rs b/src/mcp/tools/sync.rs new file mode 100644 index 0000000..eac1461 --- /dev/null +++ b/src/mcp/tools/sync.rs @@ -0,0 +1,438 @@ +//! MCP tool handler for `sync`. +//! +//! Phase 29 (#293): wires `sync` to rebase or merge a worktree branch against +//! its base branch, with a mandatory `dry_run` gate and explicit `confirm` +//! flag for the mutating path. +//! +//! ## Safety model +//! +//! `sync` is a **mutating** tool (`mutating: true` in the spec). To avoid +//! accidental rebases the tool enforces a two-key gate: +//! +//! - `dry_run` defaults to `true` → analysis-only unless caller opts out. +//! - `confirm` defaults to `false` → mutation requires both `dry_run=false` +//! **and** `confirm=true`. Either flag alone yields a dry-run report. +//! +//! ## Input +//! +//! ```json +//! { +//! "ticket": "PROJ-123", // required: parsec-managed worktree +//! "repo": "/optional/path", // optional: defaults to ctx.repo_path +//! "strategy": "rebase", // "rebase" (default) | "merge" +//! "dry_run": true, // default true — analysis only +//! "confirm": false, // default false — must be true to mutate +//! "stale_days": 5 // staleness threshold for advisory flag +//! } +//! ``` +//! +//! ## Output — dry-run (safe default) +//! +//! ```json +//! { +//! "ticket": "PROJ-123", +//! "branch": "feat/PROJ-123", +//! "base_branch": "main", +//! "strategy": "rebase", +//! "commits_behind": 3, +//! "stale": true, +//! "dry_run": true, +//! "applied": false, +//! "message": "3 commits behind origin/main — run with dry_run=false confirm=true to sync" +//! } +//! ``` +//! +//! ## Output — applied (dry_run=false, confirm=true) +//! +//! ```json +//! { +//! "ticket": "PROJ-123", +//! "branch": "feat/PROJ-123", +//! "base_branch": "main", +//! "strategy": "rebase", +//! "commits_behind": 3, +//! "stale": true, +//! "dry_run": false, +//! "applied": true, +//! "message": "rebased feat/PROJ-123 onto origin/main (3 commits applied)" +//! } +//! ``` + +use anyhow::{bail, Context, Result}; +use serde_json::{json, Value}; +use std::path::{Path, PathBuf}; + +use crate::config::ParsecConfig; +use crate::git; +use crate::mcp::McpContext; +use crate::worktree::WorktreeManager; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const DEFAULT_STALE_DAYS: i64 = 5; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn repo_path(ctx: &McpContext, input: &Value) -> PathBuf { + input + .get("repo") + .and_then(Value::as_str) + .map(PathBuf::from) + .unwrap_or_else(|| ctx.repo_path.clone()) +} + +/// Validate that the ticket string is safe to use in git operations. +/// +/// Allows alphanumerics, hyphens, underscores, and dots; rejects shell-unsafe +/// characters and empty strings. +fn validate_ticket(ticket: &str) -> Result<()> { + if ticket.is_empty() { + bail!("ticket must not be empty"); + } + if ticket.len() > 128 { + bail!("ticket value too long (max 128 chars)"); + } + if !ticket + .chars() + .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.')) + { + bail!( + "ticket '{}' contains unsafe characters; only alphanumerics, '-', '_', '.' allowed", + ticket + ); + } + Ok(()) +} + +/// Parse the `strategy` field: `"rebase"` (default) or `"merge"`. +fn parse_strategy(input: &Value) -> Result<&'static str> { + match input + .get("strategy") + .and_then(Value::as_str) + .unwrap_or("rebase") + { + "rebase" => Ok("rebase"), + "merge" => Ok("merge"), + other => bail!( + "unknown strategy '{}'; expected \"rebase\" or \"merge\"", + other + ), + } +} + +/// Count how many commits the worktree branch is behind `origin/`. +/// +/// Uses `git rev-list --count HEAD..origin/` executed in the +/// worktree directory. Returns `None` when the remote ref doesn't exist yet +/// (e.g. freshly created repo with no pushed commits). +fn commits_behind(worktree_path: &Path, base_branch: &str) -> Option { + let remote_ref = format!("origin/{base_branch}"); + let spec = format!("HEAD..{remote_ref}"); + git::run_output(worktree_path, &["rev-list", "--count", &spec]) + .ok() + .and_then(|s| s.trim().parse::().ok()) +} + +// --------------------------------------------------------------------------- +// Public handler +// --------------------------------------------------------------------------- + +/// `sync` — rebase or merge a parsec-managed worktree against its base branch. +/// +/// # Errors +/// +/// Returns an error when: +/// - `ticket` is missing, empty, or contains unsafe characters. +/// - The worktree for `ticket` cannot be found. +/// - `strategy` is not `"rebase"` or `"merge"`. +/// - `dry_run=false` + `confirm=true` (mutation path) fails at `git fetch` or +/// the subsequent rebase/merge step. +pub fn run(ctx: &McpContext, input: Value) -> Result { + // --- Parse & validate inputs ------------------------------------------- + + let ticket = input + .get("ticket") + .and_then(Value::as_str) + .unwrap_or("") + .to_owned(); + validate_ticket(&ticket)?; + + let strategy = parse_strategy(&input)?; + + let dry_run = input + .get("dry_run") + .and_then(Value::as_bool) + .unwrap_or(true); + + let confirm = input + .get("confirm") + .and_then(Value::as_bool) + .unwrap_or(false); + + let stale_days_threshold = input + .get("stale_days") + .and_then(Value::as_i64) + .filter(|&d| d > 0) + .unwrap_or(DEFAULT_STALE_DAYS); + + // --- Resolve worktree --------------------------------------------------- + + let repo = repo_path(ctx, &input); + let config = ParsecConfig::load().context("failed to load parsec config")?; + let manager = WorktreeManager::new(&repo, &config)?; + let workspace = manager.get(&ticket)?; + + let worktree_path: &Path = &workspace.path; + let branch = &workspace.branch; + let base_branch = &workspace.base_branch; + + // --- Staleness analysis (always computed, even on mutation path) -------- + + // Fetch so that remote refs are current. Non-fatal in offline mode. + let _ = git::fetch_if_remote(&repo); + + let behind = commits_behind(worktree_path, base_branch).unwrap_or(0); + + // Days since last commit in the worktree. + let stale = { + let days = git::run_output(worktree_path, &["log", "-1", "--format=%ct"]) + .ok() + .and_then(|s| s.trim().parse::().ok()) + .map(|ts| (chrono::Utc::now().timestamp() - ts) / 86_400) + .unwrap_or(0); + days >= stale_days_threshold + }; + + // --- Gate: if dry_run OR !confirm → return analysis -------------------- + + let effective_dry_run = dry_run || !confirm; + + if effective_dry_run { + let msg = if behind == 0 { + format!("{branch} is up to date with origin/{base_branch}") + } else { + format!( + "{behind} commit{s} behind origin/{base_branch} — run with dry_run=false confirm=true to {strategy}", + s = if behind == 1 { "" } else { "s" }, + ) + }; + + return Ok(json!({ + "ticket": ticket, + "branch": branch, + "base_branch": base_branch, + "strategy": strategy, + "commits_behind": behind, + "stale": stale, + "dry_run": true, + "applied": false, + "message": msg, + })); + } + + // --- Mutation path: dry_run=false AND confirm=true ---------------------- + + let remote_ref = format!("origin/{base_branch}"); + + match strategy { + "rebase" => { + git::run(worktree_path, &["rebase", &remote_ref]).with_context(|| { + format!( + "git rebase {remote_ref} failed in worktree {worktree_path:?}; \ + resolve conflicts manually then re-run `git rebase --continue`" + ) + })?; + } + "merge" => { + git::run(worktree_path, &["merge", "--no-edit", &remote_ref]).with_context(|| { + format!( + "git merge {remote_ref} failed in worktree {worktree_path:?}; \ + resolve conflicts manually then run `git merge --continue`" + ) + })?; + } + // unreachable: parse_strategy() validated the value above + _ => bail!("internal: unexpected strategy '{strategy}'"), + } + + Ok(json!({ + "ticket": ticket, + "branch": branch, + "base_branch": base_branch, + "strategy": strategy, + "commits_behind": behind, + "stale": stale, + "dry_run": false, + "applied": true, + "message": format!( + "{strategy}d {branch} onto {remote_ref} ({behind} commit{s} applied)", + s = if behind == 1 { "" } else { "s" }, + ), + })) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + // ------------------------------------------------------------------ + // validate_ticket + // ------------------------------------------------------------------ + + #[test] + fn validate_ticket_accepts_valid_ids() { + assert!(validate_ticket("PROJ-123").is_ok()); + assert!(validate_ticket("feat_123").is_ok()); + assert!(validate_ticket("v1.0.0").is_ok()); + assert!(validate_ticket("ABC").is_ok()); + } + + #[test] + fn validate_ticket_rejects_empty() { + let err = validate_ticket("").expect_err("empty should fail"); + assert!(err.to_string().contains("empty"), "{err}"); + } + + #[test] + fn validate_ticket_rejects_shell_chars() { + for bad in &[";rm -rf", "$(cmd)", "a b", "tick&et", "bad;one"] { + let err = validate_ticket(bad).expect_err("unsafe chars should fail"); + assert!( + err.to_string().contains("unsafe"), + "expected 'unsafe' in: {err}" + ); + } + } + + #[test] + fn validate_ticket_rejects_too_long() { + let long = "A".repeat(129); + let err = validate_ticket(&long).expect_err("too-long should fail"); + assert!(err.to_string().contains("too long"), "{err}"); + } + + // ------------------------------------------------------------------ + // parse_strategy + // ------------------------------------------------------------------ + + #[test] + fn parse_strategy_defaults_to_rebase() { + assert_eq!(parse_strategy(&json!({})).unwrap(), "rebase"); + } + + #[test] + fn parse_strategy_accepts_merge() { + assert_eq!( + parse_strategy(&json!({"strategy": "merge"})).unwrap(), + "merge" + ); + } + + #[test] + fn parse_strategy_rejects_unknown() { + let err = parse_strategy(&json!({"strategy": "squash"})).expect_err("squash invalid"); + assert!(err.to_string().contains("squash"), "{err}"); + } + + // ------------------------------------------------------------------ + // run() — missing ticket + // ------------------------------------------------------------------ + + #[test] + fn sync_rejects_missing_ticket() { + let ctx = McpContext::from_cwd(false).expect("context"); + let err = run(&ctx, json!({})).expect_err("missing ticket should fail"); + assert!( + err.to_string().contains("ticket") || err.to_string().contains("empty"), + "error should mention ticket: {err}" + ); + } + + // ------------------------------------------------------------------ + // run() — invalid ticket characters + // ------------------------------------------------------------------ + + #[test] + fn sync_rejects_unsafe_ticket() { + let ctx = McpContext::from_cwd(false).expect("context"); + let err = run(&ctx, json!({"ticket": "bad;ticket"})).expect_err("unsafe ticket"); + assert!( + err.to_string().contains("unsafe") || err.to_string().contains("ticket"), + "error should mention validation: {err}" + ); + } + + // ------------------------------------------------------------------ + // run() — non-existent worktree (dry_run default) + // ------------------------------------------------------------------ + + #[test] + fn sync_dry_run_fails_for_nonexistent_worktree() { + let dir = tempfile::tempdir().expect("temp dir"); + crate::git::run(dir.path(), &["init"]).expect("git init"); + crate::worktree::ParsecState::default() + .save(dir.path()) + .expect("state save"); + + let ctx = crate::mcp::McpContext { + repo_path: dir.path().to_path_buf(), + github_token: None, + github_scopes: None, + dry_run: false, + }; + + // dry_run=true (default) — should fail at worktree lookup, not at mutation + let err = run(&ctx, json!({"ticket": "PROJ-1"})).expect_err("unknown worktree"); + assert!( + err.to_string().contains("PROJ-1") + || err.to_string().contains("worktree") + || err.to_string().contains("not found"), + "error should mention missing worktree: {err}" + ); + } + + // ------------------------------------------------------------------ + // run() — confirm=false always yields dry_run regardless of dry_run param + // ------------------------------------------------------------------ + + #[test] + fn sync_confirm_false_forces_dry_run_path() { + let dir = tempfile::tempdir().expect("temp dir"); + crate::git::run(dir.path(), &["init"]).expect("git init"); + crate::worktree::ParsecState::default() + .save(dir.path()) + .expect("state save"); + + let ctx = crate::mcp::McpContext { + repo_path: dir.path().to_path_buf(), + github_token: None, + github_scopes: None, + dry_run: false, + }; + + // dry_run=false but confirm=false → should still use dry_run path + // (fails at worktree lookup before reaching the mutation gate check) + let err = run( + &ctx, + json!({"ticket": "PROJ-99", "dry_run": false, "confirm": false}), + ) + .expect_err("worktree lookup must fail"); + // Must NOT attempt actual git rebase/merge (test passes as long as + // the error is from worktree resolution, not from a mutation attempt). + assert!( + err.to_string().contains("PROJ-99") + || err.to_string().contains("not found") + || err.to_string().contains("worktree"), + "error should be from worktree resolution: {err}" + ); + } +} diff --git a/src/mcp/tools/worktree.rs b/src/mcp/tools/worktree.rs new file mode 100644 index 0000000..a4f658b --- /dev/null +++ b/src/mcp/tools/worktree.rs @@ -0,0 +1,842 @@ +//! MCP tool handlers for worktree operations. +//! +//! Tools: `worktree_list`, `worktree_start`, `worktree_status`, `worktree_ship`. +//! +//! `worktree_list` and `worktree_status` are wired as read-only Phase 4 +//! handlers. `worktree_start` is wired as of Phase 30 (#293). +//! `worktree_ship` is wired as of Phase 31 (#293): push + `gh pr create` + +//! optional cleanup, guarded by the mandatory dry_run/confirm two-key gate. + +use crate::config::ParsecConfig; +use crate::mcp::McpContext; +use crate::worktree::WorktreeManager; +use anyhow::{Context, Result}; +use serde_json::json; +use std::path::PathBuf; + +fn repo_path(ctx: &McpContext, input: &serde_json::Value) -> PathBuf { + input + .get("repo") + .and_then(serde_json::Value::as_str) + .map(PathBuf::from) + .unwrap_or_else(|| ctx.repo_path.clone()) +} + +fn manager(ctx: &McpContext, input: &serde_json::Value) -> Result { + let config = ParsecConfig::load().context("failed to load parsec config")?; + WorktreeManager::new(&repo_path(ctx, input), &config) +} + +/// `worktree_list` — list all parsec-managed worktrees. +/// +/// # Errors +/// Returns an error if the git repository cannot be read. +pub fn list(ctx: &McpContext, input: serde_json::Value) -> Result { + let manager = manager(ctx, &input)?; + let worktrees = manager.list()?; + + Ok(json!({ + "repo": manager.repo_root(), + "count": worktrees.len(), + "worktrees": worktrees, + "pr_overlay": false, + "ci_overlay": false, + })) +} + +/// `worktree_start` — create a new worktree for a ticket. +/// +/// Phase 30 (#293): wires `worktree_start` to `WorktreeManager::create` with +/// the same dry_run/confirm two-key gate used by the `sync` tool. +/// +/// ## Safety model +/// +/// The preflight layer enforces `CONFIRMATION_REQUIRED` when neither `dry_run` +/// nor `confirm` is set. Inside the handler, `dry_run=true` **always** returns +/// a preview even when `confirm=true`, so the tool can never mutate without an +/// explicit opt-out. +/// +/// ## Output — dry-run (preview) +/// +/// ```json +/// { +/// "ticket": "PROJ-123", +/// "branch": "feat/PROJ-123", +/// "base_branch": "main", +/// "title": null, +/// "parent_ticket": null, +/// "dry_run": true, +/// "created": false, +/// "message": "would create worktree for 'PROJ-123' branching 'feat/PROJ-123' from 'main'" +/// } +/// ``` +/// +/// ## Output — created +/// +/// ```json +/// { +/// "ticket": "PROJ-123", +/// "branch": "feat/PROJ-123", +/// "base_branch": "main", +/// "path": "/abs/path/to/worktree", +/// "title": null, +/// "dry_run": false, +/// "created": true, +/// "message": "created worktree for 'PROJ-123' at /abs/path/to/worktree" +/// } +/// ``` +/// +/// # Errors +/// +/// Returns an error when `ticket` is empty or contains unsafe characters, or +/// when the underlying `git worktree add` step fails on the mutation path. +pub fn start(ctx: &McpContext, input: serde_json::Value) -> anyhow::Result { + // --- Parse & validate inputs ------------------------------------------- + + let ticket = input + .get("ticket") + .and_then(serde_json::Value::as_str) + .unwrap_or("") + .to_owned(); + crate::worktree::validate_ticket_id(&ticket)?; + + let base: Option = input + .get("base") + .and_then(serde_json::Value::as_str) + .map(str::to_owned); + let title: Option = input + .get("title") + .and_then(serde_json::Value::as_str) + .map(str::to_owned); + let on: Option = input + .get("on") + .and_then(serde_json::Value::as_str) + .map(str::to_owned); + + let dry_run = input + .get("dry_run") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + let confirm = input + .get("confirm") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + + // --- Config + manager --------------------------------------------------- + + let config = ParsecConfig::load().context("failed to load parsec config")?; + let wt_manager = + WorktreeManager::new(&repo_path(ctx, &input), &config).context("failed to open repo")?; + + // Compute the effective base branch for the preview and mutation paths. + // Resolution order: explicit arg → parent worktree branch → config default → "main". + let effective_base: String = base + .as_deref() + .map(str::to_owned) + .or_else(|| { + on.as_deref() + .and_then(|parent| wt_manager.get(parent).ok()) + .map(|ws| ws.branch) + }) + .or_else(|| config.workspace.default_base.clone()) + .unwrap_or_else(|| "main".to_owned()); + + let branch_name = format!("{}{}", config.workspace.branch_prefix, ticket); + + // --- Gate: dry_run=true always returns preview -------------------------- + + if dry_run || !confirm { + return Ok(serde_json::json!({ + "ticket": ticket, + "branch": branch_name, + "base_branch": effective_base, + "title": title, + "parent_ticket": on, + "dry_run": true, + "created": false, + "message": format!( + "would create worktree for '{}' branching '{}' from '{}'", + ticket, branch_name, effective_base, + ), + })); + } + + // --- Mutation path: dry_run=false AND confirm=true ---------------------- + + let workspace = wt_manager + .create(&ticket, base.as_deref(), title, on.as_deref(), None) + .with_context(|| format!("failed to create worktree for ticket '{ticket}'"))?; + + Ok(serde_json::json!({ + "ticket": workspace.ticket, + "branch": workspace.branch, + "base_branch": workspace.base_branch, + "path": workspace.path, + "title": workspace.ticket_title, + "dry_run": false, + "created": true, + "message": format!( + "created worktree for '{}' at {}", + workspace.ticket, + workspace.path.display(), + ), + })) +} + +/// `worktree_status` — detailed status for a single worktree. +/// +/// # Errors +/// Returns an error if the ticket has no associated worktree. +pub fn status(ctx: &McpContext, input: serde_json::Value) -> Result { + let ticket = input + .get("ticket") + .and_then(serde_json::Value::as_str) + .filter(|ticket| !ticket.is_empty()) + .context("worktree_status requires a non-empty ticket")?; + crate::worktree::validate_ticket_id(ticket)?; + + let manager = manager(ctx, &input)?; + let workspace = manager.get(ticket)?; + + Ok(json!({ + "repo": manager.repo_root(), + "workspace": workspace, + "pr_overlay": null, + "ci_overlay": null, + })) +} + +/// `worktree_ship` — push branch, open/update PR, optionally clean up. +/// +/// Phase 31 (#293): wires `worktree_ship` to `WorktreeManager::ship_push` + +/// `gh pr create` with the mandatory dry_run / confirm two-key gate. +/// +/// ## Safety model +/// +/// Same pattern as `sync` and `worktree_start`: +/// - `dry_run=true` → preview only (default safe) +/// - `dry_run=false + confirm=true` → push + create PR + optional cleanup +/// +/// The preflight layer enforces `CONFIRMATION_REQUIRED` before this handler +/// runs, so `dry_run` and `confirm` can never both be absent here. +/// +/// ## Output — dry-run (preview) +/// +/// ```json +/// { +/// "ticket": "PROJ-123", +/// "branch": "feat/PROJ-123", +/// "base_branch": "main", +/// "draft": false, +/// "no_cleanup": false, +/// "dry_run": true, +/// "shipped": false, +/// "message": "would push 'feat/PROJ-123' and open PR against 'main'" +/// } +/// ``` +/// +/// ## Output — shipped +/// +/// ```json +/// { +/// "ticket": "PROJ-123", +/// "branch": "feat/PROJ-123", +/// "base_branch": "main", +/// "pr_url": "https://github.com/org/repo/pull/42", +/// "pr_number": 42, +/// "draft": false, +/// "cleaned_up": false, +/// "dry_run": false, +/// "shipped": true, +/// "message": "pushed 'feat/PROJ-123' and opened PR #42" +/// } +/// ``` +/// +/// # Errors +/// +/// Returns an error when `ticket` is empty, contains unsafe characters, or has +/// no registered worktree. The mutation path also returns an error when `git +/// push` or `gh pr create` fails. +pub fn ship(ctx: &McpContext, input: serde_json::Value) -> anyhow::Result { + // --- Parse & validate inputs ------------------------------------------- + + let ticket = input + .get("ticket") + .and_then(serde_json::Value::as_str) + .unwrap_or("") + .to_owned(); + crate::worktree::validate_ticket_id(&ticket)?; + + let draft = input + .get("draft") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + let no_cleanup = input + .get("no_cleanup") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + let dry_run = input + .get("dry_run") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + let confirm = input + .get("confirm") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + + // --- Config + manager --------------------------------------------------- + + let config = ParsecConfig::load().context("failed to load parsec config")?; + let wt_manager = + WorktreeManager::new(&repo_path(ctx, &input), &config).context("failed to open repo")?; + + // Look up the workspace early — validates ticket is registered. + let workspace = wt_manager.get(&ticket)?; + let branch = workspace.branch.clone(); + let base_branch = workspace.base_branch.clone(); + let title = workspace.ticket_title.clone(); + + // --- Gate: dry_run=true OR !confirm → preview -------------------------- + + if dry_run || !confirm { + return Ok(serde_json::json!({ + "ticket": ticket, + "branch": branch, + "base_branch": base_branch, + "draft": draft, + "no_cleanup": no_cleanup, + "dry_run": true, + "shipped": false, + "message": format!( + "would push '{}' and open {}PR against '{}'", + branch, + if draft { "draft " } else { "" }, + base_branch, + ), + })); + } + + // --- Mutation path: dry_run=false AND confirm=true ---------------------- + + let token = ctx + .github_token + .as_ref() + .map(|t| t.expose_secret().to_owned()); + + // Step 1: push branch from the worktree. + wt_manager + .ship_push(&ticket) + .with_context(|| format!("failed to push branch '{branch}'"))?; + + // Step 2: create PR via gh CLI. + let pr_title = title + .as_deref() + .map(|t| format!("[{ticket}] {t}")) + .unwrap_or_else(|| ticket.clone()); + + let repo = repo_path(ctx, &input); + let pr_result = create_gh_pr( + &repo, + &branch, + &base_branch, + &pr_title, + draft, + token.as_deref(), + ); + + let (pr_url, pr_number) = match pr_result { + Ok(pair) => pair, + Err(e) => { + // Push succeeded but PR creation failed — partial result. + return Ok(serde_json::json!({ + "ticket": ticket, + "branch": branch, + "base_branch": base_branch, + "draft": draft, + "dry_run": false, + "shipped": false, + "pushed": true, + "pr_created": false, + "error": format!("pushed branch but PR creation failed: {e}"), + })); + } + }; + + // Step 3: optional cleanup (respects config.ship.auto_cleanup). + let cleaned_up = if !no_cleanup { + wt_manager.ship_cleanup(&ticket).unwrap_or(false) + } else { + false + }; + + Ok(serde_json::json!({ + "ticket": ticket, + "branch": branch, + "base_branch": base_branch, + "pr_url": pr_url, + "pr_number": pr_number, + "draft": draft, + "cleaned_up": cleaned_up, + "dry_run": false, + "shipped": true, + "message": format!( + "pushed '{branch}' and opened PR #{pr_number} ({pr_url})", + ), + })) +} + +/// Call `gh pr create` and return `(url, pr_number)`. +/// +/// If a PR already exists for the branch, falls back to `gh pr view` to +/// retrieve the existing URL and number. +fn create_gh_pr( + repo: &std::path::Path, + branch: &str, + base: &str, + title: &str, + draft: bool, + token: Option<&str>, +) -> anyhow::Result<(String, u64)> { + let mut cmd = std::process::Command::new("gh"); + cmd.args([ + "pr", + "create", + "--head", + branch, + "--base", + base, + "--title", + title, + "--body", + "", + "--json", + "url,number", + ]); + if draft { + cmd.arg("--draft"); + } + cmd.current_dir(repo); + if let Some(t) = token { + cmd.env("GH_TOKEN", t); + } + + let output = cmd.output().context("failed to spawn 'gh pr create'")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + // PR already exists — retrieve the existing one instead. + if stderr.contains("already exists") || stderr.contains("A pull request for branch") { + return find_existing_gh_pr(repo, branch, token); + } + anyhow::bail!( + "gh pr create failed for branch '{}': {}", + branch, + stderr.trim() + ); + } + + let raw = String::from_utf8(output.stdout).context("gh pr create produced non-UTF-8 output")?; + let value: serde_json::Value = + serde_json::from_str(&raw).context("failed to parse gh pr create JSON")?; + + let url = value["url"] + .as_str() + .context("missing 'url' in gh pr create output")? + .to_owned(); + let number = value["number"] + .as_u64() + .context("missing 'number' in gh pr create output")?; + + Ok((url, number)) +} + +/// Retrieve URL + number of an existing PR for the branch via `gh pr view`. +fn find_existing_gh_pr( + repo: &std::path::Path, + branch: &str, + token: Option<&str>, +) -> anyhow::Result<(String, u64)> { + let mut cmd = std::process::Command::new("gh"); + cmd.args(["pr", "view", branch, "--json", "url,number"]); + cmd.current_dir(repo); + if let Some(t) = token { + cmd.env("GH_TOKEN", t); + } + + let output = cmd.output().context("failed to spawn 'gh pr view'")?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!( + "gh pr view failed for branch '{}': {}", + branch, + stderr.trim() + ); + } + + let raw = String::from_utf8(output.stdout).context("gh pr view produced non-UTF-8 output")?; + let value: serde_json::Value = + serde_json::from_str(&raw).context("failed to parse gh pr view JSON")?; + + let url = value["url"] + .as_str() + .context("missing 'url' in gh pr view output")? + .to_owned(); + let number = value["number"] + .as_u64() + .context("missing 'number' in gh pr view output")?; + + Ok((url, number)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::worktree::{ParsecState, Workspace, WorkspaceStatus}; + use chrono::TimeZone; + + fn fixture_repo() -> (tempfile::TempDir, McpContext) { + let dir = tempfile::tempdir().expect("temp repo"); + crate::git::run(dir.path(), &["init"]).expect("git init"); + + let mut state = ParsecState::default(); + state.add_workspace(Workspace { + ticket: "ABC-123".to_owned(), + path: dir.path().join("../git-parsec.ABC-123"), + branch: "feat/ABC-123".to_owned(), + base_branch: "main".to_owned(), + created_at: chrono::Utc.with_ymd_and_hms(2026, 6, 17, 0, 0, 0).unwrap(), + ticket_title: Some("Wire MCP worktree status".to_owned()), + status: WorkspaceStatus::Active, + parent_ticket: None, + }); + state.save(dir.path()).expect("state save"); + + let ctx = McpContext { + repo_path: dir.path().to_path_buf(), + github_token: None, + github_scopes: None, + dry_run: false, + }; + + (dir, ctx) + } + + #[test] + fn worktree_list_returns_registered_workspaces() { + let (_dir, ctx) = fixture_repo(); + + let payload = list(&ctx, json!({})).expect("worktree list"); + + assert_eq!(payload["count"], 1); + assert_eq!(payload["worktrees"][0]["ticket"], "ABC-123"); + assert_eq!(payload["worktrees"][0]["branch"], "feat/ABC-123"); + assert_eq!(payload["pr_overlay"], false); + assert_eq!(payload["ci_overlay"], false); + } + + #[test] + fn worktree_status_returns_matching_workspace() { + let (_dir, ctx) = fixture_repo(); + + let payload = status(&ctx, json!({"ticket": "ABC-123"})).expect("worktree status payload"); + + assert_eq!(payload["workspace"]["ticket"], "ABC-123"); + assert_eq!(payload["workspace"]["base_branch"], "main"); + assert!(payload["pr_overlay"].is_null()); + assert!(payload["ci_overlay"].is_null()); + } + + #[test] + fn worktree_status_requires_ticket() { + let (_dir, ctx) = fixture_repo(); + + let err = status(&ctx, json!({})).expect_err("missing ticket should fail"); + + assert!(err.to_string().contains("requires a non-empty ticket")); + } + + // ------------------------------------------------------------------ + // worktree_start — Phase 30 dry-run preview tests + // ------------------------------------------------------------------ + + #[test] + fn worktree_start_dry_run_returns_preview() { + let (dir, _ctx) = fixture_repo(); + let ctx = McpContext { + repo_path: dir.path().to_path_buf(), + github_token: None, + github_scopes: None, + dry_run: false, + }; + + let payload = + start(&ctx, json!({"ticket": "FEAT-42", "dry_run": true})).expect("dry-run preview"); + + // Preview must include key fields without touching git + assert_eq!(payload["ticket"], "FEAT-42"); + assert_eq!(payload["dry_run"], true); + assert_eq!(payload["created"], false); + let msg = payload["message"].as_str().expect("message is a string"); + assert!( + msg.contains("would create"), + "expected preview message, got: {msg}" + ); + assert!(msg.contains("FEAT-42")); + // Branch name must include ticket + let branch = payload["branch"].as_str().expect("branch is a string"); + assert!(branch.contains("FEAT-42")); + } + + #[test] + fn worktree_start_without_confirm_returns_preview() { + // Preflight already blocks the no-dry_run no-confirm case. + // Here we test that the handler itself (if ever called without confirm) + // also defaults to preview mode. + let (dir, _ctx) = fixture_repo(); + let ctx = McpContext { + repo_path: dir.path().to_path_buf(), + github_token: None, + github_scopes: None, + dry_run: false, + }; + + // Simulate the handler being invoked after a dry_run=true arg + let payload = start(&ctx, json!({"ticket": "FEAT-99", "dry_run": true})) + .expect("preview without confirm"); + + assert_eq!(payload["created"], false); + assert_eq!(payload["dry_run"], true); + } + + #[test] + fn worktree_start_dry_run_wins_over_confirm() { + let (dir, _ctx) = fixture_repo(); + let ctx = McpContext { + repo_path: dir.path().to_path_buf(), + github_token: None, + github_scopes: None, + dry_run: false, + }; + + let payload = start( + &ctx, + json!({"ticket": "FEAT-77", "dry_run": true, "confirm": true}), + ) + .expect("dry_run=true wins even with confirm=true"); + + assert_eq!(payload["dry_run"], true); + assert_eq!(payload["created"], false); + } + + #[test] + fn worktree_start_dry_run_with_explicit_base() { + let (dir, _ctx) = fixture_repo(); + let ctx = McpContext { + repo_path: dir.path().to_path_buf(), + github_token: None, + github_scopes: None, + dry_run: false, + }; + + let payload = start( + &ctx, + json!({"ticket": "FEAT-50", "base": "develop", "dry_run": true}), + ) + .expect("dry-run with explicit base"); + + assert_eq!(payload["base_branch"], "develop"); + assert_eq!(payload["dry_run"], true); + } + + #[test] + fn worktree_start_dry_run_with_title_and_parent() { + let (dir, _ctx) = fixture_repo(); + let ctx = McpContext { + repo_path: dir.path().to_path_buf(), + github_token: None, + github_scopes: None, + dry_run: false, + }; + + let payload = start( + &ctx, + json!({ + "ticket": "FEAT-55", + "title": "My feature", + "on": "ABC-123", + "dry_run": true, + }), + ) + .expect("dry-run with title and parent"); + + assert_eq!(payload["title"], "My feature"); + // parent_ticket should echo the on field + assert_eq!(payload["parent_ticket"], "ABC-123"); + // base_branch should be the parent's branch since on=ABC-123 is registered + assert_eq!(payload["base_branch"], "feat/ABC-123"); + assert_eq!(payload["dry_run"], true); + } + + #[test] + fn worktree_start_rejects_empty_ticket() { + let (dir, _ctx) = fixture_repo(); + let ctx = McpContext { + repo_path: dir.path().to_path_buf(), + github_token: None, + github_scopes: None, + dry_run: false, + }; + + let err = start(&ctx, json!({"ticket": "", "dry_run": true})) + .expect_err("empty ticket must be rejected"); + + assert!( + err.to_string().to_lowercase().contains("empty"), + "expected empty-ticket error, got: {err}" + ); + } + + // ------------------------------------------------------------------ + // worktree_ship — Phase 31 dry-run preview tests + // ------------------------------------------------------------------ + + #[test] + fn worktree_ship_dry_run_returns_preview() { + let (_dir, ctx) = fixture_repo(); + + let payload = + ship(&ctx, json!({"ticket": "ABC-123", "dry_run": true})).expect("dry-run preview"); + + assert_eq!(payload["ticket"], "ABC-123"); + assert_eq!(payload["dry_run"], true); + assert_eq!(payload["shipped"], false); + assert_eq!(payload["draft"], false); + let msg = payload["message"].as_str().expect("message string"); + assert!( + msg.contains("would push"), + "expected preview wording, got: {msg}" + ); + assert!(msg.contains("ABC-123") || msg.contains("feat/")); + } + + #[test] + fn worktree_ship_without_confirm_returns_preview() { + let (_dir, ctx) = fixture_repo(); + + // Neither dry_run nor confirm — handler defaults to preview. + // (Preflight would block this case in production; here we test + // the handler's own safe-default.) + let payload = ship(&ctx, json!({"ticket": "ABC-123"})).expect("handler default preview"); + + assert_eq!(payload["shipped"], false); + assert_eq!(payload["dry_run"], true); + } + + #[test] + fn worktree_ship_dry_run_wins_over_confirm() { + let (_dir, ctx) = fixture_repo(); + + let payload = ship( + &ctx, + json!({"ticket": "ABC-123", "dry_run": true, "confirm": true}), + ) + .expect("dry_run=true wins even with confirm=true"); + + assert_eq!(payload["dry_run"], true); + assert_eq!(payload["shipped"], false); + } + + #[test] + fn worktree_ship_dry_run_draft_flag_in_message() { + let (_dir, ctx) = fixture_repo(); + + let payload = ship( + &ctx, + json!({"ticket": "ABC-123", "draft": true, "dry_run": true}), + ) + .expect("draft dry-run preview"); + + assert_eq!(payload["draft"], true); + let msg = payload["message"].as_str().expect("message"); + assert!( + msg.contains("draft"), + "draft flag should appear in preview message, got: {msg}" + ); + } + + #[test] + fn worktree_ship_dry_run_no_cleanup_flag_preserved() { + let (_dir, ctx) = fixture_repo(); + + let payload = ship( + &ctx, + json!({"ticket": "ABC-123", "no_cleanup": true, "dry_run": true}), + ) + .expect("no_cleanup dry-run preview"); + + assert_eq!(payload["no_cleanup"], true); + assert_eq!(payload["shipped"], false); + } + + #[test] + fn worktree_ship_rejects_empty_ticket() { + let (_dir, ctx) = fixture_repo(); + + let err = ship(&ctx, json!({"ticket": "", "dry_run": true})) + .expect_err("empty ticket must be rejected"); + + assert!( + err.to_string().to_lowercase().contains("empty"), + "expected empty-ticket error, got: {err}" + ); + } + + #[test] + fn worktree_ship_rejects_unsafe_ticket() { + let (_dir, ctx) = fixture_repo(); + + let err = ship(&ctx, json!({"ticket": "bad;ticket", "dry_run": true})) + .expect_err("unsafe ticket must be rejected"); + + let msg = err.to_string(); + assert!( + msg.contains("unsafe") || msg.contains("ticket"), + "expected validation error, got: {msg}" + ); + } + + #[test] + fn worktree_ship_dry_run_fails_for_nonexistent_ticket() { + let (_dir, ctx) = fixture_repo(); + + let err = ship(&ctx, json!({"ticket": "UNKNOWN-99", "dry_run": true})) + .expect_err("non-existent ticket must fail"); + + assert!( + err.to_string().contains("UNKNOWN-99") + || err.to_string().contains("worktree") + || err.to_string().contains("not found"), + "error should mention missing ticket: {err}" + ); + } + + #[test] + fn worktree_start_rejects_unsafe_ticket() { + let (dir, _ctx) = fixture_repo(); + let ctx = McpContext { + repo_path: dir.path().to_path_buf(), + github_token: None, + github_scopes: None, + dry_run: false, + }; + + let err = start(&ctx, json!({"ticket": "../evil", "dry_run": true})) + .expect_err("path-traversal ticket must be rejected"); + + let msg = err.to_string(); + assert!( + msg.contains("unsafe") || msg.contains(".."), + "expected unsafe-ticket error, got: {msg}" + ); + } +} diff --git a/src/output/human.rs b/src/output/human.rs index b94e225..0b7f9b1 100644 --- a/src/output/human.rs +++ b/src/output/human.rs @@ -1100,6 +1100,12 @@ pub fn print_health(records: &[super::HealthRecord]) { println!("{}", "parsec health".bold()); let mut issues = 0usize; for r in records { + let missing_tag = if r.missing { + " ⚠ worktree missing!".red().to_string() + } else { + String::new() + }; + let lock_tag = if r.has_lock { " ⚠ lock file!".red().to_string() } else { @@ -1156,14 +1162,36 @@ pub fn print_health(records: &[super::HealthRecord]) { let ci_issue = matches!(r.ci_status.as_deref(), Some("failing") | Some("failure")); - let any_issue = r.has_lock + // Phase 3: in-progress git operation tags + let rebase_tag = if r.rebase_in_progress { + " ⚠ rebase in progress!".red().to_string() + } else { + String::new() + }; + let merge_tag = if r.merge_in_progress { + " ⚠ merge in progress!".red().to_string() + } else { + String::new() + }; + let cherry_tag = if r.cherry_pick_in_progress { + " ⚠ cherry-pick in progress!".red().to_string() + } else { + String::new() + }; + + let op_in_progress = + r.rebase_in_progress || r.merge_in_progress || r.cherry_pick_in_progress; + + let any_issue = r.missing + || r.has_lock || r.uncommitted > 0 || ci_issue + || op_in_progress || r.stale_days .map(|d| d > r.stale_threshold_days) .unwrap_or(false); - let icon = if r.has_lock || ci_issue { + let icon = if r.missing || r.has_lock || ci_issue || op_in_progress { "✗".red().to_string() } else if any_issue { "⚠".yellow().to_string() @@ -1176,13 +1204,17 @@ pub fn print_health(records: &[super::HealthRecord]) { } println!( - " {} {:<20}{}{}{}{}", + " {} {:<20}{}{}{}{}{}{}{}{}", icon, r.ticket.bold(), + missing_tag, uncommitted_tag, stale_tag, lock_tag, ci_tag, + rebase_tag, + merge_tag, + cherry_tag, ); } println!(); diff --git a/src/output/json.rs b/src/output/json.rs index 616cf26..bcc2b54 100644 --- a/src/output/json.rs +++ b/src/output/json.rs @@ -373,6 +373,7 @@ pub fn print_health(records: &[super::HealthRecord]) { let ci_failing = matches!(r.ci_status.as_deref(), Some("failing") | Some("failure")); json!({ "ticket": r.ticket, + "missing": r.missing, "has_lock": r.has_lock, "uncommitted": r.uncommitted, "stale_days": r.stale_days, @@ -380,17 +381,24 @@ pub fn print_health(records: &[super::HealthRecord]) { "ci_status": r.ci_status, "pr_number": r.pr_number, "ci_failing": ci_failing, + "rebase_in_progress": r.rebase_in_progress, + "merge_in_progress": r.merge_in_progress, + "cherry_pick_in_progress": r.cherry_pick_in_progress, }) }) .collect(); let all_healthy = records.iter().all(|r| { - !r.has_lock + !r.missing + && !r.has_lock && r.uncommitted == 0 && !r .stale_days .map(|d| d > r.stale_threshold_days) .unwrap_or(false) && !matches!(r.ci_status.as_deref(), Some("failing") | Some("failure")) + && !r.rebase_in_progress + && !r.merge_in_progress + && !r.cherry_pick_in_progress }); println!( "{}", diff --git a/src/output/mod.rs b/src/output/mod.rs index e17e3a7..155694e 100644 --- a/src/output/mod.rs +++ b/src/output/mod.rs @@ -47,6 +47,8 @@ pub struct DoctorCheck { pub struct HealthRecord { /// Ticket identifier for the worktree. pub ticket: String, + /// Whether the registered worktree directory is missing from disk. + pub missing: bool, /// Number of uncommitted files (staged + unstaged). pub uncommitted: usize, /// Days since the last commit, or `None` when the history is unreadable. @@ -60,6 +62,15 @@ pub struct HealthRecord { pub ci_status: Option, /// GitHub PR number linked to this worktree's branch, if any. pub pr_number: Option, + /// Phase 3: a rebase is in progress in this worktree. + /// Detected by the presence of `.git/rebase-merge/` or `.git/rebase-apply/`. + pub rebase_in_progress: bool, + /// Phase 3: a merge is in progress in this worktree. + /// Detected by the presence of `.git/MERGE_HEAD`. + pub merge_in_progress: bool, + /// Phase 3: a cherry-pick is in progress in this worktree. + /// Detected by the presence of `.git/CHERRY_PICK_HEAD`. + pub cherry_pick_in_progress: bool, } /// Per-worktree test outcome produced by `parsec test` (issue #247). diff --git a/src/panic_handler.rs b/src/panic_handler.rs new file mode 100644 index 0000000..ce78dc6 --- /dev/null +++ b/src/panic_handler.rs @@ -0,0 +1,274 @@ +//! `parsec` panic hook — opt-in crash report collection (#298). +//! +//! # Phase 1 +//! Registers a custom panic hook that: +//! 1. Prints a user-friendly crash banner to **stderr** (always). +//! 2. When `enabled = true` (opt-in), writes a structured JSON report to +//! `/parsec/crash-.json` so users can share it +//! with the maintainers. +//! +//! No data is **transmitted** automatically. The user must opt in via config +//! and manually share the file. See `docs/crash-report.md` for the full +//! privacy policy. +//! +//! # Report Contents +//! - `parsec_version` — crate version from `Cargo.toml` +//! - `timestamp` — ISO-8601 UTC timestamp of the crash +//! - `os` — `/` (e.g. `"unix/macos"`) +//! - `shell` — value of `$SHELL` (sanitised, may be absent) +//! - `command_args` — `argv[1..]` (first arg only, not flags, for privacy) +//! - `panic_message` — the panic payload as a string (may be truncated) +//! - `panic_location` — `"file:line"` where the panic occurred +//! +//! The full back-trace is **not** collected by default; set the +//! `RUST_BACKTRACE=1` environment variable for a local stack trace. + +use std::panic; +use std::sync::atomic::{AtomicBool, Ordering}; + +static REPORT_ENABLED: AtomicBool = AtomicBool::new(false); + +const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// Call this once at startup, before any `tokio` threads are spawned. +/// +/// When `enabled` is `true` the hook will also write a JSON crash report to +/// the OS cache directory (for example, `~/.cache/parsec/` on Linux or +/// `~/Library/Caches/parsec/` on macOS). +pub fn setup(enabled: bool) { + REPORT_ENABLED.store(enabled, Ordering::SeqCst); + + panic::set_hook(Box::new(|info| { + // ── Always: human-readable crash banner ──────────────────────────── + let location = info + .location() + .map(|l| format!("{}:{}", l.file(), l.line())) + .unwrap_or_else(|| "".to_string()); + + let message = format_panic_message(info); + + eprintln!(); + eprintln!("╔══════════════════════════════════════════════════════╗"); + eprintln!("║ parsec crashed — sorry about that! ║"); + eprintln!("╚══════════════════════════════════════════════════════╝"); + eprintln!(); + eprintln!(" version : parsec {CURRENT_VERSION}"); + eprintln!(" location : {location}"); + eprintln!(" message : {message}"); + eprintln!(); + eprintln!(" For a stack trace, re-run with RUST_BACKTRACE=1."); + + // ── Opt-in: write JSON report ─────────────────────────────────────── + if REPORT_ENABLED.load(Ordering::SeqCst) { + let report = build_report(&location, &message); + match save_report(&report) { + Ok(path) => { + eprintln!(" Crash report saved to: {path}"); + eprintln!( + " To report this bug, open:\n \ + https://github.com/erishforG/git-parsec/issues/new" + ); + } + Err(e) => { + eprintln!(" (crash report write failed: {e})"); + } + } + } else { + eprintln!( + " Tip: enable crash reports with `[crash_report] enabled = true` in\n \ + your parsec config to help diagnose issues. See docs/crash-report.md." + ); + } + + eprintln!(); + })); +} + +// ── Helpers ────────────────────────────────────────────────────────────────── + +/// Extract the panic payload as a trimmed, ASCII-safe string (≤ 512 chars). +fn format_panic_message(info: &panic::PanicHookInfo<'_>) -> String { + let raw = if let Some(s) = info.payload().downcast_ref::<&str>() { + s.to_string() + } else if let Some(s) = info.payload().downcast_ref::() { + s.clone() + } else { + "".to_string() + }; + // Truncate so the crash banner stays readable on narrow terminals. + let truncated = raw.chars().take(512).collect::(); + if truncated.len() < raw.len() { + format!("{truncated}…") + } else { + truncated + } +} + +/// Collect the crash report as a JSON string. +fn build_report(location: &str, message: &str) -> String { + let timestamp = { + // Use SystemTime → RFC-3339-ish without chrono (already a dep but + // keep this helper self-contained and allocation-light). + use std::time::{SystemTime, UNIX_EPOCH}; + let secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + // Minimal ISO-8601: "YYYY-MM-DDTHH:MM:SSZ" via integer maths. + let s = secs; + let (y, mo, d, h, mi, se) = unix_to_utc_fields(s); + format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{se:02}Z") + }; + + let os_family = std::env::consts::FAMILY; // "unix" | "windows" + let os_name = std::env::consts::OS; // "macos" | "linux" | "windows" … + + let shell = std::env::var("SHELL") + .ok() + .map(|s| { + // Keep only the base name for privacy (e.g. "/usr/bin/zsh" → "zsh"). + std::path::Path::new(&s) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unknown") + .to_string() + }) + .unwrap_or_else(|| "unknown".to_string()); + + // Capture argv[1] only (sub-command name) — no user-supplied arguments. + let subcommand = std::env::args() + .nth(1) + .unwrap_or_else(|| "".to_string()); + + // Escape double-quotes for the naive JSON builder below. + let esc = |s: &str| s.replace('\\', r"\\").replace('"', r#"\""#); + + format!( + r#"{{ + "parsec_version": "{ver}", + "timestamp": "{ts}", + "os": "{fam}/{os}", + "shell": "{shell}", + "subcommand": "{sub}", + "panic_location": "{loc}", + "panic_message": "{msg}" +}}"#, + ver = esc(CURRENT_VERSION), + ts = esc(×tamp), + fam = esc(os_family), + os = esc(os_name), + shell = esc(&shell), + sub = esc(&subcommand), + loc = esc(location), + msg = esc(message), + ) +} + +/// Persist the report JSON to the OS cache dir and return the file path. +fn save_report(json: &str) -> std::io::Result { + use std::time::{SystemTime, UNIX_EPOCH}; + + let ts = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + + let cache_dir = dirs::cache_dir() + .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no cache dir"))? + .join("parsec"); + + std::fs::create_dir_all(&cache_dir)?; + + let file_name = format!("crash-{ts}.json"); + let path = cache_dir.join(&file_name); + std::fs::write(&path, json)?; + Ok(path.to_string_lossy().into_owned()) +} + +/// Minimal UTC date/time decomposition from a Unix timestamp. +/// +/// Intentionally avoids the `chrono` dep (even though it is available) to +/// keep the panic hook free of code that could itself panic. The algorithm +/// covers dates from the Unix epoch through ≥ 2100. +fn unix_to_utc_fields(mut secs: u64) -> (u32, u32, u32, u32, u32, u32) { + let se = (secs % 60) as u32; + secs /= 60; + let mi = (secs % 60) as u32; + secs /= 60; + let h = (secs % 24) as u32; + secs /= 24; + + // Days since 1970-01-01 → Gregorian calendar. + // Algorithm: civil_from_days from https://howardhinnant.github.io/date_algorithms.html + let z = secs as i64 + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = (z - era * 146_097) as u64; + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; + let y = yoe as i64 + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = (doy - (153 * mp + 2) / 5 + 1) as u32; + let mo = if mp < 10 { mp + 3 } else { mp - 9 } as u32; + let y = if mo <= 2 { y + 1 } else { y } as u32; + + (y, mo, d, h, mi, se) +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unix_to_utc_known_dates() { + // 2024-01-15 11:50:45 UTC = 1705319445 + // Verified: python3 -c "import datetime; print(datetime.datetime.utcfromtimestamp(1705319445))" + let (y, mo, d, h, mi, se) = unix_to_utc_fields(1_705_319_445); + assert_eq!((y, mo, d), (2024, 1, 15)); + assert_eq!((h, mi, se), (11, 50, 45)); + } + + #[test] + fn unix_to_utc_epoch() { + let (y, mo, d, h, mi, se) = unix_to_utc_fields(0); + assert_eq!((y, mo, d), (1970, 1, 1)); + assert_eq!((h, mi, se), (0, 0, 0)); + } + + #[test] + fn unix_to_utc_leap_day() { + // 2024-02-29 00:00:00 UTC = 1709164800 + let (y, mo, d, _h, _mi, _se) = unix_to_utc_fields(1_709_164_800); + assert_eq!((y, mo, d), (2024, 2, 29)); + } + + #[test] + fn build_report_contains_required_fields() { + let json = build_report("src/main.rs:42", "test panic"); + assert!(json.contains("\"parsec_version\""), "version field"); + assert!(json.contains("\"timestamp\""), "timestamp field"); + assert!(json.contains("\"os\""), "os field"); + assert!(json.contains("\"shell\""), "shell field"); + assert!(json.contains("\"panic_location\""), "location field"); + assert!(json.contains("\"panic_message\""), "message field"); + assert!(json.contains("src/main.rs:42"), "location value"); + assert!(json.contains("test panic"), "message value"); + } + + #[test] + fn build_report_escapes_special_chars() { + let json = build_report("file.rs:1", r#"something "quoted" and \slashed"#); + // Must be valid-ish JSON: no raw unescaped double-quotes in values. + assert!(json.contains(r#"\"quoted\""#), "quotes escaped: {json}"); + assert!(json.contains(r"\\slashed"), "backslash escaped: {json}"); + } + + #[test] + fn setup_does_not_panic() { + // Calling setup a second time must not crash. (The previous hook is + // replaced; that's expected behaviour for our use-case.) + setup(false); + setup(false); + } +} diff --git a/tests/cli_tests.rs b/tests/cli_tests.rs index 82507a6..f827e6d 100644 --- a/tests/cli_tests.rs +++ b/tests/cli_tests.rs @@ -1,4 +1,5 @@ use assert_cmd::Command; +use mockito::Matcher; use predicates::prelude::*; use std::process::Command as StdCommand; use tempfile::TempDir; @@ -940,6 +941,117 @@ fn test_ship_dry_run() { .stdout(predicate::str::contains("DRY-SHIP")); } +#[test] +fn test_ship_no_cleanup_preserves_worktree() { + let (repo, _bare) = setup_repo_with_remote(); + let repo_path = repo.path().to_str().unwrap(); + + parsec() + .args(["start", "NC-SHIP", "--repo", repo_path]) + .assert() + .success(); + + // Read worktree path from state. + let state_path = repo.path().join(".parsec").join("state.json"); + let state_contents = std::fs::read_to_string(&state_path).unwrap(); + let state: serde_json::Value = serde_json::from_str(&state_contents).unwrap(); + let wt_path = state["workspaces"]["NC-SHIP"]["path"] + .as_str() + .expect("state.json should contain path for NC-SHIP") + .to_owned(); + + // Commit something so git push has content to send. + std::fs::write(format!("{}/nc.txt", wt_path), "no-cleanup test").unwrap(); + StdCommand::new("git") + .args(["add", "nc.txt"]) + .current_dir(&wt_path) + .output() + .unwrap(); + StdCommand::new("git") + .args(["commit", "-m", "nc commit"]) + .current_dir(&wt_path) + .output() + .unwrap(); + + // ship --no-pr --no-cleanup: push branch, skip PR creation, skip worktree removal. + parsec() + .args([ + "ship", + "NC-SHIP", + "--no-pr", + "--no-cleanup", + "--repo", + repo_path, + ]) + .assert() + .success(); + + // Worktree must still be listed despite auto_cleanup=true default. + parsec() + .args(["list", "--repo", repo_path]) + .assert() + .success() + .stdout(predicate::str::contains("NC-SHIP")); + + // Worktree directory must physically exist. + assert!( + std::path::Path::new(&wt_path).exists(), + "worktree directory should still exist after --no-cleanup ship" + ); +} + +#[test] +fn test_ship_auto_cleanup_removes_worktree() { + let (repo, _bare) = setup_repo_with_remote(); + let repo_path = repo.path().to_str().unwrap(); + + parsec() + .args(["start", "AC-SHIP", "--repo", repo_path]) + .assert() + .success(); + + // Read worktree path from state. + let state_path = repo.path().join(".parsec").join("state.json"); + let state_contents = std::fs::read_to_string(&state_path).unwrap(); + let state: serde_json::Value = serde_json::from_str(&state_contents).unwrap(); + let wt_path = state["workspaces"]["AC-SHIP"]["path"] + .as_str() + .expect("state.json should contain path for AC-SHIP") + .to_owned(); + + // Commit something so git push has content to send. + std::fs::write(format!("{}/ac.txt", wt_path), "auto-cleanup test").unwrap(); + StdCommand::new("git") + .args(["add", "ac.txt"]) + .current_dir(&wt_path) + .output() + .unwrap(); + StdCommand::new("git") + .args(["commit", "-m", "ac commit"]) + .current_dir(&wt_path) + .output() + .unwrap(); + + // ship --no-pr without --no-cleanup: auto_cleanup=true (default) should remove the worktree. + parsec() + .args(["ship", "AC-SHIP", "--no-pr", "--repo", repo_path]) + .assert() + .success(); + + // Worktree must no longer be listed. + parsec() + .args(["list", "--repo", repo_path]) + .assert() + .success() + .stdout(predicate::str::contains("AC-SHIP").not()); + + // Worktree directory must be physically gone. + assert!( + !std::path::Path::new(&wt_path).exists(), + "worktree directory should be removed after auto_cleanup ship" + ); +} + // --------------------------------------------------------------------------- // doctor // --------------------------------------------------------------------------- @@ -1508,6 +1620,16 @@ fn test_history_log_export_empty() { // parsec smartlog / sl (issue #245, #305) // --------------------------------------------------------------------------- +#[test] +fn test_smartlog_help_describes_shipped_overlay() { + parsec() + .args(["smartlog", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("GitHub PR/CI/review state")) + .stdout(predicate::str::contains("in later releases").not()); +} + /// `parsec smartlog` in a repo with no active worktrees should exit 0 and /// print the "No active worktrees" placeholder message. #[test] @@ -1798,6 +1920,38 @@ fn test_health_json_one_worktree() { ); } +/// A registry entry whose directory was removed externally must be reported +/// as missing and make the aggregate health result unhealthy. +#[test] +fn test_health_json_flags_missing_worktree() { + let (repo, _bare) = setup_repo_with_remote(); + let repo_path = repo.path().to_str().unwrap(); + + parsec() + .args(["start", "HL-MISSING", "--repo", repo_path]) + .assert() + .success(); + + let repo_name = repo.path().file_name().unwrap().to_string_lossy(); + let worktree_path = repo + .path() + .parent() + .unwrap() + .join(format!("{repo_name}.HL-MISSING")); + std::fs::remove_dir_all(&worktree_path).unwrap(); + + let output = parsec() + .args(["health", "--json", "--repo", repo_path]) + .output() + .unwrap(); + assert!(output.status.success()); + + let parsed: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(parsed["all_healthy"], false); + assert_eq!(parsed["worktrees"][0]["ticket"], "HL-MISSING"); + assert_eq!(parsed["worktrees"][0]["missing"], true); +} + /// `parsec health` must exit 0 even when worktrees have issues — health is /// informational and must not be used as a CI gate in Phase 1. #[test] @@ -2437,3 +2591,479 @@ fn test_dashboard_quiet_rejected() { combined ); } + +// --------------------------------------------------------------------------- +// parsec smartlog — overlay integration tests (#309 Phase 6, #310 Phase 4) +// --------------------------------------------------------------------------- + +/// `parsec smartlog --no-overlay` must succeed without any GitHub token and +/// must not emit PR or CI badges in its output. +/// +/// Validates issue #309 acceptance: "—no-overlay 시 placeholder 로 fallback". +#[test] +fn test_smartlog_no_overlay_skips_github() { + let (repo, _bare) = setup_repo_with_remote(); + let repo_path = repo.path().to_str().unwrap(); + + parsec() + .args(["start", "OV-1", "--repo", repo_path]) + .assert() + .success(); + + let output = parsec() + .args(["smartlog", "--no-overlay", "--repo", repo_path]) + // Strip any inherited GitHub token so the test is hermetic. + .env_remove("PARSEC_GITHUB_TOKEN") + .env_remove("GITHUB_TOKEN") + .env_remove("GH_TOKEN") + .output() + .unwrap(); + + assert!( + output.status.success(), + "--no-overlay must succeed without a GitHub token; stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!( + stdout.contains("OV-1"), + "worktree ticket must appear; got:\n{stdout}" + ); + assert!( + !stdout.contains("[PR #"), + "--no-overlay must not emit PR badges; got:\n{stdout}" + ); + assert!( + !stdout.contains("[CI:"), + "--no-overlay must not emit CI badges; got:\n{stdout}" + ); +} + +/// `parsec smartlog` with `PARSEC_GITHUB_API_BASE` pointing at a mockito +/// server must render PR and CI badges in the ASCII tree when the mock returns +/// successful check-run data. +/// +/// Validates: +/// - issue #309: PR overlay (state, review) surfaced in output +/// - issue #310: CI check-run aggregate badge surfaced in output +/// - `PARSEC_GITHUB_API_BASE` env var routes GitHub API calls to the mock +#[test] +fn test_smartlog_overlay_with_mock_github() { + let (repo, _bare) = setup_repo_with_remote(); + let repo_path = repo.path().to_str().unwrap(); + + parsec() + .args(["start", "OV-2", "--repo", repo_path]) + .assert() + .success(); + + // After the worktree is created with the local bare remote, swap `origin` + // to a GitHub-style URL so `GitHubClient` can parse owner/repo. The + // `parsec smartlog` command reads this URL but never git-fetches from it. + StdCommand::new("git") + .args([ + "remote", + "set-url", + "origin", + "https://github.com/testowner/testrepo.git", + ]) + .current_dir(repo.path()) + .output() + .unwrap(); + + let branch = "feature/OV-2"; + let pr_number: u64 = 99; + let fake_sha = "aaabbbccc111222333444555666777888999000ab"; + + let mut server = mockito::Server::new(); + + // 1. find_pr_by_branch → GET /repos/.../pulls?head=testowner:{branch}&state=open + let _m_pr_list = server + .mock("GET", "/repos/testowner/testrepo/pulls") + .match_query(Matcher::Any) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(format!(r#"[{{"number": {}}}]"#, pr_number)) + .expect_at_least(1) + .create(); + + // 2. GET /repos/.../pulls/{n} — called by get_pr_status AND get_check_runs. + let _m_pr_detail = server + .mock( + "GET", + format!("/repos/testowner/testrepo/pulls/{}", pr_number).as_str(), + ) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(format!( + r#"{{"number":{n},"title":"feat: mock PR","state":"open","mergeable":true,"html_url":"https://github.com/testowner/testrepo/pull/{n}","head":{{"sha":"{sha}","ref":"{branch}"}}}}"#, + n = pr_number, + sha = fake_sha, + branch = branch, + )) + .expect_at_least(1) + .create(); + + // 3. GET /repos/.../commits/{sha}/status (get_pr_status inner call) + let _m_status = server + .mock( + "GET", + format!("/repos/testowner/testrepo/commits/{}/status", fake_sha).as_str(), + ) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"state":"success"}"#) + .expect_at_least(1) + .create(); + + // 4. GET /repos/.../pulls/{n}/reviews (get_pr_status inner call) + let _m_reviews = server + .mock( + "GET", + format!("/repos/testowner/testrepo/pulls/{}/reviews", pr_number).as_str(), + ) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"[{"state":"APPROVED"}]"#) + .expect_at_least(1) + .create(); + + // 5. GET /repos/.../commits/{sha}/check-runs (get_check_runs inner call) + let _m_checks = server + .mock( + "GET", + format!( + "/repos/testowner/testrepo/commits/{}/check-runs", + fake_sha + ) + .as_str(), + ) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + r#"{"check_runs":[{"name":"CI","status":"completed","conclusion":"success","started_at":"2026-09-04T00:00:00Z","completed_at":"2026-09-04T00:05:00Z","html_url":"https://github.com/actions/runs/1"}]}"#, + ) + .expect_at_least(1) + .create(); + + let output = parsec() + .args(["smartlog", "--repo", repo_path]) + .env("PARSEC_GITHUB_TOKEN", "fake-token-for-test") + .env("PARSEC_GITHUB_API_BASE", server.url()) + .env_remove("GITHUB_TOKEN") + .env_remove("GH_TOKEN") + .output() + .unwrap(); + + assert!( + output.status.success(), + "smartlog with mock GitHub must exit 0; stderr:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!( + stdout.contains("OV-2"), + "ticket must appear; got:\n{stdout}" + ); + assert!( + stdout.contains("[PR #99"), + "PR badge must appear; got:\n{stdout}" + ); + assert!( + stdout.contains("[CI: ✓ passed"), + "CI badge must show passed; got:\n{stdout}" + ); +} +// checkpoint — CLI integration tests (#435) +// --------------------------------------------------------------------------- + +#[test] +fn test_checkpoint_help_shows_subcommands() { + let repo = setup_repo(); + parsec() + .args([ + "checkpoint", + "--help", + "--repo", + repo.path().to_str().unwrap(), + ]) + .assert() + .success() + .stdout(predicate::str::contains("create")) + .stdout(predicate::str::contains("list")) + .stdout(predicate::str::contains("restore")) + .stdout(predicate::str::contains("drop")); +} + +#[test] +fn test_checkpoint_list_empty() { + let repo = setup_repo(); + parsec() + .args([ + "checkpoint", + "list", + "--repo", + repo.path().to_str().unwrap(), + ]) + .assert() + .success() + .stdout(predicate::str::contains("No checkpoints found")); +} + +#[test] +fn test_checkpoint_list_empty_json() { + let repo = setup_repo(); + let out = parsec() + .args([ + "--json", + "checkpoint", + "list", + "--repo", + repo.path().to_str().unwrap(), + ]) + .output() + .unwrap(); + assert!(out.status.success(), "expected success"); + let stdout = String::from_utf8_lossy(&out.stdout); + assert_eq!( + stdout.trim(), + "[]", + "expected empty JSON array, got: {stdout}" + ); +} + +#[test] +fn test_checkpoint_create_clean_tree() { + // A freshly-initialised repo with only an empty commit is clean — + // parsec should report nothing to checkpoint without error. + let repo = setup_repo(); + parsec() + .args([ + "checkpoint", + "create", + "--repo", + repo.path().to_str().unwrap(), + ]) + .assert() + .success() + .stdout(predicate::str::contains("clean")); +} + +#[test] +fn test_checkpoint_create_with_changes() { + // Add an untracked file so the working tree is dirty, then create a checkpoint. + let repo = setup_repo(); + let new_file = repo.path().join("work.txt"); + std::fs::write(&new_file, "pending work").unwrap(); + + parsec() + .args([ + "checkpoint", + "create", + "before-experiment", + "--repo", + repo.path().to_str().unwrap(), + ]) + .assert() + .success() + .stdout(predicate::str::contains("before-experiment")); +} + +#[test] +fn test_checkpoint_create_then_list() { + // Create a checkpoint with a specific name and verify it appears in `list`. + let repo = setup_repo(); + let new_file = repo.path().join("draft.rs"); + std::fs::write(&new_file, "// wip").unwrap(); + + parsec() + .args([ + "checkpoint", + "create", + "my-snapshot", + "--repo", + repo.path().to_str().unwrap(), + ]) + .assert() + .success(); + + parsec() + .args([ + "checkpoint", + "list", + "--repo", + repo.path().to_str().unwrap(), + ]) + .assert() + .success() + .stdout(predicate::str::contains("my-snapshot")); +} + +#[test] +fn test_checkpoint_drop_removes_entry() { + // Create a checkpoint, then drop it; the list should return empty afterwards. + let repo = setup_repo(); + let new_file = repo.path().join("temp.rs"); + std::fs::write(&new_file, "temp").unwrap(); + + parsec() + .args([ + "checkpoint", + "create", + "drop-me", + "--repo", + repo.path().to_str().unwrap(), + ]) + .assert() + .success(); + + // Sanity: appears in list before drop. + parsec() + .args([ + "checkpoint", + "list", + "--repo", + repo.path().to_str().unwrap(), + ]) + .assert() + .success() + .stdout(predicate::str::contains("drop-me")); + + // Drop it. + parsec() + .args([ + "checkpoint", + "drop", + "drop-me", + "--repo", + repo.path().to_str().unwrap(), + ]) + .assert() + .success() + .stdout(predicate::str::contains("drop-me")); + + // Now list should be empty again. + parsec() + .args([ + "checkpoint", + "list", + "--repo", + repo.path().to_str().unwrap(), + ]) + .assert() + .success() + .stdout(predicate::str::contains("No checkpoints found")); +} + +#[test] +fn test_checkpoint_name_with_colon_rejected() { + // Names containing ':' collide with the internal prefix delimiter. + let repo = setup_repo(); + let new_file = repo.path().join("dirty.txt"); + std::fs::write(&new_file, "x").unwrap(); + + let out = parsec() + .args([ + "checkpoint", + "create", + "bad:name", + "--repo", + repo.path().to_str().unwrap(), + ]) + .output() + .unwrap(); + assert!(!out.status.success(), "expected non-zero exit for bad name"); +} + +// --------------------------------------------------------------------------- +// crash-report — CLI integration tests (#435) +// --------------------------------------------------------------------------- + +#[test] +fn test_crash_report_help_shows_subcommands() { + parsec() + .args(["crash-report", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("list")) + .stdout(predicate::str::contains("show")) + .stdout(predicate::str::contains("clear")); +} + +#[test] +fn test_crash_report_list_no_reports() { + // Use a temp cache dir so we never see real crash reports from the host. + let tmp = tempfile::tempdir().unwrap(); + parsec() + .env("XDG_CACHE_HOME", tmp.path().to_str().unwrap()) + .args(["crash-report", "list"]) + .assert() + .success() + .stdout(predicate::str::contains("No crash reports found")); +} + +#[test] +fn test_crash_report_list_json_no_reports() { + let tmp = tempfile::tempdir().unwrap(); + let out = parsec() + .env("XDG_CACHE_HOME", tmp.path().to_str().unwrap()) + .args(["--json", "crash-report", "list"]) + .output() + .unwrap(); + assert!(out.status.success(), "expected success"); + let stdout = String::from_utf8_lossy(&out.stdout); + assert_eq!( + stdout.trim(), + "[]", + "expected empty JSON array, got: {stdout}" + ); +} + +// --------------------------------------------------------------------------- +// triage — Phase 2 CLI safety tests (#302) +// --------------------------------------------------------------------------- + +#[test] +fn test_triage_help_shows_apply() { + parsec() + .args(["triage", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("--apply")) + .stdout(predicate::str::contains("dry-run")); +} + +#[test] +fn test_triage_apply_rejected_offline() { + let repo = setup_repo(); + parsec() + .args([ + "--offline", + "--repo", + repo.path().to_str().unwrap(), + "triage", + "--apply", + ]) + .assert() + .failure() + .stderr(predicate::str::contains( + "cannot use --apply in offline mode", + )); +} + +#[test] +fn test_triage_global_dry_run_overrides_apply() { + let repo = setup_repo(); + parsec() + .args([ + "--offline", + "--dry-run", + "--repo", + repo.path().to_str().unwrap(), + "triage", + "--apply", + ]) + .assert() + .success() + .stdout(predicate::str::contains("No triage rules configured")); +} diff --git a/tests/mcp/README.md b/tests/mcp/README.md new file mode 100644 index 0000000..4bdbadf --- /dev/null +++ b/tests/mcp/README.md @@ -0,0 +1,83 @@ +# MCP Recording Fixtures + +This directory stores MCP stdio recordings used by integration and smoke +tests. Fixtures are JSON Lines files so future Claude Desktop or Cursor +captures can append one request/response expectation per line. + +Each non-empty, non-comment line has this shape: + +```json +{"name":"initialize","request":{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"fixture-client","version":"0.0.0"}}},"assertions":[{"pointer":"/result/serverInfo/name","equals":"git-parsec"}]} +{"name":"initialized-notification","request":{"jsonrpc":"2.0","method":"notifications/initialized"},"no_response":true} +{"name":"ping","request":{"jsonrpc":"2.0","id":"ping","method":"ping"},"assertions":[{"pointer":"/result","kind":"object"}]} +{"name":"shutdown","request":{"jsonrpc":"2.0","id":"shutdown","method":"shutdown"},"assertions":[{"pointer":"/result","equals":null}]} +{"name":"tools-call-worktree-list","request":{"jsonrpc":"2.0","id":"worktrees","method":"tools/call","params":{"name":"worktree_list","arguments":{}}},"assertions":[{"pointer":"/result/isError","equals":false},{"pointer":"/result/content/0/text","contains_text":"\"worktrees\""}]} +``` + +## Fields + +- `name`: stable fixture label for failure messages. +- `request`: JSON-RPC request sent to `parsec mcp serve`. +- `context`: optional redacted MCP host context used when replaying the + request. +- `assertions`: checks applied to the dispatcher response. +- `no_response`: set to `true` for JSON-RPC notifications that must not emit + a response. + +Fixture names must be unique within a recording file. Response fixtures must +declare at least one assertion, while notification fixtures use `no_response` +without assertions. +Initialize fixtures should include deterministic client metadata so the stdio +contract matches desktop MCP client handshakes without recording local state. +Auth fixtures may include `context.github.token` and `context.github.scopes`; +the token must be a placeholder such as ``. + +Supported assertion keys: + +- `pointer`: required JSON Pointer into the response. +- `equals`: exact JSON value match. +- `kind`: JSON type check (`object`, `array`, `string`, `number`, `boolean`, or `null`). +- `contains_text`: substring check for deterministic error or content strings. +- `min_len`: minimum array length. +- `contains_tool`: checks an array of MCP tool objects for a matching `name`. + +Keep fixtures deterministic. Do not record machine-local paths, auth tokens, +timestamps, or network-derived fields. +For wired tool calls, assert stable envelope fields and schema keys instead of +repository-specific counts, paths, branch names, or timestamps. + +Audit compatibility fixtures use `audit_event_v.json`. They are exact +schema snapshots rather than stdio recordings: breaking field changes require +a new versioned file, while additive changes update the current fixture. + +## Redaction Contract + +Recording fixtures are committed test inputs, so they must be safe to publish +and stable across machines. Before adding a new recording, replace volatile or +secret-bearing values with deterministic placeholders. + +| Source value | Fixture placeholder | +|---|---| +| GitHub tokens, PATs, bearer tokens | `` | +| `Authorization` headers | `` | +| User home directories or absolute repo paths | `` | +| Temporary directories | `` | +| Real user emails | `` | +| Wall-clock timestamps | `` | +| Network request IDs | `` | + +Reviewers should reject fixtures that contain token-shaped strings such as +`ghp_`, `github_pat_`, `Bearer `, or `Authorization`. Fixture responses should +also avoid raw stdout/stderr dumps from MCP clients; assert only the stable JSON +fields needed by the smoke contract. + +## Recording Checklist + +1. Capture the smallest request/response pair that exercises the behavior. +2. Redact secrets, local paths, timestamps, and network-generated IDs. +3. Prefer `contains_text`, `kind`, `min_len`, and `contains_tool` over copying + full response payloads. +4. Run `cargo test --quiet mcp::tests::stdio_recording_fixtures_match_dispatcher` + before opening a PR. +5. Run `cargo test --quiet mcp::tests::stdio_recording_fixtures_are_redacted` + after adding or updating any committed recording. diff --git a/tests/mcp/fixtures/audit_event_v1.json b/tests/mcp/fixtures/audit_event_v1.json new file mode 100644 index 0000000..bdfed7a --- /dev/null +++ b/tests/mcp/fixtures/audit_event_v1.json @@ -0,0 +1,9 @@ +{ + "event": "mcp.tool_call", + "version": 1, + "tool": "worktree_ship", + "outcome": "denied", + "mutating": true, + "dryRun": false, + "correlationId": "call-42" +} diff --git a/tests/mcp/fixtures/stdio_smoke.jsonl b/tests/mcp/fixtures/stdio_smoke.jsonl new file mode 100644 index 0000000..fc0d8ed --- /dev/null +++ b/tests/mcp/fixtures/stdio_smoke.jsonl @@ -0,0 +1,64 @@ +# MCP stdio smoke contract fixtures. One JSON object per non-comment line. +{"name":"initialize","request":{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"fixture-client","version":"0.0.0"}}},"assertions":[{"pointer":"/jsonrpc","equals":"2.0"},{"pointer":"/id","equals":1},{"pointer":"/result/protocolVersion","equals":"2025-03-26"},{"pointer":"/result/serverInfo/name","equals":"git-parsec"},{"pointer":"/result/capabilities/tools","kind":"object"},{"pointer":"/result/capabilities/gitParsec/githubScopes","equals":["pull_request:read","checks:read","pull_request:write"]}]} +{"name":"initialized-notification","request":{"jsonrpc":"2.0","method":"notifications/initialized","params":{}},"no_response":true} +{"name":"ping","request":{"jsonrpc":"2.0","id":"ping","method":"ping"},"assertions":[{"pointer":"/jsonrpc","equals":"2.0"},{"pointer":"/id","equals":"ping"},{"pointer":"/result","kind":"object"}]} +{"name":"shutdown","request":{"jsonrpc":"2.0","id":"shutdown","method":"shutdown"},"assertions":[{"pointer":"/jsonrpc","equals":"2.0"},{"pointer":"/id","equals":"shutdown"},{"pointer":"/result","equals":null}]} +{"name":"tools-list","request":{"jsonrpc":"2.0","id":"tools","method":"tools/list"},"assertions":[{"pointer":"/jsonrpc","equals":"2.0"},{"pointer":"/id","equals":"tools"},{"pointer":"/result/tools","min_len":10},{"pointer":"/result/tools","contains_tool":"worktree_list"},{"pointer":"/result/tools","contains_tool":"worktree_ship"},{"pointer":"/result/tools","contains_tool":"smartlog"},{"pointer":"/result/tools","contains_tool":"ci_status"},{"pointer":"/result/tools","contains_tool":"reviews"}]} +{"name":"tools-call-missing-worktree","request":{"jsonrpc":"2.0","id":"call","method":"tools/call","params":{"name":"worktree_status","arguments":{"ticket":"ABC-123"}}},"assertions":[{"pointer":"/jsonrpc","equals":"2.0"},{"pointer":"/id","equals":"call"},{"pointer":"/result/isError","equals":true},{"pointer":"/result/content","min_len":1},{"pointer":"/result/content/0/type","equals":"text"}]} +{"name":"tools-call-auth-required","request":{"jsonrpc":"2.0","id":"auth","method":"tools/call","params":{"name":"pr_status","arguments":{"ticket":"ABC-123"}}},"assertions":[{"pointer":"/jsonrpc","equals":"2.0"},{"pointer":"/id","equals":"auth"},{"pointer":"/result/isError","equals":true},{"pointer":"/result/content","min_len":1},{"pointer":"/result/content/0/type","equals":"text"},{"pointer":"/result/content/0/text","contains_text":"AUTH_REQUIRED"},{"pointer":"/result/content/0/text","contains_text":"pull_request:read"}]} +{"name":"tools-call-worktree-list","request":{"jsonrpc":"2.0","id":"worktrees","method":"tools/call","params":{"name":"worktree_list","arguments":{}}},"assertions":[{"pointer":"/jsonrpc","equals":"2.0"},{"pointer":"/id","equals":"worktrees"},{"pointer":"/result/isError","equals":false},{"pointer":"/result/content","min_len":1},{"pointer":"/result/content/0/type","equals":"text"},{"pointer":"/result/content/0/text","contains_text":"\"worktrees\""},{"pointer":"/result/content/0/text","contains_text":"\"pr_overlay\":false"},{"pointer":"/result/content/0/text","contains_text":"\"ci_overlay\":false"}]} +{"name":"tools-call-worktree-list-overlay-auth-required","request":{"jsonrpc":"2.0","id":"overlay-auth","method":"tools/call","params":{"name":"worktree_list","arguments":{"include_pr":true,"include_ci":true}}},"assertions":[{"pointer":"/jsonrpc","equals":"2.0"},{"pointer":"/id","equals":"overlay-auth"},{"pointer":"/result/isError","equals":true},{"pointer":"/result/content","min_len":1},{"pointer":"/result/content/0/type","equals":"text"},{"pointer":"/result/content/0/text","contains_text":"AUTH_REQUIRED"},{"pointer":"/result/content/0/text","contains_text":"pull_request:read"},{"pointer":"/result/content/0/text","contains_text":"checks:read"}]} +{"name":"tools-call-ci-insufficient-scope","context":{"github":{"token":"","scopes":["pull_request:read"]}},"request":{"jsonrpc":"2.0","id":"ci-scope","method":"tools/call","params":{"name":"ci_status","arguments":{"ticket":"ABC-123"}}},"assertions":[{"pointer":"/jsonrpc","equals":"2.0"},{"pointer":"/id","equals":"ci-scope"},{"pointer":"/result/isError","equals":true},{"pointer":"/result/content","min_len":1},{"pointer":"/result/content/0/type","equals":"text"},{"pointer":"/result/content/0/text","contains_text":"INSUFFICIENT_SCOPE"},{"pointer":"/result/content/0/text","contains_text":"checks:read"}]} +{"name":"tools-call-unknown-tool","request":{"jsonrpc":"2.0","id":"bad-tool","method":"tools/call","params":{"name":"nope","arguments":{}}},"assertions":[{"pointer":"/jsonrpc","equals":"2.0"},{"pointer":"/id","equals":"bad-tool"},{"pointer":"/error/code","equals":-32602},{"pointer":"/error/message","equals":"Invalid params"}]} +{"name":"tools-call-missing-name","request":{"jsonrpc":"2.0","id":"missing-name","method":"tools/call","params":{"arguments":{}}},"assertions":[{"pointer":"/jsonrpc","equals":"2.0"},{"pointer":"/id","equals":"missing-name"},{"pointer":"/error/code","equals":-32602},{"pointer":"/error/message","equals":"Invalid params"},{"pointer":"/error/data","contains_text":"params.name"}]} +{"name":"tools-call-non-object-arguments","request":{"jsonrpc":"2.0","id":"bad-arguments","method":"tools/call","params":{"name":"worktree_status","arguments":"ticket=ABC-123"}},"assertions":[{"pointer":"/jsonrpc","equals":"2.0"},{"pointer":"/id","equals":"bad-arguments"},{"pointer":"/error/code","equals":-32602},{"pointer":"/error/message","equals":"Invalid params"},{"pointer":"/error/data","contains_text":"arguments"}]} +{"name":"invalid-request-array","request":[],"assertions":[{"pointer":"/jsonrpc","equals":"2.0"},{"pointer":"/id","equals":null},{"pointer":"/error/code","equals":-32600},{"pointer":"/error/message","equals":"Invalid Request"},{"pointer":"/error/data","contains_text":"object"}]} +{"name":"invalid-request-jsonrpc-version","request":{"jsonrpc":"1.0","id":"bad-version","method":"initialize"},"assertions":[{"pointer":"/jsonrpc","equals":"2.0"},{"pointer":"/id","equals":"bad-version"},{"pointer":"/error/code","equals":-32600},{"pointer":"/error/message","equals":"Invalid Request"},{"pointer":"/error/data","contains_text":"JSON-RPC version"}]} +{"name":"invalid-request-method-type","request":{"jsonrpc":"2.0","id":"bad-method","method":123},"assertions":[{"pointer":"/jsonrpc","equals":"2.0"},{"pointer":"/id","equals":"bad-method"},{"pointer":"/error/code","equals":-32600},{"pointer":"/error/message","equals":"Invalid Request"},{"pointer":"/error/data","contains_text":"method"}]} +{"name":"invalid-request-id-type","request":{"jsonrpc":"2.0","id":{"nested":true},"method":"initialize"},"assertions":[{"pointer":"/jsonrpc","equals":"2.0"},{"pointer":"/id","equals":null},{"pointer":"/error/code","equals":-32600},{"pointer":"/error/message","equals":"Invalid Request"},{"pointer":"/error/data","contains_text":"id"}]} +# --- Phase 22: preflight smoke fixtures — mutation confirmation + scoped auth passthrough --- +{"name":"tools-call-worktree-start-confirmation-required","request":{"jsonrpc":"2.0","id":"confirm-required","method":"tools/call","params":{"name":"worktree_start","arguments":{"ticket":"PROJ-123"}}},"assertions":[{"pointer":"/jsonrpc","equals":"2.0"},{"pointer":"/id","equals":"confirm-required"},{"pointer":"/result/isError","equals":true},{"pointer":"/result/content/0/type","equals":"text"},{"pointer":"/result/content/0/text","contains_text":"CONFIRMATION_REQUIRED"},{"pointer":"/result/content/0/text","contains_text":"worktree_start"}]} +{"name": "tools-call-worktree-start-dry-run-passthrough", "request": {"jsonrpc": "2.0", "id": "dry-run", "method": "tools/call", "params": {"name": "worktree_start", "arguments": {"ticket": "PROJ-123", "dry_run": true}}}, "assertions": [{"pointer": "/jsonrpc", "equals": "2.0"}, {"pointer": "/id", "equals": "dry-run"}, {"pointer": "/result/isError", "equals": false}, {"pointer": "/result/content/0/type", "equals": "text"}, {"pointer": "/result/content/0/text", "contains_text": "would create"}, {"pointer": "/result/content/0/text", "contains_text": "PROJ-123"}, {"pointer": "/result/content/0/text", "contains_text": "dry_run"}]} +{"name": "tools-call-worktree-start-confirmed-passthrough", "request": {"jsonrpc": "2.0", "id": "confirmed", "method": "tools/call", "params": {"name": "worktree_start", "arguments": {"ticket": "PROJ-123", "dry_run": true, "confirm": true}}}, "assertions": [{"pointer": "/jsonrpc", "equals": "2.0"}, {"pointer": "/id", "equals": "confirmed"}, {"pointer": "/result/isError", "equals": false}, {"pointer": "/result/content/0/type", "equals": "text"}, {"pointer": "/result/content/0/text", "contains_text": "would create"}, {"pointer": "/result/content/0/text", "contains_text": "PROJ-123"}, {"pointer": "/result/content/0/text", "contains_text": "dry_run"}]} +{"name":"tools-call-pr-status-scoped-auth-passthrough","context":{"github":{"token":"","scopes":["pull_request:read"]}},"request":{"jsonrpc":"2.0","id":"pr-scoped","method":"tools/call","params":{"name":"pr_status","arguments":{"ticket":"PROJ-123"}}},"assertions":[{"pointer":"/jsonrpc","equals":"2.0"},{"pointer":"/id","equals":"pr-scoped"},{"pointer":"/result/isError","equals":true},{"pointer":"/result/content/0/type","equals":"text"},{"pointer":"/result/content/0/text","contains_text":"tool_error"},{"pointer":"/result/content/0/text","contains_text":"pr_status"}]} +{"name":"tools-call-pr-status-write-scope-satisfies-read","context":{"github":{"token":"","scopes":["pull_request:write"]}},"request":{"jsonrpc":"2.0","id":"pr-write-scope","method":"tools/call","params":{"name":"pr_status","arguments":{"ticket":"PROJ-123"}}},"assertions":[{"pointer":"/jsonrpc","equals":"2.0"},{"pointer":"/id","equals":"pr-write-scope"},{"pointer":"/result/isError","equals":true},{"pointer":"/result/content/0/type","equals":"text"},{"pointer":"/result/content/0/text","contains_text":"tool_error"},{"pointer":"/result/content/0/text","contains_text":"pr_status"}]} +# --- Phase 23: stub-state coverage for remaining registered tools --- +# Phase 24: health_check is now implemented — returns real sync health data (lock/uncommitted/stale) +{"name":"tools-call-health-check","request":{"jsonrpc":"2.0","id":"health-check","method":"tools/call","params":{"name":"health_check","arguments":{}}},"assertions":[{"pointer":"/jsonrpc","equals":"2.0"},{"pointer":"/id","equals":"health-check"},{"pointer":"/result/isError","equals":false},{"pointer":"/result/content","min_len":1},{"pointer":"/result/content/0/type","equals":"text"},{"pointer":"/result/content/0/text","contains_text":"\"records\""},{"pointer":"/result/content/0/text","contains_text":"\"ci_overlay\""}]} +# Phase 25: smartlog is now implemented — returns sync DAG (no GitHub overlay) +{"name":"tools-call-smartlog","request":{"jsonrpc":"2.0","id":"smartlog-live","method":"tools/call","params":{"name":"smartlog","arguments":{}}},"assertions":[{"pointer":"/jsonrpc","equals":"2.0"},{"pointer":"/id","equals":"smartlog-live"},{"pointer":"/result/isError","equals":false},{"pointer":"/result/content","min_len":1},{"pointer":"/result/content/0/type","equals":"text"},{"pointer":"/result/content/0/text","contains_text":"\"nodes\""},{"pointer":"/result/content/0/text","contains_text":"\"pr_overlay\":false"},{"pointer":"/result/content/0/text","contains_text":"\"ci_overlay\":false"}]} +{"name":"tools-call-sync-confirmation-required","request":{"jsonrpc":"2.0","id":"sync-no-confirm","method":"tools/call","params":{"name":"sync","arguments":{}}},"assertions":[{"pointer":"/jsonrpc","equals":"2.0"},{"pointer":"/id","equals":"sync-no-confirm"},{"pointer":"/result/isError","equals":true},{"pointer":"/result/content","min_len":1},{"pointer":"/result/content/0/type","equals":"text"},{"pointer":"/result/content/0/text","contains_text":"CONFIRMATION_REQUIRED"},{"pointer":"/result/content/0/text","contains_text":"sync"}]} +{"name":"tools-call-sync-dry-run-stub","request":{"jsonrpc":"2.0","id":"sync-dry-run","method":"tools/call","params":{"name":"sync","arguments":{"dry_run":true}}},"assertions":[{"pointer":"/jsonrpc","equals":"2.0"},{"pointer":"/id","equals":"sync-dry-run"},{"pointer":"/result/isError","equals":true},{"pointer":"/result/content","min_len":1},{"pointer":"/result/content/0/type","equals":"text"},{"pointer":"/result/content/0/text","contains_text":"tool_error"},{"pointer":"/result/content/0/text","contains_text":"sync"}]} +{"name":"tools-call-reviews-auth-required","request":{"jsonrpc":"2.0","id":"reviews-no-auth","method":"tools/call","params":{"name":"reviews","arguments":{}}},"assertions":[{"pointer":"/jsonrpc","equals":"2.0"},{"pointer":"/id","equals":"reviews-no-auth"},{"pointer":"/result/isError","equals":true},{"pointer":"/result/content","min_len":1},{"pointer":"/result/content/0/type","equals":"text"},{"pointer":"/result/content/0/text","contains_text":"AUTH_REQUIRED"},{"pointer":"/result/content/0/text","contains_text":"reviews"}]} +{"name":"tools-call-reviews-with-scope-stub","context":{"github":{"token":"","scopes":["pull_request:read"]}},"request":{"jsonrpc":"2.0","id":"reviews-scoped","method":"tools/call","params":{"name":"reviews","arguments":{}}},"assertions":[{"pointer":"/jsonrpc","equals":"2.0"},{"pointer":"/id","equals":"reviews-scoped"},{"pointer":"/result/isError","equals":true},{"pointer":"/result/content","min_len":1},{"pointer":"/result/content/0/type","equals":"text"},{"pointer":"/result/content/0/text","contains_text":"tool_error"},{"pointer":"/result/content/0/text","contains_text":"reviews"}]} +# --- Phase 28: ci_status wired — passthrough fixture with checks:read scope --- +{"name":"tools-call-ci-status-with-scope","context":{"github":{"token":"","scopes":["checks:read"]}},"request":{"jsonrpc":"2.0","id":"ci-scoped","method":"tools/call","params":{"name":"ci_status","arguments":{"ticket":"PROJ-123"}}},"assertions":[{"pointer":"/jsonrpc","equals":"2.0"},{"pointer":"/id","equals":"ci-scoped"},{"pointer":"/result/isError","equals":true},{"pointer":"/result/content","min_len":1},{"pointer":"/result/content/0/type","equals":"text"},{"pointer":"/result/content/0/text","contains_text":"tool_error"},{"pointer":"/result/content/0/text","contains_text":"ci_status"}]} +# --- Phase 29: sync wired — missing ticket validation --- +{"name":"tools-call-sync-missing-ticket","request":{"jsonrpc":"2.0","id":"sync-no-ticket","method":"tools/call","params":{"name":"sync","arguments":{"dry_run":true}}},"assertions":[{"pointer":"/jsonrpc","equals":"2.0"},{"pointer":"/id","equals":"sync-no-ticket"},{"pointer":"/result/isError","equals":true},{"pointer":"/result/content","min_len":1},{"pointer":"/result/content/0/type","equals":"text"},{"pointer":"/result/content/0/text","contains_text":"tool_error"},{"pointer":"/result/content/0/text","contains_text":"sync"}]} +# --- Phase 29: sync wired — unsafe ticket rejected --- +{"name":"tools-call-sync-unsafe-ticket","request":{"jsonrpc":"2.0","id":"sync-bad-ticket","method":"tools/call","params":{"name":"sync","arguments":{"ticket":"bad;ticket","dry_run":true}}},"assertions":[{"pointer":"/jsonrpc","equals":"2.0"},{"pointer":"/id","equals":"sync-bad-ticket"},{"pointer":"/result/isError","equals":true},{"pointer":"/result/content","min_len":1},{"pointer":"/result/content/0/type","equals":"text"},{"pointer":"/result/content/0/text","contains_text":"tool_error"},{"pointer":"/result/content/0/text","contains_text":"sync"}]} +# --- Phase 31: worktree_ship — confirmation required (no dry_run, no confirm) --- +{"name":"tools-call-worktree-ship-confirmation-required","request":{"jsonrpc":"2.0","id":"ship-no-confirm","method":"tools/call","params":{"name":"worktree_ship","arguments":{"ticket":"PROJ-123"}}},"assertions":[{"pointer":"/jsonrpc","equals":"2.0"},{"pointer":"/id","equals":"ship-no-confirm"},{"pointer":"/result/isError","equals":true},{"pointer":"/result/content/0/text","contains_text":"CONFIRMATION_REQUIRED"},{"pointer":"/result/content/0/text","contains_text":"worktree_ship"}]} +# --- Phase 31: worktree_ship — dry_run=true bypasses confirmation gate but hits AUTH_REQUIRED --- +{"name":"tools-call-worktree-ship-dry-run-auth-required","request":{"jsonrpc":"2.0","id":"ship-dry-run","method":"tools/call","params":{"name":"worktree_ship","arguments":{"ticket":"PROJ-123","dry_run":true}}},"assertions":[{"pointer":"/jsonrpc","equals":"2.0"},{"pointer":"/id","equals":"ship-dry-run"},{"pointer":"/result/isError","equals":true},{"pointer":"/result/content/0/text","contains_text":"AUTH_REQUIRED"},{"pointer":"/result/content/0/text","contains_text":"worktree_ship"}]} +# --- Phase 31: worktree_ship — confirm=true (no dry_run) also hits AUTH_REQUIRED --- +{"name":"tools-call-worktree-ship-confirm-auth-required","request":{"jsonrpc":"2.0","id":"ship-confirm-auth","method":"tools/call","params":{"name":"worktree_ship","arguments":{"ticket":"PROJ-123","dry_run":false,"confirm":true}}},"assertions":[{"pointer":"/jsonrpc","equals":"2.0"},{"pointer":"/id","equals":"ship-confirm-auth"},{"pointer":"/result/isError","equals":true},{"pointer":"/result/content/0/text","contains_text":"AUTH_REQUIRED"},{"pointer":"/result/content/0/text","contains_text":"worktree_ship"}]} +# --- Phase 37: scope enforcement edge cases — worktree_ship + write-scope promotion --- +# worktree_ship requires pull_request:write; a read-only token must yield INSUFFICIENT_SCOPE. +{"name":"tools-call-worktree-ship-insufficient-scope","context":{"github":{"token":"","scopes":["pull_request:read"]}},"request":{"jsonrpc":"2.0","id":"ship-insufficient","method":"tools/call","params":{"name":"worktree_ship","arguments":{"ticket":"PROJ-123","dry_run":true}}},"assertions":[{"pointer":"/jsonrpc","equals":"2.0"},{"pointer":"/id","equals":"ship-insufficient"},{"pointer":"/result/isError","equals":true},{"pointer":"/result/content/0/type","equals":"text"},{"pointer":"/result/content/0/text","contains_text":"INSUFFICIENT_SCOPE"},{"pointer":"/result/content/0/text","contains_text":"pull_request:write"}]} +# checks:read alone is also insufficient for worktree_ship (needs pull_request:write). +{"name":"tools-call-worktree-ship-checks-scope-insufficient","context":{"github":{"token":"","scopes":["checks:read"]}},"request":{"jsonrpc":"2.0","id":"ship-checks-insufficient","method":"tools/call","params":{"name":"worktree_ship","arguments":{"ticket":"PROJ-123","dry_run":true}}},"assertions":[{"pointer":"/jsonrpc","equals":"2.0"},{"pointer":"/id","equals":"ship-checks-insufficient"},{"pointer":"/result/isError","equals":true},{"pointer":"/result/content/0/text","contains_text":"INSUFFICIENT_SCOPE"},{"pointer":"/result/content/0/text","contains_text":"pull_request:write"}]} +# pull_request:write token can also satisfy pull_request:read for reviews (write implies read). +{"name":"tools-call-reviews-write-scope-implies-read","context":{"github":{"token":"","scopes":["pull_request:write"]}},"request":{"jsonrpc":"2.0","id":"reviews-write-scope","method":"tools/call","params":{"name":"reviews","arguments":{}}},"assertions":[{"pointer":"/jsonrpc","equals":"2.0"},{"pointer":"/id","equals":"reviews-write-scope"},{"pointer":"/result/isError","equals":true},{"pointer":"/result/content/0/type","equals":"text"},{"pointer":"/result/content/0/text","contains_text":"tool_error"},{"pointer":"/result/content/0/text","contains_text":"reviews"}]} +# pr_status with all three scopes — write implies read so preflight passes; tool_error from gh CLI only. +{"name":"tools-call-pr-status-all-scopes","context":{"github":{"token":"","scopes":["pull_request:read","checks:read","pull_request:write"]}},"request":{"jsonrpc":"2.0","id":"pr-all-scopes","method":"tools/call","params":{"name":"pr_status","arguments":{"ticket":"PROJ-123"}}},"assertions":[{"pointer":"/jsonrpc","equals":"2.0"},{"pointer":"/id","equals":"pr-all-scopes"},{"pointer":"/result/isError","equals":true},{"pointer":"/result/content/0/text","contains_text":"tool_error"},{"pointer":"/result/content/0/text","contains_text":"pr_status"}]} +# --- Phase 32: Claude Desktop / Cursor e2e scenario (worktree create → ship) --- +# Represents a complete AI agent workflow: init → discover tools → start worktree → ship. +# All mutating steps use dry_run=true so fixtures are deterministic and machine-independent. +{"name":"scenario-e2e-initialize","request":{"jsonrpc":"2.0","id":"e2e-init","method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"claude-desktop","version":"1.0.0"}}},"assertions":[{"pointer":"/jsonrpc","equals":"2.0"},{"pointer":"/id","equals":"e2e-init"},{"pointer":"/result/protocolVersion","equals":"2025-03-26"},{"pointer":"/result/serverInfo/name","equals":"git-parsec"},{"pointer":"/result/capabilities/tools","kind":"object"},{"pointer":"/result/capabilities/gitParsec/githubScopes","equals":["pull_request:read","checks:read","pull_request:write"]}]} +{"name":"scenario-e2e-discover-tools","request":{"jsonrpc":"2.0","id":"e2e-tools","method":"tools/list"},"assertions":[{"pointer":"/result/tools","contains_tool":"worktree_list"},{"pointer":"/result/tools","contains_tool":"worktree_start"},{"pointer":"/result/tools","contains_tool":"worktree_ship"},{"pointer":"/result/tools","contains_tool":"health_check"},{"pointer":"/result/tools","min_len":10}]} +{"name":"scenario-e2e-list-worktrees","request":{"jsonrpc":"2.0","id":"e2e-list","method":"tools/call","params":{"name":"worktree_list","arguments":{}}},"assertions":[{"pointer":"/jsonrpc","equals":"2.0"},{"pointer":"/id","equals":"e2e-list"},{"pointer":"/result/isError","equals":false},{"pointer":"/result/content/0/text","contains_text":"\"worktrees\""},{"pointer":"/result/content/0/text","contains_text":"\"count\""}]} +{"name":"scenario-e2e-health-check","request":{"jsonrpc":"2.0","id":"e2e-health","method":"tools/call","params":{"name":"health_check","arguments":{}}},"assertions":[{"pointer":"/jsonrpc","equals":"2.0"},{"pointer":"/id","equals":"e2e-health"},{"pointer":"/result/isError","equals":false},{"pointer":"/result/content/0/text","contains_text":"\"records\""}]} +{"name":"scenario-e2e-worktree-start-preview","request":{"jsonrpc":"2.0","id":"e2e-start-preview","method":"tools/call","params":{"name":"worktree_start","arguments":{"ticket":"FEAT-42","dry_run":true}}},"assertions":[{"pointer":"/jsonrpc","equals":"2.0"},{"pointer":"/id","equals":"e2e-start-preview"},{"pointer":"/result/isError","equals":false},{"pointer":"/result/content/0/text","contains_text":"would create"},{"pointer":"/result/content/0/text","contains_text":"FEAT-42"},{"pointer":"/result/content/0/text","contains_text":"\"dry_run\":true"}]} +{"name":"scenario-e2e-worktree-ship-no-auth","request":{"jsonrpc":"2.0","id":"e2e-ship-noauth","method":"tools/call","params":{"name":"worktree_ship","arguments":{"ticket":"FEAT-42","dry_run":true}}},"assertions":[{"pointer":"/jsonrpc","equals":"2.0"},{"pointer":"/id","equals":"e2e-ship-noauth"},{"pointer":"/result/isError","equals":true},{"pointer":"/result/content/0/text","contains_text":"AUTH_REQUIRED"},{"pointer":"/result/content/0/text","contains_text":"pull_request:write"}]} +{"name":"scenario-e2e-worktree-ship-with-auth-preview","context":{"github":{"token":"","scopes":["pull_request:write"]}},"request":{"jsonrpc":"2.0","id":"e2e-ship-auth","method":"tools/call","params":{"name":"worktree_ship","arguments":{"ticket":"FEAT-42","dry_run":true}}},"assertions":[{"pointer":"/jsonrpc","equals":"2.0"},{"pointer":"/id","equals":"e2e-ship-auth"},{"pointer":"/result/isError","equals":true},{"pointer":"/result/content/0/text","contains_text":"FEAT-42"}]} diff --git a/tests/mcp_serve_e2e.rs b/tests/mcp_serve_e2e.rs new file mode 100644 index 0000000..75e418e --- /dev/null +++ b/tests/mcp_serve_e2e.rs @@ -0,0 +1,543 @@ +//! Phase 34 (#295) + Phase 35 (#294) + Phase 38 (#295) + Phase 41 (#294/#295): Live e2e integration tests for `parsec mcp serve`. +//! +//! Spawns the real parsec binary in a sandboxed temporary git repository +//! and exchanges JSON-RPC 2.0 messages over stdin/stdout. This validates +//! the full subprocess boundary — transport, dispatcher, and tool handlers — +//! beyond the in-process fixture tests in `src/mcp/mod.rs`. +//! +//! All tests use read-only tools or `dry_run=true`; no network calls are made. + +use std::io::Write; +use std::process::{Command, Stdio}; +use tempfile::TempDir; + +// --------------------------------------------------------------------------- +// Sandbox helpers +// --------------------------------------------------------------------------- + +/// Minimal git repo with one empty commit on `main` (no remote). +fn sandbox_repo() -> TempDir { + let dir = TempDir::new().expect("sandbox TempDir"); + let run = |args: &[&str]| { + Command::new("git") + .args(args) + .current_dir(dir.path()) + .output() + .expect("git"); + }; + run(&["init"]); + run(&["config", "user.name", "Sandbox"]); + run(&["config", "user.email", "sandbox@example.invalid"]); + run(&["checkout", "-b", "main"]); + run(&["commit", "--allow-empty", "-m", "sandbox init"]); + dir +} + +/// Spawn `parsec mcp serve`, write requests to stdin, close stdin, wait for +/// exit, and return newline-delimited stdout as parsed JSON values. +fn run_serve_session(sandbox: &TempDir, requests: &[serde_json::Value]) -> Vec { + run_serve_session_with_env(sandbox, requests, &[]) +} + +/// Like [`run_serve_session`] but also injects `(key, value)` pairs into the +/// subprocess environment before spawning. Use this to test env-var-based +/// config paths (e.g., `PARSEC_GITHUB_TOKEN`). +fn run_serve_session_with_env( + sandbox: &TempDir, + requests: &[serde_json::Value], + env_vars: &[(&str, &str)], +) -> Vec { + let bin = assert_cmd::cargo::cargo_bin("parsec"); + let mut cmd = Command::new(&bin); + cmd.args(["mcp", "serve"]) + .current_dir(sandbox.path()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); // audit events go to stderr; suppress in tests + for (key, val) in env_vars { + cmd.env(key, val); + } + let mut child = cmd + .spawn() + .unwrap_or_else(|e| panic!("spawn {}: {e}", bin.display())); + + { + let stdin = child.stdin.as_mut().expect("stdin"); + for req in requests { + writeln!(stdin, "{req}").expect("write"); + } + // EOF closes the server's BufRead loop + } + + let out = child.wait_with_output().expect("wait_with_output"); + assert!(out.status.success(), "serve exited {:?}", out.status); + + String::from_utf8(out.stdout) + .expect("UTF-8") + .lines() + .filter(|l| !l.trim().is_empty()) + .map(|l| serde_json::from_str(l).unwrap_or_else(|e| panic!("non-JSON: {l:?}\n{e}"))) + .collect() +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +/// Full MCP session lifecycle: initialize → notification (no response) → ping → shutdown. +#[test] +fn mcp_serve_protocol_lifecycle() { + let sb = sandbox_repo(); + let res = run_serve_session( + &sb, + &[ + serde_json::json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"live-e2e","version":"0.0.0"}}}), + serde_json::json!({"jsonrpc":"2.0","method":"notifications/initialized","params":{}}), + serde_json::json!({"jsonrpc":"2.0","id":"ping","method":"ping"}), + serde_json::json!({"jsonrpc":"2.0","id":"shutdown","method":"shutdown"}), + ], + ); + // notification yields no response → 3 responses total + assert_eq!(res[0]["id"], 1); + assert_eq!(res[0]["result"]["protocolVersion"], "2025-03-26"); + assert_eq!(res[0]["result"]["serverInfo"]["name"], "git-parsec"); + assert_eq!( + res[0]["result"]["capabilities"]["gitParsec"]["githubScopes"], + serde_json::json!(["pull_request:read", "checks:read", "pull_request:write"]) + ); + assert_eq!(res[1]["id"], "ping"); + assert!(res[1]["result"].is_object()); + assert_eq!(res[2]["id"], "shutdown"); + assert_eq!(res[2]["result"], serde_json::Value::Null); +} + +/// `tools/list` must expose all 10 registered parsec MCP tools. +#[test] +fn mcp_serve_tools_list_full_registry() { + let sb = sandbox_repo(); + let res = run_serve_session( + &sb, + &[serde_json::json!({"jsonrpc":"2.0","id":"tl","method":"tools/list"})], + ); + let tools = res[0]["result"]["tools"].as_array().expect("tools array"); + assert!(tools.len() >= 10, "expected ≥10 tools, got {}", tools.len()); + for name in [ + "worktree_list", + "worktree_start", + "worktree_status", + "worktree_ship", + "smartlog", + "ci_status", + "pr_status", + "health_check", + "reviews", + "sync", + ] { + assert!( + tools.iter().any(|t| t["name"] == name), + "missing tool '{name}'" + ); + } +} + +/// `worktree_list` (no overlay) runs locally and returns the sandbox worktree. +#[test] +fn mcp_serve_worktree_list_and_health_check() { + let sb = sandbox_repo(); + let res = run_serve_session( + &sb, + &[ + serde_json::json!({"jsonrpc":"2.0","id":"wl","method":"tools/call","params":{"name":"worktree_list","arguments":{}}}), + serde_json::json!({"jsonrpc":"2.0","id":"hc","method":"tools/call","params":{"name":"health_check","arguments":{}}}), + ], + ); + + // worktree_list + assert_eq!(res[0]["result"]["isError"], false); + let wl: serde_json::Value = serde_json::from_str( + res[0]["result"]["content"][0]["text"] + .as_str() + .expect("text"), + ) + .expect("wl json"); + assert!(wl["worktrees"].is_array()); + assert_eq!(wl["pr_overlay"], false); + assert_eq!(wl["ci_overlay"], false); + + // health_check + assert_eq!(res[1]["result"]["isError"], false); + let hc: serde_json::Value = serde_json::from_str( + res[1]["result"]["content"][0]["text"] + .as_str() + .expect("text"), + ) + .expect("hc json"); + assert!(hc["records"].is_array()); +} + +/// GitHub-backed tools return structured auth errors when no token is present. +#[test] +fn mcp_serve_github_tools_require_auth() { + let sb = sandbox_repo(); + let res = run_serve_session( + &sb, + &[ + serde_json::json!({"jsonrpc":"2.0","id":"pr","method":"tools/call","params":{"name":"pr_status","arguments":{"ticket":"T-1"}}}), + serde_json::json!({"jsonrpc":"2.0","id":"ci","method":"tools/call","params":{"name":"ci_status","arguments":{"ticket":"T-1"}}}), + ], + ); + for (i, scope) in [("pr", "pull_request:read"), ("ci", "checks:read")] { + let idx = if i == "pr" { 0 } else { 1 }; + assert_eq!(res[idx]["result"]["isError"], true, "{i} should fail"); + let text = res[idx]["result"]["content"][0]["text"] + .as_str() + .expect("text"); + assert!( + text.contains("AUTH_REQUIRED"), + "{i}: expected AUTH_REQUIRED" + ); + assert!(text.contains(scope), "{i}: expected scope {scope}"); + } +} + +/// Mutating tools enforce the dry_run / confirmation gate at the subprocess level. +#[test] +fn mcp_serve_mutation_gates() { + let sb = sandbox_repo(); + let res = run_serve_session( + &sb, + &[ + // No dry_run, no confirm → CONFIRMATION_REQUIRED + serde_json::json!({"jsonrpc":"2.0","id":"no-confirm","method":"tools/call","params":{"name":"worktree_start","arguments":{"ticket":"LIVE-1"}}}), + // dry_run=true → preview without side effects + serde_json::json!({"jsonrpc":"2.0","id":"dry-run","method":"tools/call","params":{"name":"worktree_start","arguments":{"ticket":"LIVE-2","dry_run":true}}}), + ], + ); + // no-confirm + assert_eq!(res[0]["result"]["isError"], true); + let text0 = res[0]["result"]["content"][0]["text"] + .as_str() + .expect("text"); + assert!(text0.contains("CONFIRMATION_REQUIRED"), "got: {text0}"); + + // dry-run preview + assert_eq!(res[1]["result"]["isError"], false, "dry_run should succeed"); + let text1 = res[1]["result"]["content"][0]["text"] + .as_str() + .expect("text"); + assert!( + text1.contains("LIVE-2"), + "dry_run should reference ticket: {text1}" + ); +} + +// --------------------------------------------------------------------------- +// Phase 35 (#294): PARSEC_GITHUB_TOKEN env var → McpContext +// --------------------------------------------------------------------------- + +/// When `PARSEC_GITHUB_TOKEN` is set in the subprocess environment, tools that +/// previously returned `AUTH_REQUIRED` must proceed past the token gate. +/// +/// The sandbox repo has no remote, so `pr_status` will fail at the `gh` call +/// level (tool_error) rather than at the auth gate (AUTH_REQUIRED). This proves +/// the env var is picked up and forwarded into `McpContext.github_token`. +#[test] +fn mcp_serve_env_token_bypasses_auth_required() { + let sb = sandbox_repo(); + + // Without env var: pr_status should return AUTH_REQUIRED. + let res_no_token = run_serve_session( + &sb, + &[serde_json::json!({ + "jsonrpc": "2.0", + "id": "no-token", + "method": "tools/call", + "params": { + "name": "pr_status", + "arguments": {"ticket": "TEST-1"} + } + })], + ); + assert_eq!(res_no_token[0]["result"]["isError"], true); + let text_no_token = res_no_token[0]["result"]["content"][0]["text"] + .as_str() + .expect("text"); + assert!( + text_no_token.contains("AUTH_REQUIRED"), + "without token, expected AUTH_REQUIRED; got: {text_no_token}" + ); + + // With env var: pr_status should NOT return AUTH_REQUIRED; it proceeds to + // the tool handler which fails at the gh CLI level (no remote configured). + let res_with_token = run_serve_session_with_env( + &sb, + &[serde_json::json!({ + "jsonrpc": "2.0", + "id": "with-token", + "method": "tools/call", + "params": { + "name": "pr_status", + "arguments": {"ticket": "TEST-1"} + } + })], + &[("PARSEC_GITHUB_TOKEN", "ghp_fake_token_for_e2e_test")], + ); + assert_eq!(res_with_token[0]["result"]["isError"], true); + let text_with_token = res_with_token[0]["result"]["content"][0]["text"] + .as_str() + .expect("text"); + assert!( + !text_with_token.contains("AUTH_REQUIRED"), + "with PARSEC_GITHUB_TOKEN set, AUTH_REQUIRED must not appear; got: {text_with_token}" + ); +} + +// --------------------------------------------------------------------------- +// Phase 38 (#295): mcp.toml config-file auth source + env-var precedence e2e +// +// Validates at the *subprocess binary boundary* that: +// 1. A token in `mcp.toml` (via PARSEC_MCP_CONFIG) bypasses AUTH_REQUIRED. +// 2. PARSEC_GITHUB_TOKEN wins over mcp.toml when both are present; in that +// case the config-file scope restrictions are also superseded so a tool +// that would have hit INSUFFICIENT_SCOPE under the config-file scopes +// instead reaches CONFIRMATION_REQUIRED (the mutation gate). +// --------------------------------------------------------------------------- + +/// Write `contents` to a named temp file and return the `TempDir` that keeps +/// the file alive for the duration of the test, along with the file path. +fn write_temp_toml(dir: &tempfile::TempDir, filename: &str, contents: &str) -> std::path::PathBuf { + let path = dir.path().join(filename); + std::fs::write(&path, contents).expect("write temp toml"); + path +} + +/// A token supplied only through `mcp.toml` (via `PARSEC_MCP_CONFIG`) must be +/// loaded by the server subprocess and forwarded to `McpContext.github_token`, +/// which causes tools that previously returned `AUTH_REQUIRED` to proceed past +/// the auth gate (they fail at the `gh` CLI level instead). +#[test] +fn mcp_serve_config_file_token_bypasses_auth_required() { + let sb = sandbox_repo(); + // Config dir is separate from the git sandbox so we can write freely. + let cfg_dir = tempfile::TempDir::new().expect("cfg TempDir"); + let cfg_path = write_temp_toml( + &cfg_dir, + "mcp.toml", + "[auth]\ntoken = \"ghp_config_fake_e2e\"\n", + ); + + // Without config token (no env var, no PARSEC_MCP_CONFIG): AUTH_REQUIRED. + let res_no_cfg = run_serve_session( + &sb, + &[serde_json::json!({ + "jsonrpc": "2.0", + "id": "baseline", + "method": "tools/call", + "params": { "name": "pr_status", "arguments": { "ticket": "PHASE38-0" } } + })], + ); + assert_eq!(res_no_cfg[0]["result"]["isError"], true); + let text_baseline = res_no_cfg[0]["result"]["content"][0]["text"] + .as_str() + .expect("text"); + assert!( + text_baseline.contains("AUTH_REQUIRED"), + "baseline without config: expected AUTH_REQUIRED; got: {text_baseline}" + ); + + // With PARSEC_MCP_CONFIG pointing at the temp mcp.toml: AUTH_REQUIRED must + // not appear — server picked up the config-file token and passed it through. + let res_with_cfg = run_serve_session_with_env( + &sb, + &[serde_json::json!({ + "jsonrpc": "2.0", + "id": "cfg-token", + "method": "tools/call", + "params": { "name": "pr_status", "arguments": { "ticket": "PHASE38-1" } } + })], + &[("PARSEC_MCP_CONFIG", cfg_path.to_str().unwrap())], + ); + assert_eq!(res_with_cfg[0]["result"]["isError"], true); + let text_with_cfg = res_with_cfg[0]["result"]["content"][0]["text"] + .as_str() + .expect("text"); + assert!( + !text_with_cfg.contains("AUTH_REQUIRED"), + "with config-file token, AUTH_REQUIRED must not appear; got: {text_with_cfg}" + ); +} + +/// When both `PARSEC_GITHUB_TOKEN` (env var) and `mcp.toml` (via +/// `PARSEC_MCP_CONFIG`) are present, the env var must win. The env-var path +/// does **not** load scope restrictions from the config file, so a tool that +/// would hit `INSUFFICIENT_SCOPE` under the config-file scopes must instead +/// reach `CONFIRMATION_REQUIRED` — the mutation gate — proving the env var +/// superseded the config-file token *and* its scope list. +#[test] +fn mcp_serve_env_var_wins_over_config_file_scope_restriction() { + let sb = sandbox_repo(); + let cfg_dir = tempfile::TempDir::new().expect("cfg TempDir"); + // Config file grants only pull_request:read; worktree_ship needs + // pull_request:write, so with config-only auth it would get INSUFFICIENT_SCOPE. + let cfg_path = write_temp_toml( + &cfg_dir, + "mcp.toml", + "[auth]\ntoken = \"ghp_read_only_config\"\nscopes = [\"pull_request:read\"]\n", + ); + + // Verify baseline with config-file only: INSUFFICIENT_SCOPE for worktree_ship. + let res_cfg_only = run_serve_session_with_env( + &sb, + &[serde_json::json!({ + "jsonrpc": "2.0", + "id": "cfg-only", + "method": "tools/call", + "params": { + "name": "worktree_ship", + "arguments": { "ticket": "PHASE38-A", "confirm": true } + } + })], + &[("PARSEC_MCP_CONFIG", cfg_path.to_str().unwrap())], + ); + assert_eq!(res_cfg_only[0]["result"]["isError"], true); + let text_cfg_only = res_cfg_only[0]["result"]["content"][0]["text"] + .as_str() + .expect("text"); + assert!( + text_cfg_only.contains("INSUFFICIENT_SCOPE"), + "config-file pull_request:read should block worktree_ship; got: {text_cfg_only}" + ); + + // Now add PARSEC_GITHUB_TOKEN: env var takes priority, discarding the + // config-file token AND its scope restrictions. worktree_ship proceeds + // past the scope gate and hits CONFIRMATION_REQUIRED (no dry_run / no + // explicit confirm flag combination that passes the mutation gate here), + // which is a different error than INSUFFICIENT_SCOPE. + let res_env_wins = run_serve_session_with_env( + &sb, + &[serde_json::json!({ + "jsonrpc": "2.0", + "id": "env-wins", + "method": "tools/call", + "params": { + "name": "worktree_ship", + "arguments": { "ticket": "PHASE38-B" } + } + })], + &[ + ("PARSEC_GITHUB_TOKEN", "ghp_env_full_access"), + ("PARSEC_MCP_CONFIG", cfg_path.to_str().unwrap()), + ], + ); + assert_eq!(res_env_wins[0]["result"]["isError"], true); + let text_env_wins = res_env_wins[0]["result"]["content"][0]["text"] + .as_str() + .expect("text"); + // With env var in play, scope gate passes → must reach CONFIRMATION_REQUIRED, + // not INSUFFICIENT_SCOPE. + assert!( + !text_env_wins.contains("INSUFFICIENT_SCOPE"), + "env var must supersede config-file scope restriction; got: {text_env_wins}" + ); + assert!( + text_env_wins.contains("CONFIRMATION_REQUIRED"), + "env var path should reach mutation gate (CONFIRMATION_REQUIRED); got: {text_env_wins}" + ); +} + +// --------------------------------------------------------------------------- +// Phase 41 (#294 + #295): Sandbox path-boundary enforcement — subprocess e2e +// +// Validates at the *subprocess binary boundary* that the MCP server rejects +// `repo` arguments that point outside the session-boundary directory. +// +// The unit test in `src/mcp/mod.rs::repository_argument_cannot_escape_session_boundary` +// covers this at the `dispatch_json_rpc_with_context` call level. This test +// exercises the same invariant through the full transport stack (spawn → +// stdin JSON-RPC → stdout parse) so CI confirms the sandboxing contract +// survives the stdio codec and subprocess boundary. +// +// Addresses #294 AC: "sandbox: 로컬 worktree 외부 접근 차단" +// Addresses #295 AC: "CI smoke test 통합" (runs via `cargo test`) +// --------------------------------------------------------------------------- + +/// A `repo` argument that is an absolute path outside the session boundary +/// must return `SANDBOX_VIOLATION` through the subprocess transport. +/// +/// The outside path must not appear in the error text (information leak guard). +#[test] +fn mcp_serve_sandbox_boundary_violation_rejected() { + let sb = sandbox_repo(); + // Create a distinct temp directory that is *outside* the sandbox repo. + // The server process is started in `sb`, so any path that is not a + // descendant of `sb.path()` must trigger the sandbox guard. + let outside = tempfile::TempDir::new().expect("outside TempDir"); + let outside_str = outside.path().to_str().expect("outside path to str"); + + let res = run_serve_session( + &sb, + &[serde_json::json!({ + "jsonrpc": "2.0", + "id": "sandbox-boundary", + "method": "tools/call", + "params": { + "name": "worktree_list", + "arguments": { "repo": outside_str } + } + })], + ); + + assert_eq!( + res[0]["result"]["isError"], true, + "out-of-boundary repo argument must be an error; got: {:?}", + res[0] + ); + let text = res[0]["result"]["content"][0]["text"] + .as_str() + .expect("MCP error envelope must contain text"); + assert!( + text.contains("SANDBOX_VIOLATION"), + "out-of-boundary repo must produce SANDBOX_VIOLATION; got: {text}" + ); + // Information-leak guard: the raw outside path must not appear in the response. + assert!( + !text.contains(outside_str), + "SANDBOX_VIOLATION response must not echo the requested path; got: {text}" + ); +} + +/// A `repo` argument that resolves to a directory *inside* the session +/// boundary (a sub-directory of the sandbox root) must not trigger +/// `SANDBOX_VIOLATION` — the path guard is boundary-inclusive. +/// +/// The call will still fail (no worktrees under the child path), but the +/// failure must be a tool-level error rather than a `SANDBOX_VIOLATION`. +#[test] +fn mcp_serve_sandbox_descendant_path_allowed() { + let sb = sandbox_repo(); + // Create a sub-directory inside the sandbox boundary to use as `repo`. + let child = sb.path().join("inner-worktree"); + std::fs::create_dir(&child).expect("child directory"); + let child_str = child.to_str().expect("child path to str"); + + let res = run_serve_session( + &sb, + &[serde_json::json!({ + "jsonrpc": "2.0", + "id": "sandbox-child", + "method": "tools/call", + "params": { + "name": "worktree_list", + "arguments": { "repo": child_str } + } + })], + ); + + let text = res[0]["result"]["content"][0]["text"] + .as_str() + .expect("MCP response must contain text"); + assert!( + !text.contains("SANDBOX_VIOLATION"), + "descendant path must not trigger SANDBOX_VIOLATION; got: {text}" + ); +}