diff --git a/README.md b/README.md index 205b2350..1a6a2b52 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Completion obligations for those binaries are tracked in | ---- | -------- | -------- | | API testing | `api-rest`, `api-gql`, `api-grpc`, `api-websocket`, `api-test` | Run protocol-specific API checks or orchestrate a mixed API test suite. | | Git tooling | `git-scope`, `git-cli`, `git-summary`, `git-lock` | Inspect changes, run Git helper flows, summarize commits, or manage repo-local commit locks. | +| Forge automation | `forge-cli` | Drive PR/MR + Issue lifecycle on GitHub (via `gh`) or GitLab (via `glab`) through a single provider-neutral surface; covers create / view / edit / comment / ready / merge / close, CI wait-checks, and the `pr deliver` macro. | | Agent policy and evidence | `agent-docs`, `agent-out`, `agent-scope-lock`, `test-first-evidence`, `web-evidence`, `browser-session`, `canary-check`, `docs-impact`, `heuristic-inbox`, `model-cross-check`, `repo-retro`, `review-evidence`, `skill-usage` | Resolve agent policy docs, allocate artifact paths, enforce edit scope, inspect repo retrospectives, or persist deterministic workflow evidence. | | Planning and delivery | `plan-tooling`, `plan-issue`, `plan-issue-local`, `semantic-commit` | Validate/split implementation plans, orchestrate issue delivery, rehearse local plan flows, or create semantic commits. | | Provider lanes | `codex-cli`, `gemini-cli` | Run provider-specific diagnostics, auth checks, and workflow adapters. | @@ -57,6 +58,9 @@ Each crate is either a standalone CLI binary, a multi-binary crate, or a shared - [crates/git-cli](crates/git-cli): Git tools dispatcher (utils/reset/commit/branch/ci/open). - [crates/git-summary](crates/git-summary): Per-author contribution summaries over a date range (adds/dels/net/commits). - [crates/git-lock](crates/git-lock): Label-based commit locks per repo (lock/list/diff/unlock/tag). +- [crates/forge-cli](crates/forge-cli): Provider-neutral forge CLI wrapping `gh` / `glab` for + PR/MR + Issue lifecycle, CI wait-checks, and the `pr deliver` macro + (open draft → CI green → ready → merge). ### Desktop, media, and local utility CLIs diff --git a/crates/forge-cli/tests/integration.rs b/crates/forge-cli/tests/integration.rs index 6a84bc2f..a3ffc847 100644 --- a/crates/forge-cli/tests/integration.rs +++ b/crates/forge-cli/tests/integration.rs @@ -4,6 +4,7 @@ mod integration { mod auth_status; mod cli; + mod completion_sync; mod exit_codes; mod exit_codes_full; mod fixture_lint; diff --git a/crates/forge-cli/tests/integration/completion_sync.rs b/crates/forge-cli/tests/integration/completion_sync.rs new file mode 100644 index 00000000..b1c97d22 --- /dev/null +++ b/crates/forge-cli/tests/integration/completion_sync.rs @@ -0,0 +1,98 @@ +//! Sprint 8 Task 8.1 — completion script generation and sync test. +//! +//! Asserts: +//! 1. `forge-cli completion bash|zsh` succeeds and emits a non-empty script +//! whose preamble matches the shell-specific marker (`_forge-cli` for +//! bash, `#compdef forge-cli` for zsh). +//! 2. The checked-in completion files at `completions/{bash,zsh}/` stay in +//! byte-equality with the live binary output, so a future flag addition +//! that forgets to regenerate the snapshots fails CI rather than landing +//! silently. + +use std::path::PathBuf; +use std::process::Command; + +use pretty_assertions::assert_eq; + +use super::support::forge_cli_bin; + +fn run_completion(shell: &str) -> (i32, String, String) { + let output = Command::new(forge_cli_bin()) + .args(["completion", shell]) + .output() + .expect("spawn forge-cli"); + ( + output.status.code().unwrap_or(-1), + String::from_utf8_lossy(&output.stdout).into_owned(), + String::from_utf8_lossy(&output.stderr).into_owned(), + ) +} + +fn workspace_root() -> PathBuf { + // tests live under crates/forge-cli/tests; the workspace root is two + // ancestors above the crate dir. Walk up until we find `completions/`. + std::env::current_dir() + .expect("cwd") + .ancestors() + .find(|p| p.join("completions").is_dir() && p.join("Cargo.toml").is_file()) + .expect("locate workspace root") + .to_path_buf() +} + +#[test] +fn completion_bash_emits_non_empty_script_with_forge_cli_marker() { + let (code, stdout, stderr) = run_completion("bash"); + assert_eq!(code, 0, "stderr={stderr}"); + assert!( + !stdout.is_empty(), + "bash completion script must be non-empty" + ); + assert!( + stdout.contains("_forge-cli"), + "bash completion must define _forge-cli, got first 200 bytes: {}", + &stdout[..stdout.len().min(200)] + ); +} + +#[test] +fn completion_zsh_emits_non_empty_script_with_compdef_marker() { + let (code, stdout, stderr) = run_completion("zsh"); + assert_eq!(code, 0, "stderr={stderr}"); + assert!( + !stdout.is_empty(), + "zsh completion script must be non-empty" + ); + assert!( + stdout.contains("#compdef forge-cli"), + "zsh completion must start with #compdef forge-cli, got first 200 bytes: {}", + &stdout[..stdout.len().min(200)] + ); +} + +#[test] +fn checked_in_bash_completion_matches_live_binary_output() { + let (code, stdout, _) = run_completion("bash"); + assert_eq!(code, 0); + let path = workspace_root().join("completions/bash/forge-cli"); + let on_disk = + std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display())); + assert_eq!( + on_disk.trim_end(), + stdout.trim_end(), + "completions/bash/forge-cli is out of sync with the binary; regenerate via\n forge-cli completion bash > completions/bash/forge-cli" + ); +} + +#[test] +fn checked_in_zsh_completion_matches_live_binary_output() { + let (code, stdout, _) = run_completion("zsh"); + assert_eq!(code, 0); + let path = workspace_root().join("completions/zsh/_forge-cli"); + let on_disk = + std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display())); + assert_eq!( + on_disk.trim_end(), + stdout.trim_end(), + "completions/zsh/_forge-cli is out of sync with the binary; regenerate via\n forge-cli completion zsh > completions/zsh/_forge-cli" + ); +} diff --git a/docs/plans/forge-cli/forge-cli-execution-state.md b/docs/plans/forge-cli/forge-cli-execution-state.md index 7db80239..9c2ad15f 100644 --- a/docs/plans/forge-cli/forge-cli-execution-state.md +++ b/docs/plans/forge-cli/forge-cli-execution-state.md @@ -7,12 +7,13 @@ - Execution window: 2026-05-19 → ongoing - Staged execution confirmation: not applicable (default-continue authorization: "默認就一直做下去") -- Current task: Sprint 7 review (PR pending) -- Next task: Task 8.1 +- Current task: Sprint 8 review (PR pending) +- Next task: Task 8.3 (release flow — runs after PR merges via + `nils-cli-bump-version-tag-release`, not in this PR) - Last updated: 2026-05-20 -- Branch/commit: `feat/forge-cli-v1-sprint7-parity` cut from - `origin/main@15fcc73` (Sprint 6 PR #397 merge); parity harness, - exit-code matrix, and fixture redaction audit complete locally, +- Branch/commit: `feat/forge-cli-v1-sprint8-wrapper` cut from + `origin/main@3474555` (Sprint 7 PR #398 merge); Task 8.1 wrapper + + completion sync test and Task 8.2 README cross-link complete locally, PR pending push and CI - Source document: docs/plans/forge-cli/forge-cli-plan.md - Direct source-doc execution waiver: not applicable @@ -45,9 +46,9 @@ | Task 7.1 | completed | Parity harness | branch `feat/forge-cli-v1-sprint7-parity` | Sprint 7 — 11-row table + 5 cross-provider envelope assertions | | Task 7.2 | completed | Exit-code matrix completion | branch `feat/forge-cli-v1-sprint7-parity` | Sprint 7 — 12 tests covering every documented (exit, kind) | | Task 7.3 | completed | Fixture redaction audit | branch `feat/forge-cli-v1-sprint7-parity` | Sprint 7 — lint script + planted-token regression test | -| Task 8.1 | pending | `wrappers/forge-cli` + shell completions | n/a | Sprint 8 | -| Task 8.2 | pending | Homebrew tap formula update | n/a | Sprint 8 | -| Task 8.3 | pending | `nils-cli` minor bump + tag + tap formula bump | n/a | Sprint 8 | +| Task 8.1 | completed | `wrappers/forge-cli` + shell completions | branch `feat/forge-cli-v1-sprint8-wrapper` | Sprint 8 — wrapper mirrors git-cli, 4 completion-sync tests | +| Task 8.2 | completed | Homebrew tap formula update | branch `feat/forge-cli-v1-sprint8-wrapper` | Sprint 8 — workspace README cross-link + wrapper ready for tap | +| Task 8.3 | pending | `nils-cli` minor bump + tag + tap formula bump | post-merge release flow | Runs via nils-cli-bump-version-tag-release after PR merges | ## Validation @@ -126,3 +127,16 @@ (`scripts/ci/forge-cli-fixture-lint.sh` + planted-token regression in `fixture_lint.rs`, wired into the docs-only entrypoint). Test surface grows to 219 lib + 89 integration. +- 2026-05-20 — Sprint 7 merged via PR #398 (`3474555`); cut Sprint 8 + branch `feat/forge-cli-v1-sprint8-wrapper` from updated `origin/main`. + Task 8.1 added `wrappers/forge-cli` (bash wrapper mirroring + `wrappers/git-cli` — auto/debug/installed modes with cargo-fallback) + and four completion-sync tests + (`crates/forge-cli/tests/integration/completion_sync.rs`) that pin + byte-equality between the binary's `forge-cli completion bash|zsh` + output and the checked-in `completions/{bash,zsh}/` snapshots. + Task 8.2 added the workspace README cross-link (CLI surface map + + Git-tooling section). Task 8.3 (`nils-cli` minor bump + tap formula + bump) runs post-merge via the `nils-cli-bump-version-tag-release` + skill and is not part of this PR. Test surface grows to 219 lib + + 93 integration. diff --git a/wrappers/forge-cli b/wrappers/forge-cli new file mode 100755 index 00000000..fd1dc9df --- /dev/null +++ b/wrappers/forge-cli @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +set -euo pipefail + +bin_name="forge-cli" +crate_name="nils-forge-cli" +args=("$@") +if [[ ${#args[@]} -ge 2 && ${args[0]} == "--" && ${args[1]} == "help" ]]; then + args=("help" "${args[@]:2}") +fi + +self_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +self_path="$self_dir/$(basename -- "${BASH_SOURCE[0]}")" + +mode="${NILS_WRAPPER_MODE:-auto}" +install_prefix="${NILS_WRAPPER_INSTALL_PREFIX:-$HOME/.local/nils-cli}" + +if [[ "$install_prefix" == "~" ]]; then + install_prefix="$HOME" +elif [[ "$install_prefix" == "~/"* ]]; then + install_prefix="$HOME/${install_prefix#~/}" +fi + +if [[ "$mode" != "auto" && "$mode" != "debug" && "$mode" != "installed" ]]; then + echo "${bin_name}: invalid NILS_WRAPPER_MODE='${mode}' (expected: auto|debug|installed)" >&2 + exit 64 +fi + +emit_status() { + if [[ "${NILS_WRAPPER_STATUS_HINTS:-1}" == "0" ]]; then + return 0 + fi + + echo "$*" >&2 +} + +is_self_candidate() { + local candidate="$1" + [[ -n "$candidate" && -e "$candidate" && "$candidate" -ef "$self_path" ]] +} + +resolve_installed() { + local candidate="" + + candidate="${install_prefix}/${bin_name}" + if [[ -x "$candidate" ]] && ! is_self_candidate "$candidate"; then + printf '%s\n' "$candidate" + return 0 + fi + + candidate="$(command -v "$bin_name" 2>/dev/null || true)" + if [[ -n "$candidate" && -x "$candidate" ]] && ! is_self_candidate "$candidate"; then + printf '%s\n' "$candidate" + return 0 + fi + + return 1 +} + +run_debug() { + if command -v cargo >/dev/null 2>&1; then + emit_status "${bin_name}: exec=cargo mode=${mode} crate=${crate_name} (cargo -q: build may stay silent while compiling)" + exec cargo run -q -p "$crate_name" -- "${args[@]}" + fi + + echo "${bin_name}: cargo not found (required when NILS_WRAPPER_MODE=debug)" >&2 + exit 1 +} + +run_installed() { + local installed="" + if installed="$(resolve_installed)"; then + emit_status "${bin_name}: exec=installed mode=${mode} path=${installed}" + exec "$installed" "${args[@]}" + fi + + echo "${bin_name}: installed binary not found (NILS_WRAPPER_MODE=installed)" >&2 + echo "${bin_name}: expected ${install_prefix}/${bin_name} or a PATH entry" >&2 + exit 1 +} + +case "$mode" in + debug) + run_debug + ;; + installed) + run_installed + ;; +esac + +installed="" +if installed="$(resolve_installed)"; then + emit_status "${bin_name}: exec=installed mode=${mode} path=${installed}" + exec "$installed" "${args[@]}" +fi + +if command -v cargo >/dev/null 2>&1; then + emit_status "${bin_name}: exec=cargo mode=${mode} crate=${crate_name} (cargo -q: build may stay silent while compiling)" + exec cargo run -q -p "$crate_name" -- "${args[@]}" +fi + +echo "forge-cli: binary not found (install via cargo install or build the workspace)" >&2 +exit 1