diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0feced0..03940c8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,76 +5,81 @@ on: push: branches: [master, main] -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true +permissions: + contents: read jobs: test: runs-on: ubuntu-latest - steps: - - name: Check out repository - uses: actions/checkout@v4 - - - name: Set up mise - uses: jdx/mise-action@v2 - - - name: Install test dependencies - run: | - sudo apt-get update - sudo apt-get install -y shellcheck - - - name: Static checks - run: | - bash -n scripts/start-issue - shellcheck install.sh scripts/start-issue scripts/build-start-issue scripts/bump-version scripts/prepare-release scripts/lib/start_issue/*.sh - git diff --check + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - run: make test + - run: make build + - run: .build/start-issue --version - - name: Legacy environment drift check - run: | - legacy_name="CLAUDE""_WORKTREE_DIR" - if grep -R --line-number "$legacy_name" scripts README.md README.ru.md docs doc test .github; then - echo "Legacy Claude worktree environment variable name must not be used." - exit 1 - fi - - - name: Integration tests - run: mise exec -- bats test + sandbox-e2e: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - run: make e2e-sandbox - install-script: - runs-on: ${{ matrix.os }} + platform-smoke: strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest] - + include: + - runner: ubuntu-latest + artifact: start-issue + - runner: macos-latest + artifact: start-issue + - runner: windows-latest + artifact: start-issue.exe + runs-on: ${{ matrix.runner }} steps: - - name: Check out repository - uses: actions/checkout@v4 - - - name: Build local release artifact - run: | - bash scripts/build-start-issue "$RUNNER_TEMP/start-issue" >/dev/null - chmod +x "$RUNNER_TEMP/start-issue" - - - name: Generate checksum file + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - run: go build -o ${{ matrix.artifact }} ./cmd/start-issue + - if: runner.os != 'Windows' + run: ./${{ matrix.artifact }} --version + - if: runner.os != 'Windows' + run: make test + - if: runner.os == 'Windows' + shell: pwsh run: | - if command -v sha256sum >/dev/null 2>&1; then - checksum="$(sha256sum "$RUNNER_TEMP/start-issue" | awk '{ print $1 }')" - else - checksum="$(shasum -a 256 "$RUNNER_TEMP/start-issue" | awk '{ print $1 }')" - fi - printf '%s %s\n' "$checksum" start-issue > "$RUNNER_TEMP/start-issue.sha256" + & .\${{ matrix.artifact }} --version + $update = (go run ./cmd/start-issue update | Out-String) + if ($update -notmatch 'Windows update is manual') { throw "Expected Windows manual-update instruction: $update" } - - name: Test install.sh via pipe - run: | - curl -fsSL "file://$GITHUB_WORKSPACE/install.sh" | env \ - PREFIX="$RUNNER_TEMP/prefix" \ - START_ISSUE_ASSET_URL="file://$RUNNER_TEMP/start-issue" \ - START_ISSUE_CHECKSUM_URL="file://$RUNNER_TEMP/start-issue.sha256" \ - bash - - - name: Verify installed binary - run: | - test -x "$RUNNER_TEMP/prefix/bin/start-issue" - "$RUNNER_TEMP/prefix/bin/start-issue" --version | grep '^start-issue v' + cross-build: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - os: linux + arch: amd64 + - os: linux + arch: arm64 + - os: darwin + arch: amd64 + - os: darwin + arch: arm64 + - os: windows + arch: amd64 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - run: go build ./cmd/start-issue + env: + GOOS: ${{ matrix.os }} + GOARCH: ${{ matrix.arch }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d40eee2..330c059 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,51 +2,31 @@ name: Release on: push: - tags: - - "v*" + tags: ["v*"] permissions: contents: write -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - jobs: release: runs-on: ubuntu-latest - steps: - - name: Check out repository - uses: actions/checkout@v4 - - - name: Set up mise - uses: jdx/mise-action@v2 - - - name: Install test dependencies - run: | - sudo apt-get update - sudo apt-get install -y shellcheck - - - name: Verify tag matches script version - run: | - script_version="$(awk -F'"' '/^VERSION="/ { print $2; exit }' scripts/start-issue)" - tag_version="${GITHUB_REF_NAME#v}" - test "$script_version" = "$tag_version" - - - name: Run checks - run: make test - - - name: Build release artifact - run: | - make build - cp .build/start-issue start-issue - sha256sum start-issue > start-issue.sha256 - - - name: Publish GitHub release + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - run: make test + - run: make e2e-sandbox + - uses: goreleaser/goreleaser-action@v6 + with: + distribution: goreleaser + version: ~> v2 + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Publish release env: - GITHUB_TOKEN: ${{ github.token }} - run: | - gh release create "$GITHUB_REF_NAME" \ - start-issue \ - start-issue.sha256 \ - --generate-notes + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh release edit "$GITHUB_REF_NAME" --draft=false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..62b6c2e --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.build/ +/start-issue +/start-issue.exe diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..3442fda --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,38 @@ +version: 2 +project_name: start-issue + +before: + hooks: + - go mod tidy + - sh -c 'mkdir -p .release && cp scripts/v1-upgrade-shim .release/start-issue && chmod 0755 .release/start-issue && (cd .release && sha256sum start-issue > start-issue.sha256)' + +builds: + - id: start-issue + main: ./cmd/start-issue + binary: start-issue + env: [CGO_ENABLED=0] + goos: [linux, darwin, windows] + goarch: [amd64, arm64] + ignore: + - goos: windows + goarch: arm64 + ldflags: + - -s -w -X main.version={{ .Version }} + +archives: + - id: binaries + formats: [binary] + name_template: "{{ .ProjectName }}-{{ .Os }}-{{ .Arch }}" + +checksum: + name_template: checksums.txt + +release: + # Keep the release invisible until GoReleaser has uploaded both the normal + # assets and the v1 bridge required by older self-updaters. + draft: true + extra_files: + - glob: .release/start-issue + name_template: start-issue + - glob: .release/start-issue.sha256 + name_template: start-issue.sha256 diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e97552..1f7700f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,13 @@ This project follows Semantic Versioning. - Removed the obsolete `codex exec --ask-for-approval` argument from the Codex human-gate workflow, restoring compatibility with current Codex CLI versions. +## [2.0.0] - 2026-07-22 + +### Changed + +- Replaced the Bash CLI, Bats suite, and shell build/release tooling with the Go implementation and Go test suite. +- Changed distribution to platform-specific Go binaries with `checksums.txt` verification. + ## [1.13.2] - 2026-07-19 ### Added diff --git a/Makefile b/Makefile index ca87388..06e8916 100644 --- a/Makefile +++ b/Makefile @@ -1,57 +1,41 @@ -.PHONY: build install uninstall test e2e-human-gate print-version bump-patch bump-minor bump-major release-patch release-minor release-major +.PHONY: build install uninstall test e2e-sandbox e2e-human-gate print-version bump-patch bump-minor bump-major release-patch release-minor release-major PREFIX ?= $(HOME)/.local BINDIR ?= $(PREFIX)/bin BUILD_DIR ?= .build BUILD_OUTPUT ?= $(BUILD_DIR)/start-issue +# Release tags are the version source. A checkout between releases carries the +# nearest tag plus its Git describe suffix, which keeps source builds distinct +# from the last published release for update comparisons. +VERSION ?= $(shell git describe --tags --match 'v[0-9]*' --always --dirty 2>/dev/null | sed 's/^v//') +VERSION := $(if $(strip $(VERSION)),$(VERSION),dev) build: @mkdir -p "$(BUILD_DIR)" - @bash scripts/build-start-issue "$(BUILD_OUTPUT)" >/dev/null - @chmod +x "$(BUILD_OUTPUT)" + go build -trimpath -ldflags "-s -w -X main.version=$(VERSION)" -o "$(BUILD_OUTPUT)" ./cmd/start-issue @echo "Built: $(BUILD_OUTPUT)" -install: +install: build @mkdir -p "$(BINDIR)" - @set -e; \ - tmpfile="$$(mktemp)"; \ - trap 'rm -f "$$tmpfile"' EXIT; \ - bash scripts/build-start-issue "$$tmpfile" >/dev/null; \ - cp "$$tmpfile" "$(BINDIR)/start-issue" - @chmod +x "$(BINDIR)/start-issue" + install -m 0755 "$(BUILD_OUTPUT)" "$(BINDIR)/start-issue" @echo "Installed: $(BINDIR)/start-issue" uninstall: - @rm -f "$(BINDIR)/start-issue" + rm -f "$(BINDIR)/start-issue" @echo "Removed: $(BINDIR)/start-issue" test: - bash -n scripts/start-issue - shellcheck install.sh scripts/start-issue scripts/build-start-issue scripts/bump-version scripts/prepare-release scripts/lib/start_issue/*.sh test/e2e/*.sh + @test -z "$$($$(go env GOROOT)/bin/gofmt -l cmd)" + go vet ./... + go test ./... python3 scripts/check_memory_bank_index.py --max-depth 4 git diff --check - bats test -e2e-human-gate: - @bash test/e2e/human-gate.sh +e2e-human-gate: build + @START_ISSUE_E2E_BINARY="$(abspath $(BUILD_OUTPUT))" bash test/e2e/human-gate.sh -print-version: - @awk -F'"' '/^VERSION="/ { print $$2; exit }' scripts/start-issue - -bump-patch: - @bash scripts/bump-version patch - -bump-minor: - @bash scripts/bump-version minor - -bump-major: - @bash scripts/bump-version major +e2e-sandbox: build + @START_ISSUE_SANDBOX_BINARY="$(abspath $(BUILD_OUTPUT))" bash test/e2e/sandbox.sh -release-patch: - @bash scripts/prepare-release patch - -release-minor: - @bash scripts/prepare-release minor - -release-major: - @bash scripts/prepare-release major +print-version: + @echo "$(VERSION)" diff --git a/README.md b/README.md index 620249b..71e38d9 100644 --- a/README.md +++ b/README.md @@ -16,38 +16,19 @@ It fetches issue metadata with `gh`, creates a git worktree with a branch name b ## Install -Install the latest published release: +Install from source with Go: ```bash -curl -fsSL https://raw.githubusercontent.com/dapi/start-issue/master/install.sh | bash +go install github.com/dapi/start-issue/v2/cmd/start-issue@latest ``` -The installer downloads the latest GitHub Release asset into `~/.local/bin/start-issue` by default. +Published releases contain platform-specific Go binaries and a `checksums.txt` +manifest. Download the asset matching your OS and architecture from the release +page and verify it against that manifest before adding it to `PATH`. -For developer diagnostics on a machine where the install appears to hang: - -```bash -curl -fsSL https://raw.githubusercontent.com/dapi/start-issue/master/install.sh | bash -s -- --debug -``` - -This enables shell tracing plus verbose `curl` or `wget` output so you can see which step is blocking. - -Manual install: - -```bash -mkdir -p ~/.local/bin -curl -fsSL https://github.com/dapi/start-issue/releases/latest/download/start-issue -o ~/.local/bin/start-issue -chmod +x ~/.local/bin/start-issue -``` - -Verify the download if you want: - -```bash -tmpdir="$(mktemp -d)" -curl -fsSL https://github.com/dapi/start-issue/releases/latest/download/start-issue -o "$tmpdir/start-issue" -curl -fsSL https://github.com/dapi/start-issue/releases/latest/download/start-issue.sha256 -o "$tmpdir/start-issue.sha256" -(cd "$tmpdir" && shasum -a 256 -c start-issue.sha256) -``` +After bootstrapping the Go command, `start-issue install` performs the same +platform selection and SHA-256 verification before installing the latest POSIX +release binary into `~/.local/bin`. Build and install from source: @@ -55,7 +36,7 @@ Build and install from source: make install ``` -This builds a self-contained `start-issue` script from the modular sources and installs it to `~/.local/bin/start-issue`. +This builds and installs the Go binary to `~/.local/bin/start-issue`. Make sure `~/.local/bin` is in your `PATH`. @@ -110,7 +91,7 @@ flowchart TD F -- yes --> G["Print planned actions
and exit"] F -- no --> H["Create or reuse git worktree"] - H --> I["Run init.sh if enabled"] + H --> I["Run worktree init hook if enabled"] I --> J["Render agent prompt"] J --> K{"Agent selected?"} @@ -123,19 +104,19 @@ flowchart TD ## Internal Architecture -The CLI entrypoint remains `scripts/start-issue`, but the implementation is now split into focused shell modules under `scripts/lib/start_issue/`. -`make build` and `make install` bundle those modules back into a single-file script for distribution and local installation. +The Go entrypoint is `cmd/start-issue`. It owns argument parsing, configuration +resolution, repository and worktree orchestration, self-install/update, output, +and adapter commands for supported agents. `git`, `gh`, and agent CLIs remain +explicit external process boundaries. -- `cli.sh` parses arguments and normalizes flags into workflow state. -- `config.sh` resolves agent, model, and prompt configuration. -- `github.sh` resolves repository context and fetches issue metadata. -- `worktree.sh` plans branch/worktree behavior and runs worktree-side effects. -- `agent.sh` owns agent adapter operations: validation, launch command construction, AI branch naming, and prompt improvement. -- `release.sh` owns release download, checksum, and version-normalization helpers shared by install and update paths. -- `update.sh` owns the self-update workflow and latest-release resolution. -- `output.sh` renders help, status, dry-run output, and session framing. -- `init.sh` owns `start-issue init` plus the user-config onboarding helpers behind `setup`. -- `pipeline.sh` makes the orchestration pipeline explicit. +- Configuration and prompt helpers resolve CLI, environment, project, and user + defaults. +- Repository/worktree helpers fetch issue metadata, plan reuse safely, and run + the optional `init.sh` hook found in a prepared worktree. +- Agent helpers validate adapters, build launch commands, generate AI branch + names, and run Codex human-gate mode. +- Release helpers select platform assets, verify checksums and staged + `--version` output, and atomically install updates. The internal pipeline is now: @@ -146,7 +127,10 @@ The internal pipeline is now: 5. Execute the plan. 6. Launch the selected agent. -The project should keep Bash as long as lifecycle commands, configuration shape, and output needs stay simple enough for shell modules to remain readable. If future work requires nested configuration, richer subcommands such as `resume` or `cleanup`, or more structured machine-readable output, that should be treated as the threshold for evaluating a Python core. +The Go implementation keeps lifecycle commands, configuration shape, and output +in one compiled CLI while retaining external-tool boundaries. Future additions +should preserve the same focused helper boundaries rather than reintroducing a +second runtime implementation. ## CLI Arguments @@ -227,9 +211,11 @@ The workflow: 2. Reads the version of the executable the user is currently running. 3. Normalizes version strings so `1.11.1` and `v1.11.1` compare as equal. 4. If the running version is current or newer than the latest published release, exits `0` with a clear status message. -5. If a newer published release exists, downloads `start-issue` and `start-issue.sha256`, verifies the checksum, and installs the update into the same executable path the user invoked. +5. If a newer published release exists, downloads the matching platform binary and `checksums.txt`, verifies the checksum, and installs the update into the resolved target of the executable the user invoked. -The update workflow works outside a git repository. It requires `gh`, `jq`, and either `curl` or `wget`. +The update workflow works outside a git repository and requires only `gh`. +The Go binary parses release metadata, downloads assets, and verifies checksums +internally. ## Codex Human-Gate @@ -304,6 +290,19 @@ the obsolete `--ask-for-approval` flag). The selected Codex executable is printed in the test output. They do not prove application behavior beyond this human-gate protocol and are intentionally excluded from CI. +### CI sandbox E2E + +Run the deterministic built-binary E2E locally: + +```bash +make e2e-sandbox +``` + +It uses a temporary local git repository plus fake `gh` and Kimi commands, but +real worktree creation, `init.sh`, prompt rendering, model/cwd forwarding, and +dry-run behavior. It needs no network, credentials, or external agent and runs +in the `sandbox-e2e` CI job. + Configuration precedence: 1. Agent: CLI `--agent` / `--no-agent`, then project config, user config, `START_ISSUE_AGENT`, then built-in default `claude` @@ -316,7 +315,9 @@ Claude uses the plugin-native command by default: /task-router:route-task {ISSUE_URL} ``` -Other agents use a portable prompt by default. +Other agents use a portable prompt by default. Kimi is launched from the +worktree directory because current Kimi Code CLI versions do not support the +legacy `--work-dir` option and reject `--yolo` together with `--prompt`. To improve the prompt template used for future development starts, run: @@ -324,7 +325,7 @@ To improve the prompt template used for future development starts, run: start-issue 123 --agent codex --improve-prompt ``` -The command resolves the active prompt template with the normal precedence, fetches the issue as context, asks the selected agent for a complete improved prompt template, and writes a proposal file. It does not overwrite the active prompt. File-backed prompts write next to the source as `*.improved.md` by default; built-in and inline prompts write to `.start-issue/prompt.improved.md`. Use `--prompt-output-file` to choose another proposal path. +The command resolves the active prompt template with the normal precedence, fetches the issue as context, asks the selected agent for a complete improved prompt template, and writes a proposal file. It does not overwrite the active prompt. Markdown prompt files write next to the source as `*.improved.md`; other file names append `.improved`. Built-in and inline prompts write to `.start-issue/prompt.improved.md`. Use `--prompt-output-file` to choose another proposal path. Prompt templates support: @@ -354,32 +355,37 @@ Optional dependency for Zellij support: ## Requirements -- `bash` - `git` - `gh` CLI with authenticated GitHub session -- `jq` - selected agent CLI unless `--agent none` or `--dry-run` is used -The `curl | bash` installer and self-update workflow need `bash` plus either `curl` or `wget`. +Building from source additionally requires Go 1.24+. The optional Bash +installer and the manual-install snippet require `bash`, `curl` or `wget`, and +a SHA-256 tool; those tools are not used by `start-issue update`. ## Releases -GitHub Releases are published automatically when a SemVer tag like `v1.12.0` is pushed. The release workflow reruns the test suite, verifies that the tag matches `VERSION` in `scripts/start-issue`, builds the bundled `start-issue` script, and uploads: +GitHub Releases are published automatically when a SemVer tag like `v1.12.0` is pushed. The release workflow reruns the Go test suite and publishes platform-specific binaries with a checksum manifest: -- `start-issue` -- `start-issue.sha256` +- `start-issue-linux-amd64` +- `start-issue-linux-arm64` +- `start-issue-darwin-amd64` +- `start-issue-darwin-arm64` +- `start-issue-windows-amd64.exe` +- `checksums.txt` +- `start-issue` and `start-issue.sha256` (temporary v1 update bridge) To prepare a release locally: ```bash -make release-patch -make release-minor -make release-major +make test +git tag v2.0.0 +git push origin v2.0.0 ``` Before preparing a release, add user-facing changes under `## [Unreleased]` in `CHANGELOG.md`. -Each command requires a clean worktree, bumps `VERSION`, moves the `CHANGELOG.md` unreleased entries under the new version and date, runs `make test` and `make build`, creates a local commit like `Release v1.12.0`, and creates the matching annotated git tag. +Create releases from a clean worktree after `make test` and `make build` pass. The tag is the source of the published binary version. Publish the prepared release with: diff --git a/README.ru.md b/README.ru.md index d362085..9ae5107 100644 --- a/README.ru.md +++ b/README.ru.md @@ -19,34 +19,27 @@ Установить последний опубликованный релиз: ```bash -curl -fsSL https://raw.githubusercontent.com/dapi/start-issue/master/install.sh | bash +go install github.com/dapi/start-issue/v2/cmd/start-issue@latest ``` -Скрипт установки скачивает asset из последнего GitHub Release в `~/.local/bin/start-issue` по умолчанию. - -Для диагностики, если установка на машине подвисает: - -```bash -curl -fsSL https://raw.githubusercontent.com/dapi/start-issue/master/install.sh | bash -s -- --debug -``` - -Этот режим включает трассировку shell и подробный вывод `curl` или `wget`, чтобы было видно, на каком шаге всё остановилось. +Опубликованные релизы содержат бинарники для конкретной платформы и файл +`checksums.txt`. После первоначальной установки через Go команда +`start-issue install` самостоятельно выбирает бинарник для текущей POSIX-платформы, +проверяет SHA-256 и устанавливает его в `~/.local/bin/start-issue`. Ручная установка: -```bash -mkdir -p ~/.local/bin -curl -fsSL https://github.com/dapi/start-issue/releases/latest/download/start-issue -o ~/.local/bin/start-issue -chmod +x ~/.local/bin/start-issue -``` - -При желании можно проверить checksum: - ```bash tmpdir="$(mktemp -d)" -curl -fsSL https://github.com/dapi/start-issue/releases/latest/download/start-issue -o "$tmpdir/start-issue" -curl -fsSL https://github.com/dapi/start-issue/releases/latest/download/start-issue.sha256 -o "$tmpdir/start-issue.sha256" -(cd "$tmpdir" && shasum -a 256 -c start-issue.sha256) +os="$(uname -s | tr '[:upper:]' '[:lower:]')" +arch="$(uname -m)" +case "$arch" in x86_64) arch=amd64 ;; arm64|aarch64) arch=arm64 ;; esac +asset="start-issue-${os}-${arch}" +curl -fsSL "https://github.com/dapi/start-issue/releases/latest/download/${asset}" -o "$tmpdir/$asset" +curl -fsSL https://github.com/dapi/start-issue/releases/latest/download/checksums.txt -o "$tmpdir/checksums.txt" +(cd "$tmpdir" && grep -E " [*]?${asset}$" checksums.txt | shasum -a 256 -c -) +mkdir -p ~/.local/bin +install -m 0755 "$tmpdir/$asset" ~/.local/bin/start-issue ``` Сборка и установка из исходников: @@ -55,7 +48,7 @@ curl -fsSL https://github.com/dapi/start-issue/releases/latest/download/start-is make install ``` -Команда собирает self-contained `start-issue` из модульных исходников и устанавливает его в `~/.local/bin/start-issue`. +Команда собирает Go-бинарник `start-issue` из исходников и устанавливает его в `~/.local/bin/start-issue`. Убедитесь, что `~/.local/bin` есть в вашем `PATH`. @@ -144,6 +137,19 @@ test/e2e/human-gate.sh --scenario human-gate в test output. Они не доказывают поведение приложения за пределами human-gate protocol и намеренно не входят в CI. +### CI sandbox E2E + +Детерминированный E2E для собранного бинарника можно запустить локально: + +```bash +make e2e-sandbox +``` + +Он использует временный локальный git-репозиторий и fake-команды `gh` и Kimi, +но реально проверяет создание worktree, `init.sh`, рендер prompt, передачу +model/cwd и dry-run. Сеть, credentials и внешние агенты не нужны; в CI это +отдельный job `sandbox-e2e`. + ## Использование ```bash @@ -183,7 +189,7 @@ flowchart TD F -- yes --> G["Напечатать план
и выйти"] F -- no --> H["Создать или переиспользовать
git worktree"] - H --> I["Запустить init.sh
если включено"] + H --> I["Запустить hook инициализации
если включен"] I --> J["Сформировать prompt
для agent"] J --> K{"Agent выбран?"} @@ -196,19 +202,16 @@ flowchart TD ## Внутренняя архитектура -CLI entrypoint остается `scripts/start-issue`, но реализация теперь разбита на специализированные shell-модули в `scripts/lib/start_issue/`. -`make build` и `make install` собирают эти модули обратно в single-file script для дистрибуции и локальной установки. +CLI entrypoint — `cmd/start-issue`; runtime, build и тесты реализованы на Go. +`make build` и `make install` собирают и устанавливают Go-бинарник. -- `cli.sh` парсит аргументы и нормализует флаги в состояние workflow. -- `config.sh` разрешает конфигурацию agent, model и prompt. -- `github.sh` определяет контекст репозитория и получает metadata issue. -- `worktree.sh` планирует поведение branch/worktree и выполняет worktree-side effects. -- `agent.sh` владеет операциями agent adapter: validation, сборка launch command, AI branch naming и prompt improvement. -- `release.sh` владеет download/checksum/version-normalization helper-логикой, общей для install и update paths. -- `update.sh` владеет workflow self-update и определением latest release. -- `output.sh` рендерит help, status, dry-run output и session framing. -- `init.sh` владеет `start-issue init` и helper-логикой user-config onboarding за `setup`. -- `pipeline.sh` делает orchestration pipeline явным. +- Helpers конфигурации и prompt разрешают CLI, environment, project и user defaults. +- Helpers repository/worktree получают metadata issue, безопасно планируют reuse и + запускают optional hook `init.sh` внутри подготовленной worktree. +- Helpers agent adapter валидируют agent, строят launch commands, генерируют AI + branch names и выполняют Codex human-gate mode. +- Helpers release выбирают platform assets, проверяют checksum и staged + `--version`, затем атомарно устанавливают update. Внутренний pipeline теперь такой: @@ -219,7 +222,10 @@ CLI entrypoint остается `scripts/start-issue`, но реализация 5. Execute the plan. 6. Launch the selected agent. -Bash стоит сохранять, пока lifecycle commands, shape конфигурации и требования к output остаются достаточно простыми для читаемых shell-модулей. Если будущие задачи потребуют nested configuration, более богатых subcommands вроде `resume` или `cleanup`, или структурированного machine-readable output, это следует считать порогом для оценки Python core. +Go implementation сохраняет lifecycle commands, configuration shape и output в +одном compiled CLI, оставляя `git`, `gh` и agent CLIs внешними process +boundaries. Новые возможности должны сохранять эти focused helper boundaries, +а не возвращать второй runtime. ## Аргументы CLI @@ -300,9 +306,10 @@ Workflow: 2. Читает версию executable, который пользователь запустил. 3. Нормализует версии, поэтому `1.11.1` и `v1.11.1` считаются равными. 4. Если текущая версия уже актуальна или новее последнего опубликованного релиза, команда завершается с кодом `0` и печатает понятный статус. -5. Если опубликован более новый релиз, команда скачивает `start-issue` и `start-issue.sha256`, проверяет checksum и устанавливает обновление в тот же executable path, который был вызван. +5. Если опубликован более новый релиз, команда скачивает бинарник для текущей платформы и `checksums.txt`, проверяет checksum и устанавливает обновление в resolved target executable path, который был вызван. -Workflow обновления работает вне git repository. Для него нужны `gh`, `jq` и либо `curl`, либо `wget`. +Workflow обновления работает вне git repository и требует только `gh`. Go-бинарник +сам разбирает metadata release, скачивает assets и проверяет checksums. Приоритет конфигурации: @@ -316,7 +323,9 @@ Claude по умолчанию использует plugin-native команду /task-router:route-task {ISSUE_URL} ``` -Другие агенты по умолчанию используют portable prompt. +Другие агенты по умолчанию используют portable prompt. Kimi запускается из +директории worktree: актуальные версии Kimi Code CLI не поддерживают старую +опцию `--work-dir` и запрещают сочетать `--yolo` с `--prompt`. Чтобы улучшить prompt template, который будет использоваться для будущих стартов разработки, запустите: @@ -324,7 +333,7 @@ Claude по умолчанию использует plugin-native команду start-issue 123 --agent codex --improve-prompt ``` -Команда выбирает активный prompt template по обычному приоритету, получает issue как контекст, просит выбранного агента вернуть полный улучшенный prompt template и записывает proposal-файл. Активный prompt не перезаписывается. Для prompt-файлов proposal по умолчанию создается рядом с источником как `*.improved.md`; для built-in и inline prompt используется `.start-issue/prompt.improved.md`. Используйте `--prompt-output-file`, чтобы указать другой путь. +Команда выбирает активный prompt template по обычному приоритету, получает issue как контекст, просит выбранного агента вернуть полный улучшенный prompt template и записывает proposal-файл. Активный prompt не перезаписывается. Для Markdown prompt-файлов proposal по умолчанию создается рядом с источником как `*.improved.md`; к остальным именам файлов добавляется `.improved`. Для built-in и inline prompt используется `.start-issue/prompt.improved.md`. Используйте `--prompt-output-file`, чтобы указать другой путь. Prompt templates поддерживают: @@ -354,34 +363,35 @@ Prompt templates поддерживают: ## Требования -- `bash` - `git` - `gh` CLI с авторизованной GitHub session -- `jq` - CLI выбранного агента, если не используется `--agent none` или `--dry-run` -Для `curl | bash` installer и workflow self-update нужны `bash` и либо `curl`, либо `wget`. +Для сборки из исходников дополнительно нужен Go 1.24+. Опциональному Bash +installer и manual-install snippet нужны `bash`, `curl` или `wget` и SHA-256 +tool; `start-issue update` эти инструменты не использует. ## Релизы -GitHub Releases публикуются автоматически, когда в репозиторий пушится SemVer tag вроде `v1.12.0`. Release workflow заново прогоняет тесты, проверяет, что tag совпадает с `VERSION` в `scripts/start-issue`, собирает bundled-скрипт `start-issue` и загружает: +GitHub Releases публикуются автоматически, когда в репозиторий пушится SemVer tag вроде `v2.0.0`. Release workflow заново прогоняет тесты, собирает Go-бинарники для поддерживаемых платформ и загружает: -- `start-issue` -- `start-issue.sha256` +- `start-issue--` (и `.exe` для Windows) +- `checksums.txt` +- `start-issue` и `start-issue.sha256` (временный bridge для обновления с v1) Подготовить релиз локально можно так: ```bash -make release-patch -make release-minor -make release-major +make test +git tag v2.0.0 +git push origin v2.0.0 ``` Перед подготовкой релиза добавьте user-facing изменения в `CHANGELOG.md` под `## [Unreleased]`. -Каждая команда требует чистое рабочее дерево, поднимает `VERSION`, переносит unreleased-записи из `CHANGELOG.md` под новую версию и дату, запускает `make test` и `make build`, создает локальный commit вида `Release v1.12.0` и создает matching annotated git tag. +Создавайте релизы из чистого рабочего дерева после успешных `make test` и `make build`. Tag определяет версию публикуемого бинарного файла. -Опубликовать подготовленный релиз: +Опубликовать подготовленный release можно также вместе с веткой: ```bash git push origin master --follow-tags diff --git a/cmd/start-issue/main.go b/cmd/start-issue/main.go new file mode 100644 index 0000000..7695922 --- /dev/null +++ b/cmd/start-issue/main.go @@ -0,0 +1,2260 @@ +package main + +import ( + "bufio" + "bytes" + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "runtime/debug" + "strconv" + "strings" + "syscall" + "time" + "unicode/utf8" +) + +// version is set by release builds with -ldflags. go install records the +// module version in build metadata instead, which runningVersion reads below. +var version string + +// sourceVersion identifies direct development builds that were not made by +// Make or go install. Release and source builds inject/record their version. +const sourceVersion = "dev" + +const defaultReleaseRepository = "dapi/start-issue" + +var gitDescribeSuffix = regexp.MustCompile(`^(.*?)(?:-[0-9]+-g[0-9a-fA-F]+(?:-dirty)?|-dirty)$`) + +func runningVersion() string { + if version != "" { + return strings.TrimPrefix(version, "v") + } + info, ok := debug.ReadBuildInfo() + if !ok { + return sourceVersion + } + return versionFromBuildInfo(info) +} + +func versionFromBuildInfo(info *debug.BuildInfo, fallback ...string) string { + defaultVersion := sourceVersion + if len(fallback) > 0 { + defaultVersion = fallback[0] + } + if info != nil && info.Main.Version != "" && info.Main.Version != "(devel)" { + return strings.TrimPrefix(info.Main.Version, "v") + } + return defaultVersion +} + +type options struct { + repo, base, worktreeDir, agent, model, promptFile, prompt, command string + promptOutput, worktreeDirSource string + issue string + dryRun, noInit, flat, ai, improvePrompt, humanGate, project, user, force bool + mode string +} + +type issue struct { + Title, Body string + Labels []issueLabel +} + +type issueLabel struct { + Name string `json:"name"` +} + +// UnmarshalJSON preserves the shell implementation's treatment of a GitHub +// issue with no body: GitHub represents it as null, while the CLI uses an +// empty string when rendering the prompt. +func (in *issue) UnmarshalJSON(data []byte) error { + var value struct { + Title string `json:"title"` + Body *string `json:"body"` + Labels []issueLabel `json:"labels"` + } + if err := json.Unmarshal(data, &value); err != nil { + return err + } + in.Title = value.Title + in.Body = "" + if value.Body != nil { + in.Body = *value.Body + } + in.Labels = value.Labels + return nil +} + +type exitError struct { + code int + err error +} + +func (e exitError) Error() string { return e.err.Error() } + +func (e exitError) Unwrap() error { return e.err } + +type promptFileNotFoundError struct { + path string + cause error +} + +func (e promptFileNotFoundError) Error() string { + return fmt.Sprintf("Prompt file not found: %s", e.path) +} + +func (e promptFileNotFoundError) Unwrap() error { return e.cause } + +func main() { + o, err := parse(os.Args[1:]) + if err != nil { + die(err) + } + printBanner() + if o.mode != "" { + if err := runMode(o); err != nil { + die(err) + } + return + } + if o.issue == "" { + if err := runMissingIssue(o); err != nil { + die(err) + } + return + } + if err := run(o); err != nil { + die(err) + } +} + +func parse(args []string) (options, error) { + o := options{worktreeDir: os.Getenv("START_ISSUE_WORKTREE_DIR")} + if o.worktreeDir != "" { + o.worktreeDirSource = "START_ISSUE_WORKTREE_DIR" + } + var err error + for len(args) > 0 { + a := args[0] + args = args[1:] + value := func() (string, error) { + if len(args) == 0 || strings.HasPrefix(args[0], "-") { + return "", fmt.Errorf("%s requires a value.", a) + } + v := args[0] + args = args[1:] + if v == "" { + return "", fmt.Errorf("%s requires a value.", a) + } + return v, nil + } + switch a { + case "--help", "-h": + usage() + os.Exit(0) + case "--version", "-v": + fmt.Printf("start-issue v%s\n", runningVersion()) + os.Exit(0) + case "--repo", "-r": + o.repo, err = value() + case "--base", "-b": + o.base, err = value() + case "--worktree-dir", "-w": + o.worktreeDir, err = value() + if err == nil { + o.worktreeDirSource = "CLI" + } + case "--agent": + o.agent, err = value() + case "--model": + o.model, err = value() + case "--prompt-file": + o.promptFile, err = value() + case "--prompt": + o.prompt, err = value() + case "--command", "-c": + o.command, err = value() + case "--no-agent", "--no-claude": + o.agent = "none" + case "--dry-run": + o.dryRun = true + case "--no-init": + o.noInit = true + case "--flat": + o.flat = true + case "--ai": + o.ai = true + case "--improve-prompt": + o.improvePrompt = true + case "--prompt-output-file": + o.promptOutput, err = value() + case "--human-gate": + o.humanGate = true + case "--project": + o.project = true + case "--user": + o.user = true + case "--force": + o.force = true + case "init", "setup", "--setup", "update", "--update", "install", "--install": + mode := strings.TrimLeft(a, "-") + if o.mode != "" { + return o, fmt.Errorf("Use only one command mode; got %s and %s.", o.mode, mode) + } + o.mode = mode + case "--human-gate-help": + humanGateHelp() + os.Exit(0) + default: + if strings.HasPrefix(a, "-") { + return o, fmt.Errorf("Unknown option: %s. Use --help for usage.", a) + } + if o.issue != "" { + return o, fmt.Errorf("Unexpected argument: %s", a) + } + o.issue = a + } + if err != nil { + return o, err + } + } + if o.prompt != "" && o.promptFile != "" { + return o, errors.New("Use either --prompt-file or --prompt, not both.") + } + if o.project && o.user { + return o, errors.New("Use either --project or --user, not both.") + } + if (o.project || o.user || o.force) && o.mode != "init" { + return o, errors.New("--project, --user, and --force are only valid with init.") + } + if o.mode != "" && o.issue != "" { + return o, fmt.Errorf("Use either %s or , not both.", o.mode) + } + if o.worktreeDir == "" && o.mode == "" { + home, err := userHomeDir() + if err != nil { + return o, fmt.Errorf("default worktree directory requires a home directory; set --worktree-dir or START_ISSUE_WORKTREE_DIR: %w", err) + } + o.worktreeDir = filepath.Join(home, "worktrees") + o.worktreeDirSource = "built-in default" + } + return o, nil +} + +func userHomeDir() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + if strings.TrimSpace(home) == "" || !filepath.IsAbs(home) { + return "", errors.New("home directory is unavailable") + } + return home, nil +} + +func run(o options) error { + return runWithReader(o, bufio.NewReader(os.Stdin)) +} + +func runWithReader(o options, reader *bufio.Reader) error { + onboardingErr := maybeRunFirstRunOnboarding(o.dryRun, o.command, reader) + if err := need("git"); err != nil { + return err + } + if onboardingErr != nil { + return onboardingErr + } + root, err := output("git", "rev-parse", "--show-toplevel") + if err != nil { + return errors.New("Not in a git repository") + } + root = strings.TrimSpace(root) + agent, agentSource, err := resolveAgent(root, o.agent) + if err != nil { + return err + } + if o.humanGate && agent != "codex" { + return fmt.Errorf("--human-gate requires agent 'codex'. Current agent: %s.", agent) + } + if o.improvePrompt && agent == "none" { + return errors.New("--improve-prompt requires an agent. Use --agent claude, codex, kimi, or pi.") + } + if agent != "none" && !o.dryRun { + if err := need(agent); err != nil { + return fmt.Errorf("%s CLI not found. Install it or use --agent none.", agent) + } + } + model, modelSource, err := resolveModel(root, o.model) + if err != nil { + return err + } + prompt, promptSource, promptLocation, promptFile, err := resolvePrompt(root, agent, o) + if err != nil { + return err + } + number, repo, err := parseIssue(o.issue, o.repo) + if err != nil { + return err + } + if repo == "" { + repo, err = detectRepo() + if err != nil { + return err + } + } + if o.base == "" { + o.base = detectBase() + } + if err := checkGitHubAccess(); err != nil { + return err + } + data, err := output("gh", "api", fmt.Sprintf("repos/%s/issues/%s", repo, number)) + if err != nil { + return fmt.Errorf("Issue #%s not found in %s", number, repo) + } + var in issue + if err = json.Unmarshal([]byte(data), &in); err != nil { + return err + } + labels := []string{} + for _, l := range in.Labels { + labels = append(labels, l.Name) + } + if o.improvePrompt { + return improvePrompt(root, agent, model, prompt, promptSource, promptFile, o, in, repo, number, strings.Join(labels, ", ")) + } + renameZellijTab(number, o.dryRun) + branch := branchName(number, in.Title, strings.Join(labels, ", ")) + branchSource := "fast" + if o.ai { + if generated, err := aiBranchName(agent, model, root, number, in.Title, strings.Join(labels, ", ")); err == nil && regexp.MustCompile(`^(feature|fix|hotfix|refactor|docs|test|chore)/issue-[0-9]+-[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$`).MatchString(generated) { + branch = generated + branchSource = "ai:" + agent + } else { + fmt.Printf(" Could not generate branch name with %s; using fast fallback\n", agent) + } + } + issueURL := fmt.Sprintf("https://github.com/%s/issues/%s", repo, number) + worktree := canonicalPath(worktreePath(o.worktreeDir, branch, o.flat)) + fmt.Printf("Agent: %s\nAgent source: %s\nModel: %s\nModel source: %s\nWorktree directory: %s (%s)\nPrompt source: %s\nPrompt location: %s\n\n", agent, agentSource, show(model), modelSource, o.worktreeDir, o.worktreeDirSource, promptSource, promptLocation) + fmt.Printf("🔍 Fetching issue #%s from %s...\n Title: %s\n", number, repo, in.Title) + if len(labels) > 0 { + fmt.Printf(" Labels: %s\n", strings.Join(labels, ", ")) + } + fmt.Printf(" Branch: %s (%s)\n📁 Creating worktree...\n Path: %s\n Base: %s\n", branch, branchSource, worktree, o.base) + branchExists := branchRefExists(branch) + existing := branchWorktree(branch) + recreate := false + if branchExists { + choice := branchConflictChoice(branch, existing, reader) + switch choice { + case "1": + if existing == "" { + return fmt.Errorf("No existing worktree found for branch '%s'. Use 3 to delete and recreate.", branch) + } + if err := validateReusedWorktree(existing, branch); err != nil { + return err + } + if !o.noInit { + runInit(existing, o.dryRun) + } + return launchSelected(o, agent, model, existing, renderIssuePrompt(prompt, issueURL, number, in, labels, repo, branch, existing, o.base)) + case "2": + branch = nextSuffixedBranch(branch) + worktree = canonicalPath(worktreePath(o.worktreeDir, branch, o.flat)) + fmt.Printf(" New branch name: %s\n", branch) + case "3": + if o.dryRun { + if existing != "" { + fmt.Printf(" [DRY-RUN] Would remove worktree: %s\n", existing) + } + fmt.Printf(" [DRY-RUN] Would delete branch: %s\n", branch) + recreate = true + break + } + if err := removeWorktreeAndBranch(existing, branch); err != nil { + return err + } + default: + return errors.New("Cancelled.") + } + } + if !recreate { + if _, err := os.Stat(worktree); err == nil { + pathBranch, registered := worktreeRegistration(worktree) + if o.dryRun { + if !registered { + fmt.Printf(" [DRY-RUN] Worktree path exists but is not a registered worktree; would stop and require manual recovery: %s\n", worktree) + return nil + } + fmt.Printf(" [DRY-RUN] Worktree path exists; would prompt for reuse or delete/recreate: %s\n", worktree) + return nil + } + choice, err := pathConflictChoice(worktree, pathBranch, registered, reader) + if err != nil { + return err + } + switch choice { + case "1": + if err := validateReusedWorktree(worktree, branch); err != nil { + return err + } + if !o.noInit { + runInit(worktree, o.dryRun) + } + return launchSelected(o, agent, model, worktree, renderIssuePrompt(prompt, issueURL, number, in, labels, repo, branch, worktree, o.base)) + case "2": + if err := removeWorktreeAndBranch(worktree, strings.TrimPrefix(pathBranch, "refs/heads/")); err != nil { + return err + } + default: + return errors.New("Cancelled.") + } + } + } + rendered := renderIssuePrompt(prompt, issueURL, number, in, labels, repo, branch, worktree, o.base) + if o.dryRun { + fmt.Printf(" [DRY-RUN] Would run: git worktree add -b %s %s %s\n", branch, worktree, o.base) + if o.humanGate { + return humanGate(model, worktree, rendered, true) + } + return launchSelected(options{dryRun: true}, agent, model, worktree, rendered) + } + if err := os.MkdirAll(filepath.Dir(worktree), 0755); err != nil { + return err + } + _ = command("git", "fetch", "origin", o.base, "--quiet") + if err := command("git", "worktree", "add", "-b", branch, worktree, "origin/"+o.base); err != nil { + if err = command("git", "worktree", "add", "-b", branch, worktree, o.base); err != nil { + return errors.New("Failed to create worktree") + } + } + if !o.noInit { + runInit(worktree, false) + } + return launchSelected(o, agent, model, worktree, rendered) +} + +func runInit(worktree string, dryRun bool) { + if init := filepath.Join(worktree, "init.sh"); fileExists(init) { + if dryRun { + fmt.Printf(" [DRY-RUN] Would run: %s\n", init) + return + } + if err := commandAt(worktree, "bash", "./init.sh"); err != nil { + fmt.Println("Warning: init.sh exited with non-zero code") + } + } +} + +func renameZellijTab(number string, dryRun bool) { + if _, err := exec.LookPath("zellij-tab-status"); err != nil { + if dryRun { + fmt.Println(" [DRY-RUN] Would skip zellij tab rename: zellij-tab-status not found") + } + return + } + if dryRun { + fmt.Printf(" [DRY-RUN] Would run: zellij-tab-status --set-name #%s\n", number) + return + } + if err := command("zellij-tab-status", "--set-name", "#"+number); err != nil { + fmt.Println("Warning: Could not rename zellij tab with zellij-tab-status") + } +} + +func fileExists(path string) bool { _, err := os.Stat(path); return err == nil } +func maybeRunFirstRunOnboarding(dryRun bool, command string, reader *bufio.Reader) error { + home, err := userHomeDir() + if err != nil { + return nil + } + dir := filepath.Join(home, ".config", "start-issue") + if _, err := os.Stat(dir); err == nil { + return nil + } else if !os.IsNotExist(err) { + return err + } + fmt.Println("Configuration is not initialized yet.") + fmt.Println() + fmt.Println("Usage: start-issue [options]") + fmt.Println() + fmt.Print("No start-issue user configuration found. Run setup now? [Y/n] ") + if dryRun { + fmt.Printf("[DRY-RUN] Would create first-run configuration marker: %s\n", dir) + return nil + } + answer, err := reader.ReadString('\n') + if err != nil { + return errors.New("No response received.") + } + switch strings.TrimSpace(strings.ToLower(answer)) { + case "", "y", "yes": + return setupMode(home, false, command, reader) + case "n", "no": + return os.MkdirAll(dir, 0755) + default: + return fmt.Errorf("Invalid response: %s. Use y or n.", strings.TrimSpace(answer)) + } +} + +func runMissingIssue(o options) error { + if o.improvePrompt { + return errors.New("--improve-prompt requires . Example: start-issue 123 --improve-prompt") + } + root, _ := output("git", "rev-parse", "--show-toplevel") + root = strings.TrimSpace(root) + if err := maybeRunFirstRunOnboarding(o.dryRun, o.command, bufio.NewReader(os.Stdin)); err != nil { + return err + } + agent, agentSource, err := resolveAgent(root, o.agent) + if err != nil { + return err + } + model, modelSource, err := resolveModel(root, o.model) + if err != nil { + return err + } + _, promptSource, promptLocation, _, err := resolvePrompt(root, agent, o) + if err != nil { + return err + } + usage() + fmt.Println("Current configuration:") + fmt.Printf(" Agent: %s\n Agent source: %s\n Model: %s\n Model source: %s\n Prompt source: %s\n Prompt location: %s\n Worktree dir: %s (%s)\n", agent, agentSource, show(model), modelSource, promptSource, promptLocation, o.worktreeDir, o.worktreeDirSource) + return errors.New("missing issue URL or issue number") +} + +func runMode(o options) error { + if o.mode == "update" { + return updateMode(o) + } + root, _ := output("git", "rev-parse", "--show-toplevel") + root = strings.TrimSpace(root) + if o.mode == "install" { + return installMode(o.dryRun) + } + if o.mode == "setup" { + home, err := userHomeDir() + if err != nil { + return err + } + return setupMode(home, o.dryRun, o.command, bufio.NewReader(os.Stdin)) + } + if o.mode != "init" { + return fmt.Errorf("Unknown command mode: %s", o.mode) + } + home := "" + if !o.project { + var err error + home, err = userHomeDir() + if err != nil { + return err + } + } + dir, err := selectInitDir(root, home, o, bufio.NewReader(os.Stdin)) + if err != nil { + return err + } + agent, agentSource, err := resolveInitAgent(filepath.Join(dir, "agent"), o.agent, o.force) + if err != nil { + return err + } + model, err := resolveInitModel(filepath.Join(dir, "model"), o.model, o.force) + if err != nil { + return err + } + prompt := o.prompt + promptSource := "CLI --prompt" + if o.promptFile != "" { + b, err := readPromptFile(o.promptFile) + if err != nil { + return err + } + prompt = string(b) + promptSource = "CLI --prompt-file: " + o.promptFile + } + if prompt == "" { + if agent == "claude" { + prompt = claudeDefaultPrompt(o.command) + promptSource = "built-in Claude command" + } else { + prompt = defaultPortablePrompt() + promptSource = "built-in portable prompt" + } + } + plan := initConfigPlan{ + dir: dir, + agent: agent, + agentSource: agentSource, + model: model, + prompt: prompt, + promptSource: promptSource, + force: o.force, + } + if o.dryRun { + return plan.printDryRun() + } + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + if err := plan.apply(); err != nil { + return err + } + fmt.Printf("Wrote start-issue configuration: %s\n", dir) + return nil +} + +func selectInitDir(root, home string, o options, reader *bufio.Reader) (string, error) { + if o.project { + if root == "" { + return "", errors.New("--project requires a git repository") + } + return filepath.Join(root, ".start-issue"), nil + } + userDir := filepath.Join(home, ".config", "start-issue") + if o.user { + return userDir, nil + } + if root == "" { + fmt.Printf("Initialize start-issue configuration:\n 1) User config (%s)\nChoice [1]: ", userDir) + choice, err := reader.ReadString('\n') + if err != nil { + return "", errors.New("No init scope selected. Use --user outside a git repository.") + } + switch strings.TrimSpace(strings.ToLower(choice)) { + case "", "1", "u", "user": + return userDir, nil + default: + return "", errors.New("Project config requires a git repository. Use --user outside a git repository.") + } + } + + projectDir := filepath.Join(root, ".start-issue") + fmt.Printf("Initialize start-issue configuration:\n 1) Project config (%s)\n 2) User config (%s)\nChoice [1/2]: ", projectDir, userDir) + choice, err := reader.ReadString('\n') + if err != nil { + return "", errors.New("No init scope selected. Use --project or --user.") + } + switch strings.TrimSpace(strings.ToLower(choice)) { + case "1", "p", "project": + return projectDir, nil + case "2", "u", "user": + return userDir, nil + default: + return "", fmt.Errorf("Invalid init scope: %s. Use --project or --user.", strings.TrimSpace(choice)) + } +} + +type initConfigPlan struct { + dir, agent, agentSource, model, prompt, promptSource string + force bool +} + +func resolveInitAgent(path, cli string, force bool) (string, string, error) { + if !force { + agent, err := configAgent(path, "") + if err != nil { + return "", "", err + } + if agent != "" { + return agent, path + " (existing)", nil + } + } + if cli != "" { + if err := validateAgent(cli); err != nil { + return "", "", err + } + return cli, "CLI", nil + } + return "claude", "built-in default", nil +} + +func resolveInitModel(path, cli string, force bool) (string, error) { + if !force { + model, err := configValue(path, "model") + if err != nil { + return "", err + } + if model != "" { + return model, nil + } + } + if cli == "" { + return "", nil + } + model := strings.TrimSpace(cli) + if model == "" { + return "", errors.New("Model config is empty. Remove the empty model config or set a value.") + } + return model, nil +} + +func (p initConfigPlan) paths() (agent, model, prompt string) { + return filepath.Join(p.dir, "agent"), filepath.Join(p.dir, "model"), filepath.Join(p.dir, "prompt.md") +} + +func (p initConfigPlan) printDryRun() error { + agentPath, modelPath, promptPath := p.paths() + fmt.Printf("[DRY-RUN] Would create configuration in: %s\n", p.dir) + fmt.Printf("[DRY-RUN] Agent: %s (%s)\n", p.agent, p.agentSource) + fmt.Printf("[DRY-RUN] Prompt source: %s\n", p.promptSource) + if err := printPlannedWrite("agent config", agentPath, p.force); err != nil { + return err + } + if p.model != "" { + if err := printPlannedWrite("model config", modelPath, p.force); err != nil { + return err + } + } else if p.force { + exists, err := pathExists(modelPath) + if err != nil { + return err + } + if exists { + fmt.Printf("[DRY-RUN] Would remove model config: %s\n", modelPath) + } else { + fmt.Printf("[DRY-RUN] No model config to write (built-in default: unset)\n") + } + } + return printPlannedWrite("prompt template", promptPath, p.force) +} + +func printPlannedWrite(label, path string, force bool) error { + exists, err := pathExists(path) + if err != nil { + return err + } + if exists && !force { + fmt.Printf("[DRY-RUN] %s already exists, keeping: %s\n", label, path) + return nil + } + fmt.Printf("[DRY-RUN] Would write %s: %s\n", label, path) + return nil +} + +func (p initConfigPlan) apply() error { + agentPath, modelPath, promptPath := p.paths() + if err := writeConfig(agentPath, p.agent+"\n", p.force); err != nil { + return err + } + if p.model != "" { + if err := writeConfig(modelPath, p.model+"\n", p.force); err != nil { + return err + } + } else if p.force { + if err := os.Remove(modelPath); err != nil && !os.IsNotExist(err) { + return err + } + } + return writeConfig(promptPath, p.prompt+"\n", p.force) +} + +func pathExists(path string) (bool, error) { + _, err := os.Stat(path) + if err == nil { + return true, nil + } + if os.IsNotExist(err) { + return false, nil + } + return false, err +} + +func setupMode(home string, dryRun bool, command string, reader *bufio.Reader) error { + dir := filepath.Join(home, ".config", "start-issue") + if !dryRun { + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + } + fmt.Println("Select default agent: 1) claude 2) codex 3) kimi 4) pi 5) skip") + fmt.Print("Choice: ") + choice, err := reader.ReadString('\n') + if err != nil { + return errors.New("No setup agent selected.") + } + choice = strings.TrimSpace(choice) + normalizedChoice := strings.ToLower(choice) + agents := map[string]string{"1": "claude", "claude": "claude", "2": "codex", "codex": "codex", "3": "kimi", "kimi": "kimi", "4": "pi", "pi": "pi"} + agent, selected := agents[normalizedChoice] + skipAgent := normalizedChoice == "" || normalizedChoice == "5" || normalizedChoice == "skip" + if !selected && !skipAgent { + return errors.New("invalid setup choice") + } + if skipAgent { + agent = "claude" + } + prompt := defaultSetupPrompt(agent, command) + fmt.Printf("\nDefault prompt preview:\n%s\n\n", prompt) + fmt.Print("Save a default prompt? [Y/n] ") + answer, err := reader.ReadString('\n') + if err != nil { + return errors.New("No response received.") + } + savePrompt := false + switch strings.TrimSpace(strings.ToLower(answer)) { + case "", "y", "yes": + savePrompt = true + case "n", "no": + default: + return fmt.Errorf("Invalid response: %s. Use y or n.", strings.TrimSpace(answer)) + } + if dryRun { + fmt.Printf("[DRY-RUN] Would create configuration in: %s\n", dir) + if savePrompt { + fmt.Printf("[DRY-RUN] Would write prompt template: %s\n", filepath.Join(dir, "prompt.md")) + } else { + fmt.Printf("[DRY-RUN] Would remove prompt template: %s\n", filepath.Join(dir, "prompt.md")) + } + if skipAgent { + fmt.Printf("[DRY-RUN] Would remove agent config: %s\n", filepath.Join(dir, "agent")) + } else { + fmt.Printf("[DRY-RUN] Would write agent config: %s\n", filepath.Join(dir, "agent")) + } + return nil + } + if savePrompt { + if err := writeConfig(filepath.Join(dir, "prompt.md"), prompt+"\n", true); err != nil { + return err + } + } else if err := removeConfigIfExists(filepath.Join(dir, "prompt.md")); err != nil { + return fmt.Errorf("remove prompt template: %w", err) + } + if skipAgent { + if err := removeConfigIfExists(filepath.Join(dir, "agent")); err != nil { + return fmt.Errorf("remove agent config: %w", err) + } + } else if err := writeConfig(filepath.Join(dir, "agent"), agent+"\n", true); err != nil { + return err + } + fmt.Printf("Wrote start-issue configuration: %s\n", dir) + return nil +} + +func removeConfigIfExists(path string) error { + err := os.Remove(path) + if err != nil && !os.IsNotExist(err) { + return err + } + return nil +} + +func defaultSetupPrompt(agent, command string) string { + if agent == "claude" { + return claudeDefaultPrompt(command) + } + return defaultPortablePrompt() +} + +func installMode(dryRun bool) error { + name, err := releaseAssetName(runtime.GOOS, runtime.GOARCH) + if err != nil { + return err + } + if runtime.GOOS == "windows" { + return errors.New("Windows installation is manual: download start-issue-windows-amd64.exe from the latest release") + } + home, err := userHomeDir() + if err != nil { + return err + } + target := filepath.Join(home, ".local", "bin", "start-issue") + if dryRun { + fmt.Printf("[DRY-RUN] Would download %s, verify checksums.txt, and install: %s\n", name, target) + return nil + } + if err := checkGitHubAccess(); err != nil { + return err + } + data, err := output("gh", "api", "repos/"+releaseRepository()+"/releases/latest") + if err != nil { + return errors.New("Could not fetch the latest start-issue release") + } + var release githubRelease + if err := json.Unmarshal([]byte(data), &release); err != nil { + return err + } + assetURL, checksumURL := release.assetURLs(name) + if assetURL == "" || checksumURL == "" { + return fmt.Errorf("latest release does not contain %s and checksums.txt", name) + } + binary, err := download(assetURL) + if err != nil { + return err + } + checksums, err := download(checksumURL) + if err != nil { + return err + } + if !validChecksum(binary, name, string(checksums)) { + return fmt.Errorf("checksum verification failed for %s", name) + } + if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { + return err + } + if err := installVerifiedUpdate(target, binary, release.TagName); err != nil { + return err + } + fmt.Printf("Installed start-issue v%s at: %s\n", strings.TrimPrefix(release.TagName, "v"), target) + return nil +} + +func installBinary(target string, binary []byte) error { + temporary, err := stageBinary(target, binary) + if err != nil { + return err + } + defer os.Remove(temporary) + if err := os.Rename(temporary, target); err != nil { + return err + } + return nil +} + +func writeConfig(path, content string, force bool) error { + if _, err := os.Stat(path); err == nil && !force { + return nil + } + return os.WriteFile(path, []byte(content), 0644) +} + +func configAgent(path, fallback string) (string, error) { + b, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return fallback, nil + } + return "", err + } + agent := first(string(b)) + if agent == "" { + return "", errors.New("Agent config is empty. Remove the empty agent config or set a value.") + } + switch agent { + case "claude", "codex", "kimi", "pi", "none": + return agent, nil + default: + return "", fmt.Errorf("Unknown agent: %s. Valid agents: claude, codex, kimi, pi, none.", agent) + } +} + +func configValue(path, name string) (string, error) { + b, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return "", nil + } + return "", err + } + value := first(string(b)) + if value == "" { + return "", fmt.Errorf("%s config is empty. Remove the empty %s config or set a value.", strings.ToUpper(name[:1])+name[1:], name) + } + return value, nil +} + +func updateMode(o options) error { + assetName, err := releaseAssetName(runtime.GOOS, runtime.GOARCH) + if err != nil { + return err + } + if runtime.GOOS == "windows" { + fmt.Println("Windows update is manual: download start-issue-windows-amd64.exe from the latest release and replace the executable on PATH.") + return nil + } + if err := checkGitHubAccess(); err != nil { + return err + } + data, err := output("gh", "api", "repos/"+releaseRepository()+"/releases/latest") + if err != nil { + return errors.New("Could not fetch the latest start-issue release") + } + var release githubRelease + if err := json.Unmarshal([]byte(data), &release); err != nil { + return fmt.Errorf("decode latest release: %w", err) + } + if strings.TrimSpace(release.TagName) == "" { + return errors.New("latest release response is missing tag_name") + } + if compareVersions(runningVersion(), release.TagName) >= 0 { + fmt.Printf("start-issue is already up to date (%s).\n", runningVersion()) + return nil + } + assetURL, checksumURL := release.assetURLs(assetName) + if assetURL == "" || checksumURL == "" { + return fmt.Errorf("latest release does not contain %s and checksums.txt", assetName) + } + if o.dryRun { + target, err := runningExecutablePath() + if err != nil { + return err + } + fmt.Printf("[DRY-RUN] Would download %s, verify checksums.txt, and replace: %s\n", assetName, target) + return nil + } + binary, err := download(assetURL) + if err != nil { + return fmt.Errorf("download %s: %w", assetName, err) + } + checksums, err := download(checksumURL) + if err != nil { + return fmt.Errorf("download checksums.txt: %w", err) + } + if !validChecksum(binary, assetName, string(checksums)) { + return fmt.Errorf("checksum verification failed for %s", assetName) + } + target, err := runningExecutablePath() + if err != nil { + return err + } + if err := installVerifiedUpdate(target, binary, release.TagName); err != nil { + return err + } + fmt.Printf("Updated start-issue at: %s\nVersion: start-issue v%s\n", target, strings.TrimPrefix(release.TagName, "v")) + return nil +} + +func runningExecutablePath() (string, error) { + target, err := os.Executable() + if err != nil { + return "", err + } + resolved, err := filepath.EvalSymlinks(target) + if err != nil { + return "", fmt.Errorf("resolve running executable %q: %w", target, err) + } + return resolved, nil +} + +func installVerifiedUpdate(target string, binary []byte, expectedTag string) error { + temporary, err := stageBinary(target, binary) + if err != nil { + return err + } + defer os.Remove(temporary) + if err := verifyStagedBinary(temporary, expectedTag); err != nil { + return err + } + if err := os.Rename(temporary, target); err != nil { + return err + } + return nil +} + +func stageBinary(target string, binary []byte) (string, error) { + temporary, err := os.CreateTemp(filepath.Dir(target), "."+filepath.Base(target)+".new-*") + if err != nil { + return "", err + } + path := temporary.Name() + cleanup := func(err error) (string, error) { + _ = temporary.Close() + _ = os.Remove(path) + return "", err + } + if err := temporary.Chmod(0755); err != nil { + return cleanup(err) + } + if _, err := temporary.Write(binary); err != nil { + return cleanup(err) + } + if err := temporary.Close(); err != nil { + _ = os.Remove(path) + return "", err + } + return path, nil +} + +func verifyStagedBinary(path, expectedTag string) error { + result, err := exec.Command(path, "--version").Output() + if err != nil { + return fmt.Errorf("staged update failed version verification: %w", err) + } + got := strings.TrimSpace(string(result)) + want := "start-issue v" + strings.TrimPrefix(expectedTag, "v") + if got != want { + return fmt.Errorf("staged update version %q does not match expected release %q", got, expectedTag) + } + return nil +} + +type githubRelease struct { + TagName string `json:"tag_name"` + Assets []struct { + Name string `json:"name"` + URL string `json:"browser_download_url"` + } `json:"assets"` +} + +func releaseRepository() string { + if repository := strings.TrimSpace(os.Getenv("START_ISSUE_REPOSITORY")); repository != "" { + return repository + } + return defaultReleaseRepository +} + +func (r githubRelease) assetURLs(name string) (string, string) { + var assetURL, checksumURL string + for _, asset := range r.Assets { + switch asset.Name { + case name: + assetURL = asset.URL + case "checksums.txt": + checksumURL = asset.URL + } + } + return assetURL, checksumURL +} + +func releaseAssetName(goos, goarch string) (string, error) { + switch goos + "/" + goarch { + case "linux/amd64", "linux/arm64", "darwin/amd64", "darwin/arm64": + return fmt.Sprintf("start-issue-%s-%s", goos, goarch), nil + case "windows/amd64": + return "start-issue-windows-amd64.exe", nil + default: + return "", fmt.Errorf("unsupported release platform %s/%s; supported platforms: linux/amd64, linux/arm64, darwin/amd64, darwin/arm64, windows/amd64", goos, goarch) + } +} + +func download(url string) ([]byte, error) { + response, err := http.Get(url) + if err != nil { + return nil, err + } + defer response.Body.Close() + if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { + return nil, fmt.Errorf("unexpected HTTP status %s", response.Status) + } + return io.ReadAll(response.Body) +} + +func validChecksum(binary []byte, name, manifest string) bool { + want := "" + for _, line := range strings.Split(manifest, "\n") { + fields := strings.Fields(line) + if len(fields) >= 2 && strings.TrimPrefix(fields[1], "*") == name { + want = fields[0] + break + } + } + actual := fmt.Sprintf("%x", sha256.Sum256(binary)) + return want != "" && strings.EqualFold(want, actual) +} + +type semanticVersion struct { + core [3]int + prerelease []string + postTag bool +} + +func parseSemanticVersion(value string) semanticVersion { + value = strings.TrimPrefix(strings.TrimSpace(value), "v") + if build := strings.IndexByte(value, '+'); build >= 0 { + value = value[:build] + } + // Make builds use git describe. Its "--g" suffix, or + // "-dirty" when the checkout is modified exactly at a tag, means this + // source build is newer than the referenced tag, not a SemVer prerelease. + // Preserve that distinction so update never downgrades it. + postTag := false + if matches := gitDescribeSuffix.FindStringSubmatch(value); matches != nil { + value = matches[1] + postTag = true + } + parts := strings.SplitN(value, "-", 2) + core := strings.Split(parts[0], ".") + result := semanticVersion{postTag: postTag} + for index := range result.core { + if index < len(core) { + result.core[index], _ = strconv.Atoi(core[index]) + } + } + if len(parts) == 2 && parts[1] != "" { + result.prerelease = strings.Split(parts[1], ".") + } + return result +} + +func compareVersions(left, right string) int { + a, b := parseSemanticVersion(left), parseSemanticVersion(right) + for index := range a.core { + if a.core[index] < b.core[index] { + return -1 + } + if a.core[index] > b.core[index] { + return 1 + } + } + if len(a.prerelease) == 0 && len(b.prerelease) > 0 { + return 1 + } + if len(a.prerelease) > 0 && len(b.prerelease) == 0 { + return -1 + } + for index := 0; index < len(a.prerelease) && index < len(b.prerelease); index++ { + if result := comparePrereleaseIdentifier(a.prerelease[index], b.prerelease[index]); result != 0 { + return result + } + } + if len(a.prerelease) < len(b.prerelease) { + return -1 + } + if len(a.prerelease) > len(b.prerelease) { + return 1 + } + if a.postTag && !b.postTag { + return 1 + } + if !a.postTag && b.postTag { + return -1 + } + return 0 +} + +func comparePrereleaseIdentifier(left, right string) int { + leftNumber, leftErr := strconv.Atoi(left) + rightNumber, rightErr := strconv.Atoi(right) + if leftErr == nil && rightErr == nil { + return compareInts(leftNumber, rightNumber) + } + if leftErr == nil { + return -1 + } + if rightErr == nil { + return 1 + } + return strings.Compare(left, right) +} + +func compareInts(left, right int) int { + if left < right { + return -1 + } + if left > right { + return 1 + } + return 0 +} + +func resolveAgent(root, cli string) (string, string, error) { + project := "" + if root != "" { + project = filepath.Join(root, ".start-issue", "agent") + } + v, s, e := resolve(cli, project, userConfigPath("agent"), "START_ISSUE_AGENT", "claude") + if e != nil { + return "", "", e + } + if v == "" { + return "", s, errors.New("Agent config is empty. Valid agents: claude, codex, kimi, pi, none.") + } + if err := validateAgent(v); err != nil { + return "", "", err + } + return v, s, nil +} + +func validateAgent(agent string) error { + switch agent { + case "claude", "codex", "kimi", "pi", "none": + return nil + default: + return fmt.Errorf("Unknown agent: %s. Valid agents: claude, codex, kimi, pi, none.", agent) + } +} +func resolveModel(root, cli string) (string, string, error) { + if cli != "" { + cli = strings.TrimSpace(cli) + if cli == "" { + return "", "CLI", errors.New("--model requires a non-empty value.") + } + } + project := "" + if root != "" { + project = filepath.Join(root, ".start-issue", "model") + } + v, s, e := resolve(cli, project, userConfigPath("model"), "START_ISSUE_MODEL", "") + if e == nil && s != "built-in default" && v == "" { + e = errors.New("Model config is empty. Remove the empty model config or set a value.") + } + return v, s, e +} +func resolve(cli, project, user, env, def string) (string, string, error) { + if cli != "" { + return cli, "CLI", nil + } + for _, p := range []string{project, user} { + if p == "" { + continue + } + if b, e := os.ReadFile(p); e == nil { + return first(string(b)), p, nil + } else if !os.IsNotExist(e) { + return "", "", e + } + } + if raw, ok := os.LookupEnv(env); ok && raw != "" { + return strings.TrimSpace(raw), env, nil + } + return def, "built-in default", nil +} +func first(s string) string { + for _, l := range strings.Split(s, "\n") { + if l = strings.TrimSpace(strings.SplitN(l, "#", 2)[0]); l != "" { + return l + } + } + return "" +} + +func userConfigPath(name string) string { + home, err := userHomeDir() + if err != nil { + return "" + } + return filepath.Join(home, ".config", "start-issue", name) +} + +// resolvePrompt returns the rendered template, its display source and location, +// and (only for file-backed prompts) the resolved source file path. +func resolvePrompt(root, agent string, o options) (string, string, string, string, error) { + if o.prompt != "" { + return o.prompt, "CLI --prompt", "inline CLI argument", "", nil + } + if o.promptFile != "" { + b, e := readPromptFile(o.promptFile) + location, locationErr := absolutePath(o.promptFile) + if e != nil { + return "", "", "", "", e + } + return string(b), "CLI --prompt-file: " + o.promptFile, location, location, locationErr + } + if os.Getenv("START_ISSUE_PROMPT_FILE") != "" && os.Getenv("START_ISSUE_PROMPT") != "" { + return "", "", "", "", errors.New("Use either START_ISSUE_PROMPT_FILE or START_ISSUE_PROMPT, not both.") + } + if path := os.Getenv("START_ISSUE_PROMPT_FILE"); path != "" { + b, e := readPromptFile(path) + location, locationErr := absolutePath(path) + if e != nil { + return "", "", "", "", e + } + return string(b), "START_ISSUE_PROMPT_FILE: " + path, location, location, locationErr + } + if value := os.Getenv("START_ISSUE_PROMPT"); value != "" { + return value, "START_ISSUE_PROMPT", "START_ISSUE_PROMPT environment variable", "", nil + } + paths := []string{} + if userConfig := userConfigPath("prompt.md"); userConfig != "" { + paths = append(paths, userConfig) + } + if root != "" { + paths = append([]string{filepath.Join(root, ".start-issue", "prompt.md")}, paths...) + } + for _, path := range paths { + if b, e := readPromptFile(path); e == nil { + return string(b), path, path, path, nil + } else if !errors.Is(e, os.ErrNotExist) { + return "", "", "", "", e + } + } + location, err := os.Executable() + if err != nil { + return "", "", "", "", err + } + if agent == "claude" { + return claudeDefaultPrompt(o.command), "built-in Claude command", location, "", nil + } + return defaultPortablePrompt(), "built-in portable prompt", location, "", nil +} + +func readPromptFile(path string) ([]byte, error) { + b, err := os.ReadFile(path) + if os.IsNotExist(err) { + return nil, promptFileNotFoundError{path: path, cause: err} + } + // Bash command substitution, used by the v1 implementation to read prompt + // files, removes every trailing newline. Keep file-backed prompt sources + // compatible so init subsequently writes exactly one trailing newline. + return bytes.TrimRight(b, "\n"), err +} + +func absolutePath(path string) (string, error) { return filepath.Abs(path) } +func claudeDefaultPrompt(command string) string { + if command != "" { + return command + " {ISSUE_URL}" + } + return "/task-router:route-task {ISSUE_URL}" +} +func defaultPortablePrompt() string { + return `Implement GitHub issue {ISSUE_URL} in this worktree. + +Context: +- Repo: {REPO} +- Issue: #{ISSUE_NUMBER} +- Title: {ISSUE_TITLE} +- Branch: {BRANCH_NAME} +- Worktree: {WORKTREE_PATH} + +Start by reading the issue with gh if needed. Follow repository instructions. Keep changes scoped. Run relevant tests or checks. Summarize changed files and verification before finishing. +If you open a PR for this work, target the base branch {BASE_BRANCH}.` +} +func parseIssue(v, repo string) (string, string, error) { + re := regexp.MustCompile(`^https://github\.com/([^/]+)/([^/]+)/issues/([0-9]+)`) + if m := re.FindStringSubmatch(v); m != nil { + return m[3], m[1] + "/" + m[2], nil + } + if regexp.MustCompile(`^[0-9]+$`).MatchString(v) { + return v, repo, nil + } + return "", "", fmt.Errorf("Invalid issue format: %s. Use issue number or full GitHub URL.", v) +} +func detectRepo() (string, error) { + v, e := output("git", "remote", "get-url", "origin") + if e != nil { + return "", errors.New("Cannot detect repository. No 'origin' remote found. Use --repo flag.") + } + v = strings.TrimSuffix(strings.TrimSpace(v), ".git") + return repoFromRemoteURL(v) +} + +func repoFromRemoteURL(v string) (string, error) { + for _, p := range []string{"git@github.com:", "https://github.com/"} { + if strings.HasPrefix(v, p) { + return strings.TrimPrefix(v, p), nil + } + } + return "", fmt.Errorf("Cannot parse repository from remote URL: %s. Use --repo flag.", v) +} +func detectBase() string { + if v, e := output("git", "symbolic-ref", "refs/remotes/origin/HEAD"); e == nil { + return strings.TrimPrefix(strings.TrimSpace(v), "refs/remotes/origin/") + } + v, _ := output("git", "rev-parse", "--abbrev-ref", "HEAD") + return strings.TrimSpace(v) +} +func branchName(n, title, labels string) string { + kind := "feature" + switch { + case containsAny(labels, "hotfix", "critical", "urgent"): + kind = "hotfix" + case containsAny(labels, "bug", "fix", "bugfix", "error"): + kind = "fix" + case containsAny(labels, "docs", "documentation"): + kind = "docs" + case containsAny(labels, "refactor", "tech-debt", "cleanup", "technical"): + kind = "refactor" + case containsAny(labels, "test", "testing", "tests"): + kind = "test" + case containsAny(labels, "chore", "ci", "build", "infra"): + kind = "chore" + } + slug := slugify(title) + return fmt.Sprintf("%s/issue-%s-%s", kind, n, slug) +} + +func containsAny(s string, terms ...string) bool { + for _, term := range terms { + if strings.Contains(s, term) { + return true + } + } + return false +} +func aiBranchName(agent, model, root, number, title, labels string) (string, error) { + if agent == "none" { + return "", errors.New("no agent") + } + prompt := fmt.Sprintf("Git branch name for issue #%s: %q (labels: %s).\nFormat: {type}/issue-%s-{kebab-case-name}\nTypes: bug/fix -> fix, enhancement -> feature, hotfix -> hotfix, docs -> docs, refactor -> refactor, test -> test, chore -> chore, default -> feature.\nIf the title contains non-English text (for example Cyrillic), transliterate it to English for the kebab-case name.\nStrip leading bracketed process/stage tags (for example [brief], [investigation], [PR-008]) from the kebab-case name; they mark workflow stage. The {type} still comes from the labels above.\nReply with ONLY the branch name.", number, title, labels, number) + args := helperArgs(agent, model, root, prompt) + result, err := helperOutput(agent, root, args) + if err != nil { + return "", err + } + var branch string + for _, line := range strings.Split(result, "\n") { + line = strings.TrimSpace(strings.ReplaceAll(strings.ReplaceAll(line, "`", ""), "\"", "")) + if line != "" { + branch = line + } + } + if branch == "" { + return "", errors.New("empty branch") + } + return branch, nil +} + +func slugify(title string) string { + title = regexp.MustCompile(`^(\[[^]]*\][\s-]*)+`).ReplaceAllString(title, "") + var b strings.Builder + for _, r := range strings.ToLower(title) { + if replacement, ok := cyrillicTransliteration[r]; ok { + b.WriteString(replacement) + continue + } + if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' { + b.WriteRune(r) + } else { + b.WriteByte('-') + } + } + slug := regexp.MustCompile(`-+`).ReplaceAllString(b.String(), "-") + if len(slug) > 40 { + slug = slug[:40] + } + slug = strings.Trim(slug, "-") + if slug == "" { + slug = "work" + } + return slug +} + +var cyrillicTransliteration = map[rune]string{ + 'а': "a", 'б': "b", 'в': "v", 'г': "g", 'д': "d", 'е': "e", 'ё': "yo", 'ж': "zh", 'з': "z", 'и': "i", 'й': "y", 'к': "k", 'л': "l", 'м': "m", 'н': "n", 'о': "o", 'п': "p", 'р': "r", 'с': "s", 'т': "t", 'у': "u", 'ф': "f", 'х': "kh", 'ц': "ts", 'ч': "ch", 'ш': "sh", 'щ': "shch", 'ъ': "", 'ы': "y", 'ь': "", 'э': "e", 'ю': "yu", 'я': "ya", +} + +func render(s string, m map[string]string) string { + // Preserve the Bash implementation's ordered substitutions. In particular, + // placeholders in issue metadata are expanded when their replacement occurs + // later in this sequence. + for _, key := range []string{ + "ISSUE_URL", + "ISSUE_NUMBER", + "ISSUE_TITLE", + "ISSUE_BODY", + "ISSUE_LABELS", + "REPO", + "BRANCH_NAME", + "WORKTREE_PATH", + "BASE_BRANCH", + } { + if value, ok := m[key]; ok { + s = strings.ReplaceAll(s, "{"+key+"}", value) + } + } + return s +} + +func renderIssuePrompt(prompt, issueURL, number string, in issue, labels []string, repo, branch, worktree, base string) string { + return render(prompt, map[string]string{ + "ISSUE_URL": issueURL, + "ISSUE_NUMBER": number, + "ISSUE_TITLE": in.Title, + "ISSUE_BODY": in.Body, + "ISSUE_LABELS": strings.Join(labels, ", "), + "REPO": repo, + "BRANCH_NAME": branch, + "WORKTREE_PATH": worktree, + "BASE_BRANCH": base, + }) +} + +func worktreePath(parent, branch string, flat bool) string { + name := branch + if flat { + name = strings.ReplaceAll(name, "/", "-") + } + return filepath.Join(parent, name) +} + +func branchRefExists(branch string) bool { + _, err := output("git", "show-ref", "--verify", "--quiet", "refs/heads/"+branch) + return err == nil +} + +func nextSuffixedBranch(branch string) string { + for version := 2; ; version++ { + candidate := fmt.Sprintf("%s-v%d", branch, version) + if !branchRefExists(candidate) { + return candidate + } + } +} + +func branchConflictChoice(branch, existing string, reader *bufio.Reader) string { + fmt.Printf("\nBranch '%s' already exists.\n", branch) + if existing != "" { + fmt.Printf(" Existing worktree: %s\n", existing) + } + fmt.Print("\n 1) Use existing worktree and continue\n 2) Create new branch with different name\n 3) Delete branch/worktree and recreate\n 0) Exit\n\nChoice: ") + return readChoice(reader) +} + +func pathConflictChoice(worktree, branch string, registered bool, reader *bufio.Reader) (string, error) { + fmt.Printf("\nWorktree path already exists: %s\n", worktree) + if !registered { + return "", fmt.Errorf("Cannot use worktree path '%s': it exists but is not a registered git worktree. Move or remove the directory manually, then rerun.", worktree) + } + if branch == "" { + fmt.Println(" Registered branch: detached HEAD") + } else { + fmt.Printf(" Registered branch: %s\n", strings.TrimPrefix(branch, "refs/heads/")) + } + fmt.Print("\n 1) Use existing worktree\n 2) Delete and recreate\n 0) Exit\n\nChoice: ") + return readChoice(reader), nil +} + +func readChoice(reader *bufio.Reader) string { + answer, _ := reader.ReadString('\n') + return strings.TrimSpace(answer) +} + +func validateReusedWorktree(path, branch string) error { + if !fileExists(path) { + return fmt.Errorf("Cannot reuse worktree path '%s': directory does not exist.", path) + } + registered, found := worktreeRegistration(path) + if !found { + return fmt.Errorf("Cannot reuse worktree path '%s': path exists but is not a git worktree for this repository.", path) + } + if registered == "" { + return fmt.Errorf("Cannot reuse worktree path '%s': it is a detached git worktree; expected branch '%s'.", path, branch) + } + if registered != "refs/heads/"+branch { + return fmt.Errorf("Cannot reuse worktree path '%s': it belongs to branch '%s', not '%s'.", path, strings.TrimPrefix(registered, "refs/heads/"), branch) + } + return nil +} + +func removeWorktreeAndBranch(worktree, branch string) error { + if worktree != "" { + if fileExists(worktree) { + removable, err := removableLinkedWorktree(worktree) + if err != nil { + return fmt.Errorf("could not verify that worktree path %q is removable: %w", worktree, err) + } + if !removable { + return fmt.Errorf("refusing to remove %q: it is not a removable linked worktree", worktree) + } + } + if err := command("git", "worktree", "remove", "--force", worktree); err != nil { + if fileExists(worktree) { + return fmt.Errorf("refusing to delete worktree path %q after git worktree remove failed: %w", worktree, err) + } + if err := command("git", "worktree", "prune"); err != nil { + return err + } + } + } + if branch != "" { + if err := command("git", "branch", "-D", branch); err != nil { + return err + } + } + return nil +} + +// removableLinkedWorktree reports whether path is a registered non-primary, +// non-current worktree. Git's primary and current worktrees must never be +// removed by this command. +func removableLinkedWorktree(path string) (bool, error) { + list, err := output("git", "worktree", "list", "--porcelain") + if err != nil { + return false, err + } + target := canonicalPath(path) + current, err := output("git", "rev-parse", "--show-toplevel") + if err != nil { + return false, err + } + if canonicalPath(strings.TrimSpace(current)) == target { + return false, nil + } + worktreeIndex := -1 + for _, line := range strings.Split(list, "\n") { + if !strings.HasPrefix(line, "worktree ") { + continue + } + worktreeIndex++ + if canonicalPath(strings.TrimPrefix(line, "worktree ")) == target { + return worktreeIndex > 0, nil + } + } + return false, nil +} +func branchWorktree(branch string) string { + value, err := output("git", "worktree", "list", "--porcelain") + if err != nil { + return "" + } + current := "" + for _, line := range strings.Split(value, "\n") { + if strings.HasPrefix(line, "worktree ") { + current = strings.TrimPrefix(line, "worktree ") + } + if line == "branch refs/heads/"+branch { + return current + } + } + return "" +} +func worktreeBranch(path string) string { + branch, _ := worktreeRegistration(path) + return branch +} + +// worktreeRegistration distinguishes a registered detached worktree from a +// plain directory. Detached worktrees do not have a branch record in Git's +// porcelain output, but can still be safely removed through Git. +func worktreeRegistration(path string) (string, bool) { + path = canonicalPath(path) + value, err := output("git", "worktree", "list", "--porcelain") + if err != nil { + return "", false + } + current := "" + registered := false + currentMatches := false + branch := "" + for _, line := range strings.Split(value, "\n") { + if strings.HasPrefix(line, "worktree ") { + current = strings.TrimPrefix(line, "worktree ") + currentMatches = canonicalPath(current) == path + registered = registered || currentMatches + } + if currentMatches && strings.HasPrefix(line, "branch ") { + branch = strings.TrimPrefix(line, "branch ") + } + } + return branch, registered +} +func canonicalPath(path string) string { + absolute, err := filepath.Abs(path) + if err != nil { + return path + } + if resolved, err := filepath.EvalSymlinks(absolute); err == nil { + return resolved + } + return absolute +} +func launchSelected(o options, agent, model, worktree, prompt string) error { + if o.dryRun { + if o.humanGate { + return humanGate(model, worktree, prompt, true) + } + if agent == "none" { + printManualNextSteps(model, worktree) + return nil + } + printLaunch(agent, model, worktree, prompt) + return nil + } + if o.humanGate { + return humanGate(model, worktree, prompt, false) + } + return launch(agent, model, worktree, prompt) +} +func improvePrompt(root, agent, model, prompt, source, promptFile string, o options, in issue, repo, number, labels string) error { + if agent == "none" { + return errors.New("--improve-prompt requires an agent. Use --agent claude, codex, kimi, or pi.") + } + outputPath := promptImprovementOutputPath(root, promptFile, o) + if o.dryRun { + fmt.Printf("📝 Improving prompt template...\n Prompt source: %s\n Proposal path: %s\n [DRY-RUN] Would ask %s to generate an improved prompt proposal.\n", source, outputPath, agent) + return nil + } + if _, err := os.Stat(outputPath); err == nil { + return fmt.Errorf("Prompt improvement output already exists: %s", outputPath) + } + request := promptImprovementRequest(prompt, source, in, repo, number, labels) + args := helperArgs(agent, model, root, request) + result, err := helperOutput(agent, root, args) + if err != nil { + return fmt.Errorf("Could not generate improved prompt with %s", agent) + } + proposal := normalizePromptProposal(result) + if proposal == "" { + return errors.New("Improved prompt proposal is empty.") + } + if err := os.MkdirAll(filepath.Dir(outputPath), 0755); err != nil { + return err + } + if err := os.WriteFile(outputPath, []byte(proposal+"\n"), 0644); err != nil { + return err + } + fmt.Printf("📝 Prompt improvement written: %s\n", outputPath) + return nil +} + +func promptImprovementOutputPath(root, promptFile string, o options) string { + if o.promptOutput != "" { + return o.promptOutput + } + if promptFile == "" { + return filepath.Join(root, ".start-issue", "prompt.improved.md") + } + if filepath.Ext(promptFile) == ".md" { + return strings.TrimSuffix(promptFile, ".md") + ".improved.md" + } + return promptFile + ".improved" +} + +func promptImprovementRequest(prompt, source string, in issue, repo, number, labels string) string { + issueURL := fmt.Sprintf("https://github.com/%s/issues/%s", repo, number) + return fmt.Sprintf("Improve the following start-issue prompt template.\n\nReturn ONLY the complete improved prompt template. Do not include commentary, code fences, diffs, or explanations.\n\nPreserve any placeholders that are still useful. Supported placeholders:\n{ISSUE_URL}, {ISSUE_NUMBER}, {ISSUE_TITLE}, {ISSUE_BODY}, {ISSUE_LABELS}, {REPO}, {BRANCH_NAME}, {WORKTREE_PATH}, {BASE_BRANCH}\n\nPrompt source:\n%s\n\nRepository:\n%s\n\nCurrent issue used as improvement context:\n- URL: %s\n- Number: %s\n- Title: %s\n- Labels: %s\n- Body:\n%s\n\nCurrent prompt template:\n--- START PROMPT TEMPLATE ---\n%s\n--- END PROMPT TEMPLATE ---", source, repo, issueURL, number, in.Title, labels, in.Body, prompt) +} + +func normalizePromptProposal(result string) string { + lines := strings.Split(strings.TrimSpace(result), "\n") + if len(lines) >= 2 && regexp.MustCompile("^```[[:alnum:]_-]*\\s*$").MatchString(strings.TrimSpace(lines[0])) && strings.TrimSpace(lines[len(lines)-1]) == "```" { + lines = lines[1 : len(lines)-1] + } + return strings.TrimSpace(strings.Join(lines, "\n")) +} +func humanGate(model, worktree, prompt string, dryRun bool) error { + runID := os.Getenv("START_ISSUE_RUN_ID") + if runID == "" { + runID = time.Now().Format("20060102-150405") + } + dir := filepath.Join(worktree, ".start-issue", "runs", runID) + events, last := filepath.Join(dir, "events.jsonl"), filepath.Join(dir, "last-message.txt") + args := []string{"exec", "--cd", worktree, "--sandbox", "workspace-write", "--json", "--output-last-message", last, "-"} + if model != "" { + args = append([]string{"exec", "--model", model}, args[1:]...) + } + if dryRun { + threadID := filepath.Join(dir, "thread-id") + fmt.Printf(" [DRY-RUN] Would run: codex %s > %s\n", shellJoin(args), shellQuote(events)) + fmt.Printf(" [DRY-RUN] Would write captured thread ID: %s\n", threadID) + return nil + } + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + file, err := os.Create(events) + if err != nil { + return err + } + defer file.Close() + cmd := exec.Command("codex", args...) + cmd.Stdin = strings.NewReader(prompt) + cmd.Stdout = file + cmd.Stderr = os.Stderr + _ = cmd.Run() + threadID, err := captureThreadID(events) + if err != nil { + return err + } + if err := os.WriteFile(filepath.Join(dir, "thread-id"), []byte(threadID+"\n"), 0644); err != nil { + return err + } + body, err := os.ReadFile(last) + if err != nil { + return fmt.Errorf("No recognized final status found. Inspect: %s", last) + } + status := finalStatus(string(body)) + if status == "DONE" { + fmt.Println("✅ Codex finished with STATUS: DONE") + return nil + } + if status == "HUMAN_GATE" { + resume := []string{"resume", "--include-non-interactive", threadID} + fmt.Printf("Resume command: codex %s\nThread ID: %s\n", strings.Join(resume, " "), threadID) + if err := command("codex", resume...); err != nil { + fmt.Fprintln(os.Stderr, "Could not open Codex resume session.") + return exitError{code: 2, err: errors.New("Could not open Codex resume session.")} + } + return nil + } + return fmt.Errorf("No recognized final status found. Inspect: %s", last) +} + +func captureThreadID(events string) (string, error) { + eventsBody, err := os.ReadFile(events) + if err != nil { + return "", err + } + for _, line := range strings.Split(string(eventsBody), "\n") { + var event struct { + Type string `json:"type"` + ThreadID string `json:"thread_id"` + } + if json.Unmarshal([]byte(line), &event) == nil && event.Type == "thread.started" && event.ThreadID != "" { + return event.ThreadID, nil + } + } + return "", fmt.Errorf("Codex human-gate run did not capture thread_id. Inspect: %s", events) +} + +func finalStatus(body string) string { + for _, line := range strings.Split(body, "\n") { + if strings.HasPrefix(line, "STATUS:") { + return strings.TrimSpace(strings.TrimPrefix(line, "STATUS:")) + } + } + return "" +} +func printLaunch(a, m, w, p string) { + if a == "none" { + fmt.Printf(" Agent: none\n Model: %s\n [DRY-RUN] Would prepare worktree without launching an agent\n", show(m)) + return + } + promptLength := utf8.RuneCountInString(p) + fmt.Printf(" Prompt length: %d chars\n", promptLength) + if promptLength > 4000 && os.Getenv("START_ISSUE_DUMP_PROMPT") != "1" { + fmt.Println(" Prompt omitted from command display because it is large.") + fmt.Println(" Set START_ISSUE_DUMP_PROMPT=1 to print the full rendered prompt.") + p = fmt.Sprintf("", promptLength) + } + fmt.Printf(" Agent: %s\n Model: %s\n [DRY-RUN] Would run: %s\n", a, show(m), launchDisplayCommand(a, m, w, p)) +} + +func launchDisplayCommand(a, m, w, p string) string { + command := shellJoin(launchArgs(a, m, w, p)) + if a == "claude" || a == "kimi" || a == "pi" { + return "cd " + shellQuote(w) + " && " + command + } + return command +} +func launch(a, m, w, p string) error { + if a == "none" { + printManualNextSteps(m, w) + return nil + } + args := launchArgs(a, m, w, p) + var err error + if a == "claude" || a == "kimi" || a == "pi" { + err = commandAt(w, args...) + } else { + err = command(args[0], args[1:]...) + } + if err != nil { + var exited *exec.ExitError + if errors.As(err, &exited) { + return exitError{code: processExitCode(exited), err: err} + } + return err + } + return nil +} + +func processExitCode(exited *exec.ExitError) int { + if status, ok := exited.Sys().(syscall.WaitStatus); ok && status.Signaled() { + return 128 + int(status.Signal()) + } + return exited.ExitCode() +} + +func printManualNextSteps(model, worktree string) { + fmt.Printf("✅ Worktree ready at: %s\n\n", worktree) + fmt.Printf("Selected agent: none\nResolved model: %s\n", show(model)) + fmt.Println("To start working:") + fmt.Printf(" cd %s\n\n", shellQuote(worktree)) + fmt.Println("Suggested agent commands:") + fmt.Println(" claude") + fmt.Printf(" codex --cd %s\n", shellQuote(worktree)) + fmt.Printf(" (cd %s && kimi)\n", shellQuote(worktree)) + fmt.Println(" pi") +} + +func shellQuote(value string) string { + if value != "" && !strings.ContainsAny(value, " \t\r\n'\"\\$`!()[]{}*?;<>&|#~") { + return value + } + return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'" +} + +func shellJoin(values []string) string { + quoted := make([]string, len(values)) + for i, value := range values { + quoted[i] = shellQuote(value) + } + return strings.Join(quoted, " ") +} +func launchArgs(a, m, w, p string) []string { + switch a { + case "none": + return nil + case "claude": + x := []string{"claude"} + if m != "" { + x = append(x, "--model", m) + } + return append(x, "--dangerously-skip-permissions", p) + case "codex": + x := []string{"codex"} + if m != "" { + x = append(x, "--model", m) + } + return append(x, "--cd", w, "--dangerously-bypass-approvals-and-sandbox", p) + case "kimi": + x := []string{"kimi"} + if m != "" { + x = append(x, "--model", m) + } + return append(x, "-p", p) + default: + x := []string{"pi"} + if m != "" { + x = append(x, "--model", m) + } + return append(x, p) + } +} + +func helperArgs(agent, model, root, prompt string) []string { + withModel := func(args []string) []string { + if model != "" { + return append(args, "--model", model) + } + return args + } + switch agent { + case "claude": + if model == "" { + model = "haiku" + } + args := withModel([]string{"claude", "--print"}) + return append(args, "--no-session-persistence", "--disable-slash-commands", prompt) + case "codex": + args := []string{"codex", "exec"} + if model != "" { + args = append(args, "--model", model) + } + return append(args, "--cd", root, "--sandbox", "read-only", "--skip-git-repo-check", prompt) + case "kimi": + args := []string{"kimi"} + if model != "" { + args = append(args, "--model", model) + } + return append(args, "-p", prompt) + case "pi": + args := []string{"pi"} + if model != "" { + args = append(args, "--model", model) + } + return append(args, "--print", "--no-tools", "--no-session", prompt) + default: + return nil + } +} +func helperOutput(agent, root string, args []string) (string, error) { + if agent == "kimi" { + return outputAt(root, args[0], args[1:]...) + } + return output(args[0], args[1:]...) +} +func command(name string, args ...string) error { + c := exec.Command(name, args...) + c.Stdout = os.Stdout + c.Stderr = os.Stderr + c.Stdin = os.Stdin + return c.Run() +} +func commandAt(dir string, args ...string) error { + c := exec.Command(args[0], args[1:]...) + c.Dir = dir + c.Stdout = os.Stdout + c.Stderr = os.Stderr + c.Stdin = os.Stdin + return c.Run() +} +func output(name string, args ...string) (string, error) { + b, e := exec.Command(name, args...).Output() + return string(b), e +} +func outputAt(dir, name string, args ...string) (string, error) { + cmd := exec.Command(name, args...) + cmd.Dir = dir + b, e := cmd.Output() + return string(b), e +} +func need(n string) error { + if _, e := exec.LookPath(n); e != nil { + return fmt.Errorf("%s not found", n) + } + return nil +} + +func checkGitHubAccess() error { + if _, err := exec.LookPath("gh"); err != nil { + return errors.New("gh CLI not found. Install: https://cli.github.com") + } + return checkGHAuth() +} + +func checkGHAuth() error { + if err := exec.Command("gh", "auth", "status").Run(); err != nil { + return errors.New("gh not authenticated. Run: gh auth login") + } + return nil +} +func show(v string) string { + if v == "" { + return "" + } + return v +} +func die(e error) { + code := 1 + var exit exitError + if errors.As(e, &exit) { + code = exit.code + } + fmt.Fprintln(os.Stderr, "Error:", e) + os.Exit(code) +} +func usage() { + fmt.Printf(`start-issue v%s + +Start working on a GitHub issue with git worktree and a configurable agent + +Usage: start-issue [options] + start-issue init [--project|--user] [--force] [options] + start-issue setup | --setup + start-issue update | --update + start-issue install | --install + +Arguments: + GitHub issue URL or issue number + Examples: 123, https://github.com/owner/repo/issues/123 + init Create default start-issue configuration + setup Run first-run user configuration onboarding + update Update the running start-issue installation + install Install the latest release into ~/.local/bin + +Options: + --repo, -r Repository (default: detected from git remote) + --base, -b Base branch (default: main or master) + --worktree-dir, -w Directory for worktrees + Default: START_ISSUE_WORKTREE_DIR or ~/worktrees + --flat Use flat worktree structure (replace / with - in path) + --agent Agent to launch: claude, codex, kimi, pi, none + With init: default agent to write + --model Model to use for the selected agent + With init: default model config to write + --no-agent Only create worktree, do not start an agent session + --no-claude Compatibility alias for --no-agent + --prompt Prompt template for the launched agent + --prompt-file Prompt template file for the launched agent + --improve-prompt Ask the selected agent to improve the selected + prompt template and write a reviewable proposal + --human-gate Codex-only batch mode that resumes on HUMAN_GATE + --human-gate-help Show detailed help for the human-gate mode + --prompt-output-file + Output path for --improve-prompt proposal + --no-init Skip init.sh execution + --command, -c Compatibility: initial command for Claude default launch + --ai Use the selected agent for branch name generation + Default: fast branch-name heuristics + --project With init: write .start-issue config in this repo + --user With init: write config in ~/.config/start-issue + --force With init: overwrite existing config files + --dry-run Show what would be done without executing + --setup Run first-run user configuration onboarding + --update Update the running start-issue installation + --install Install the latest release into ~/.local/bin + --version, -v Show version + --help, -h Show this help + +Agent selection precedence: + CLI --agent / --no-agent + .start-issue/agent in the git root + ~/.config/start-issue/agent + START_ISSUE_AGENT + built-in default: claude + +Model selection precedence: + CLI --model + .start-issue/model in the git root + ~/.config/start-issue/model + START_ISSUE_MODEL + built-in default: unset (agent CLI decides) + +Prompt template precedence: + CLI --prompt-file / --prompt + START_ISSUE_PROMPT_FILE / START_ISSUE_PROMPT + .start-issue/prompt.md in the git root + ~/.config/start-issue/prompt.md + built-in default + +Prompt improvement: + --improve-prompt uses the selected agent to generate a complete improved + prompt template proposal. It does not overwrite the active prompt template. + Markdown prompt files write next to the source as *.improved.md; other + file names append .improved. Built-in and inline prompts write to + .start-issue/prompt.improved.md by default. Use --prompt-output-file to + choose another proposal path. + +Prompt variables: + {ISSUE_URL}, {ISSUE_NUMBER}, {ISSUE_TITLE}, {ISSUE_BODY}, {ISSUE_LABELS}, + {REPO}, {BRANCH_NAME}, {WORKTREE_PATH}, {BASE_BRANCH} + +Environment variables: + START_ISSUE_AGENT + START_ISSUE_MODEL + START_ISSUE_PROMPT + START_ISSUE_PROMPT_FILE + START_ISSUE_WORKTREE_DIR + START_ISSUE_DUMP_PROMPT + +Examples: + start-issue 123 + start-issue https://github.com/owner/repo/issues/123 + start-issue 123 --repo owner/repo --base develop + start-issue 123 --agent codex + start-issue 123 --agent codex --model gpt-5.2 + start-issue 123 --agent codex --human-gate + start-issue 123 --agent claude --model sonnet + start-issue 123 --agent kimi --prompt-file .start-issue/prompt.md + start-issue 123 --no-agent # Only create worktree + start-issue 123 --command "/debug" # Claude command prefix + start-issue 123 --flat # Flat worktree path + start-issue 123 --dry-run + start-issue init + start-issue setup + start-issue init --project --agent codex --model gpt-5.2 + start-issue init --project --agent codex + start-issue init --user --force + start-issue --setup + start-issue update + start-issue --update + start-issue install + start-issue --install + start-issue --human-gate-help +`, runningVersion()) +} + +func printBanner() { + fmt.Printf("start-issue v%s\n\n", runningVersion()) +} + +func humanGateHelp() { + printBanner() + fmt.Println(`Codex human-gate mode + +Usage: + start-issue --agent codex --human-gate + start-issue --human-gate-help + +Flow: + The normal issue workflow creates or reuses the worktree, renders the + prompt, and runs Codex in batch mode. The final message must contain one + terminal status line: STATUS: DONE or STATUS: HUMAN_GATE. + +Exit codes: + 0 Codex returned STATUS: DONE. + 1 Codex failed, no thread_id was captured, no recognized status was found, + or parsing failed. + 2 Codex returned STATUS: HUMAN_GATE but automatic interactive resume failed. + +State artifacts: + /.start-issue/runs//events.jsonl + /.start-issue/runs//last-message.txt + /.start-issue/runs//thread-id + +Recovery: + If automatic resume fails, run: + codex resume --include-non-interactive `) +} diff --git a/cmd/start-issue/main_test.go b/cmd/start-issue/main_test.go new file mode 100644 index 0000000..ffe782b --- /dev/null +++ b/cmd/start-issue/main_test.go @@ -0,0 +1,1918 @@ +package main + +import ( + "bufio" + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "net" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "runtime" + "runtime/debug" + "strings" + "testing" +) + +func TestParseIssue(t *testing.T) { + number, repo, err := parseIssue("https://github.com/dapi/start-issue/issues/34", "") + if err != nil || number != "34" || repo != "dapi/start-issue" { + t.Fatalf("got %q %q %v", number, repo, err) + } +} + +func TestParseTracksWorktreeDirectorySource(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("START_ISSUE_WORKTREE_DIR", "") + + o, err := parse(nil) + if err != nil { + t.Fatal(err) + } + if got, want := o.worktreeDirSource, "built-in default"; got != want { + t.Fatalf("default worktree directory source = %q, want %q", got, want) + } + + t.Setenv("START_ISSUE_WORKTREE_DIR", "/env-worktrees") + o, err = parse(nil) + if err != nil { + t.Fatal(err) + } + if got, want := o.worktreeDirSource, "START_ISSUE_WORKTREE_DIR"; got != want { + t.Fatalf("environment worktree directory source = %q, want %q", got, want) + } + + o, err = parse([]string{"--worktree-dir", "/cli-worktrees"}) + if err != nil { + t.Fatal(err) + } + if got, want := o.worktreeDirSource, "CLI"; got != want { + t.Fatalf("CLI worktree directory source = %q, want %q", got, want) + } +} + +func TestUserHomeDirRejectsUnavailableOrRelativeHome(t *testing.T) { + t.Setenv("HOME", "") + if runtime.GOOS != "windows" { + if _, err := userHomeDir(); err == nil { + t.Fatal("empty HOME returned a usable home directory") + } + } + + t.Setenv("HOME", "relative-home") + if runtime.GOOS != "windows" { + if _, err := userHomeDir(); err == nil { + t.Fatal("relative HOME returned a usable home directory") + } + } +} + +func TestParseRequiresHomeOnlyForDefaultWorktreeDirectory(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows derives its home directory from USERPROFILE") + } + t.Setenv("HOME", "") + t.Setenv("START_ISSUE_WORKTREE_DIR", "") + + if _, err := parse([]string{"1"}); err == nil || !strings.Contains(err.Error(), "set --worktree-dir or START_ISSUE_WORKTREE_DIR") { + t.Fatalf("default worktree directory error = %v", err) + } + if o, err := parse([]string{"1", "--worktree-dir", t.TempDir()}); err != nil || o.worktreeDirSource != "CLI" { + t.Fatalf("explicit worktree directory = %#v, %v", o, err) + } +} + +func TestResolversDoNotReadRelativeUserConfigWhenHomeIsUnavailable(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows derives its home directory from USERPROFILE") + } + wd := t.TempDir() + previous, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + defer func() { _ = os.Chdir(previous) }() + if err := os.Chdir(wd); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(".config", "start-issue"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(".config", "start-issue", "agent"), []byte("codex\n"), 0644); err != nil { + t.Fatal(err) + } + t.Setenv("HOME", "") + + agent, source, err := resolveAgent("", "") + if err != nil || agent != "claude" || source != "built-in default" { + t.Fatalf("resolveAgent = %q, %q, %v", agent, source, err) + } +} + +func TestResolvePromptReportsMissingPromptFile(t *testing.T) { + missing := filepath.Join(t.TempDir(), "missing-prompt.md") + for _, test := range []struct { + name string + o options + env bool + }{ + {name: "CLI", o: options{promptFile: missing}}, + {name: "environment", env: true}, + } { + t.Run(test.name, func(t *testing.T) { + t.Setenv("START_ISSUE_PROMPT", "") + if test.env { + t.Setenv("START_ISSUE_PROMPT_FILE", missing) + } else { + t.Setenv("START_ISSUE_PROMPT_FILE", "") + } + + _, _, _, _, err := resolvePrompt(t.TempDir(), "none", test.o) + if err == nil || err.Error() != "Prompt file not found: "+missing { + t.Fatalf("resolvePrompt error = %v, want public missing-file error", err) + } + if !errors.Is(err, fs.ErrNotExist) { + t.Fatalf("resolvePrompt error = %v, want errors.Is(err, fs.ErrNotExist)", err) + } + }) + } +} + +func TestResolvePromptStripsTrailingNewlinesFromPromptFiles(t *testing.T) { + root, home, promptFile := t.TempDir(), t.TempDir(), filepath.Join(t.TempDir(), "prompt.md") + const content = "Prompt {ISSUE_URL}\n\n" + if err := os.WriteFile(promptFile, []byte(content), 0644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(root, ".start-issue"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, ".start-issue", "prompt.md"), []byte(content), 0644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(home, ".config", "start-issue"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(home, ".config", "start-issue", "prompt.md"), []byte(content), 0644); err != nil { + t.Fatal(err) + } + t.Setenv("HOME", home) + + for _, test := range []struct { + name string + root string + o options + env bool + }{ + {name: "CLI", root: t.TempDir(), o: options{promptFile: promptFile}}, + {name: "environment", root: t.TempDir(), env: true}, + {name: "project config", root: root}, + {name: "user config", root: t.TempDir()}, + } { + t.Run(test.name, func(t *testing.T) { + t.Setenv("START_ISSUE_PROMPT", "") + if test.env { + t.Setenv("START_ISSUE_PROMPT_FILE", promptFile) + } else { + t.Setenv("START_ISSUE_PROMPT_FILE", "") + } + + got, _, _, _, err := resolvePrompt(test.root, "codex", test.o) + if err != nil || got != "Prompt {ISSUE_URL}" { + t.Fatalf("resolvePrompt() = %q, %v; want prompt without trailing newlines", got, err) + } + }) + } +} + +func TestBranchName(t *testing.T) { + if got := branchName("34", "Fix broken output!", "bug"); got != "fix/issue-34-fix-broken-output" { + t.Fatalf("got %q", got) + } +} + +func TestBranchNameMatchesFastShellRules(t *testing.T) { + tests := []struct { + title, labels, want string + }{ + {"[brief] Исправить ЦАП", "urgent, bug", "hotfix/issue-34-ispravit-tsap"}, + {"Documentation tidy", "documentation", "docs/issue-34-documentation-tidy"}, + {"Fix broken output", "Bug", "feature/issue-34-fix-broken-output"}, + {"", "", "feature/issue-34-work"}, + } + for _, test := range tests { + if got := branchName("34", test.title, test.labels); got != test.want { + t.Errorf("branchName(%q, %q) = %q, want %q", test.title, test.labels, got, test.want) + } + } +} + +func TestSlugifyTruncatesBeforeTrimmingSeparators(t *testing.T) { + title := "😀" + strings.Repeat("a", 39) + " trailing" + if got, want := slugify(title), strings.Repeat("a", 39); got != want { + t.Fatalf("slugify(%q) = %q, want %q", title, got, want) + } +} + +func TestRender(t *testing.T) { + if got := render("{REPO} #{ISSUE_NUMBER}", map[string]string{"REPO": "dapi/start-issue", "ISSUE_NUMBER": "34"}); got != "dapi/start-issue #34" { + t.Fatalf("got %q", got) + } +} + +func TestRenderPreservesOrderedExpansionOfIssueData(t *testing.T) { + in := issue{Title: "Keep {REPO}", Body: "and {ISSUE_NUMBER}"} + got := renderIssuePrompt("{ISSUE_URL}; {ISSUE_NUMBER}; {ISSUE_TITLE}; {ISSUE_BODY}; {ISSUE_LABELS}; {REPO}; {BRANCH_NAME}; {WORKTREE_PATH}; {BASE_BRANCH}", "https://github.com/dapi/start-issue/issues/34", "34", in, []string{"bug"}, "dapi/start-issue", "feature/issue-34", "/tmp/reused", "main") + want := "https://github.com/dapi/start-issue/issues/34; 34; Keep dapi/start-issue; and {ISSUE_NUMBER}; bug; dapi/start-issue; feature/issue-34; /tmp/reused; main" + if got != want { + t.Fatalf("got %q, want %q", got, want) + } +} + +func TestIssueUnmarshalNormalizesNullBody(t *testing.T) { + var in issue + if err := json.Unmarshal([]byte(`{"title":"No description","body":null,"labels":[{"name":"bug"}]}`), &in); err != nil { + t.Fatal(err) + } + if in.Body != "" { + t.Fatalf("Body = %q, want empty string", in.Body) + } + if got := renderIssuePrompt("{ISSUE_BODY}", "", "", in, nil, "", "", "", ""); got != "" { + t.Fatalf("rendered null body = %q, want empty string", got) + } +} + +func TestDetectRepoTrimsNewlineBeforeGitSuffix(t *testing.T) { + bin := t.TempDir() + writeExecutable(t, filepath.Join(bin, "git"), "#!/bin/sh\nprintf '%s\\n' 'https://github.com/dapi/start-issue.git'\n") + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + + got, err := detectRepo() + if err != nil || got != "dapi/start-issue" { + t.Fatalf("got %q, %v", got, err) + } +} + +func TestDryRunLaunchDoesNotExecuteAgent(t *testing.T) { + bin := t.TempDir() + marker := filepath.Join(t.TempDir(), "codex-ran") + writeExecutable(t, filepath.Join(bin, "codex"), "#!/bin/sh\ntouch '"+marker+"'\n") + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + + if err := launchSelected(options{dryRun: true}, "codex", "", t.TempDir(), "prompt"); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatalf("dry-run executed codex: %v", err) + } +} + +func TestDryRunDeleteAndRecreatePreservesExistingBranchAndWorktree(t *testing.T) { + home, bin, root, worktrees := t.TempDir(), t.TempDir(), t.TempDir(), t.TempDir() + worktree := filepath.Join(worktrees, "feature", "issue-1-add-login-button") + worktreeMarker := filepath.Join(worktree, "uncommitted-work") + branchMarker := filepath.Join(t.TempDir(), "branch-exists") + gitLog := filepath.Join(t.TempDir(), "git-mutations.log") + if err := os.MkdirAll(filepath.Join(home, ".config", "start-issue"), 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(worktree, 0755); err != nil { + t.Fatal(err) + } + for _, marker := range []string{worktreeMarker, branchMarker} { + if err := os.WriteFile(marker, []byte("must remain"), 0644); err != nil { + t.Fatal(err) + } + } + writeExecutable(t, filepath.Join(bin, "git"), fmt.Sprintf(`#!/bin/sh +case "$1 $2 $3" in + "rev-parse --show-toplevel ") printf '%%s\n' %q ;; + "remote get-url origin") printf '%%s\n' 'git@github.com:owner/repo.git' ;; + "show-ref --verify --quiet") [ "$4" = "refs/heads/feature/issue-1-add-login-button" ] && exit 0; exit 1 ;; + "worktree list --porcelain") printf 'worktree %%s\nbranch refs/heads/master\n\nworktree %%s\nbranch refs/heads/feature/issue-1-add-login-button\n' %q %q ;; + "worktree remove --force") printf '%%s\n' "$*" >> "$START_ISSUE_GIT_LOG"; rm -rf "$4" ;; +esac +if [ "$1 $2" = "branch -D" ]; then + printf '%%s\n' "$*" >> "$START_ISSUE_GIT_LOG" + rm -f "$START_ISSUE_BRANCH_MARKER" +fi +`, root, root, worktree)) + writeExecutable(t, filepath.Join(bin, "gh"), `#!/bin/sh +if [ "$1 $2" = "auth status" ]; then exit 0; fi +if [ "$1" = api ]; then + printf '%s\n' '{"title":"Add login button","body":"","labels":[]}' +fi +`) + t.Setenv("HOME", home) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("START_ISSUE_GIT_LOG", gitLog) + t.Setenv("START_ISSUE_BRANCH_MARKER", branchMarker) + + output := captureStdout(t, func() { + err := runWithReader(options{ + issue: "1", + repo: "owner/repo", + base: "master", + worktreeDir: worktrees, + agent: "none", + dryRun: true, + noInit: true, + }, bufio.NewReader(strings.NewReader("3\n"))) + if err != nil { + t.Fatal(err) + } + }) + for _, marker := range []string{worktreeMarker, branchMarker} { + if _, err := os.Stat(marker); err != nil { + t.Fatalf("dry-run removed %s: %v", marker, err) + } + } + if mutations, err := os.ReadFile(gitLog); err == nil && len(mutations) > 0 { + t.Fatalf("dry-run ran destructive git commands:\n%s", mutations) + } + for _, want := range []string{ + "[DRY-RUN] Would remove worktree: " + worktree, + "[DRY-RUN] Would delete branch: feature/issue-1-add-login-button", + "[DRY-RUN] Would run: git worktree add -b feature/issue-1-add-login-button", + } { + if !strings.Contains(output, want) { + t.Fatalf("dry-run output missing %q:\n%s", want, output) + } + } +} + +func TestSuffixWorktreeUsesSuffixedBranch(t *testing.T) { + bin := t.TempDir() + writeExecutable(t, filepath.Join(bin, "git"), `#!/bin/sh +case "$4" in + refs/heads/feature/issue-34-original|refs/heads/feature/issue-34-original-v2) exit 0 ;; + *) exit 1 ;; +esac +`) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + branch := nextSuffixedBranch("feature/issue-34-original") + if branch != "feature/issue-34-original-v3" { + t.Fatalf("got branch %q", branch) + } + if got, want := worktreePath("/worktrees", branch, false), "/worktrees/feature/issue-34-original-v3"; got != want { + t.Fatalf("got %q, want %q", got, want) + } +} + +func TestRemoveWorktreeAndBranchRemovesStaleRegistration(t *testing.T) { + bin, log := t.TempDir(), filepath.Join(t.TempDir(), "git.log") + writeExecutable(t, filepath.Join(bin, "git"), `#!/bin/sh +printf '%s\n' "$*" >> "$START_ISSUE_GIT_LOG" +if [ "$1 $2" = "worktree remove" ] && [ "$START_ISSUE_REMOVE_WORKTREE_FAIL" = 1 ]; then + exit 1 +fi +`) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("START_ISSUE_GIT_LOG", log) + + stale := filepath.Join(t.TempDir(), "externally-deleted-worktree") + if err := removeWorktreeAndBranch(stale, "feature/issue-34-stale"); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(log) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + "worktree remove --force " + stale, + "branch -D feature/issue-34-stale", + } { + if !strings.Contains(string(got), want) { + t.Fatalf("git calls missing %q:\n%s", want, got) + } + } +} + +func TestRemoveWorktreeAndBranchPrunesAfterFailedStaleRemoval(t *testing.T) { + bin, log := t.TempDir(), filepath.Join(t.TempDir(), "git.log") + writeExecutable(t, filepath.Join(bin, "git"), `#!/bin/sh +printf '%s\n' "$*" >> "$START_ISSUE_GIT_LOG" +if [ "$1 $2" = "worktree remove" ]; then + exit 1 +fi +`) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("START_ISSUE_GIT_LOG", log) + + stale := filepath.Join(t.TempDir(), "externally-deleted-worktree") + if err := removeWorktreeAndBranch(stale, "feature/issue-34-stale"); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(log) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(got), "worktree prune") { + t.Fatalf("failed stale removal did not prune registration:\n%s", got) + } +} + +func TestRemoveWorktreeAndBranchRefusesPrimaryWorktree(t *testing.T) { + bin, log, primary := t.TempDir(), filepath.Join(t.TempDir(), "git.log"), t.TempDir() + marker := filepath.Join(primary, "keep") + if err := os.WriteFile(marker, []byte("must not be deleted"), 0644); err != nil { + t.Fatal(err) + } + writeExecutable(t, filepath.Join(bin, "git"), fmt.Sprintf(`#!/bin/sh +printf '%%s\n' "$*" >> "$START_ISSUE_GIT_LOG" +case "$1 $2 $3" in + "worktree list --porcelain") printf 'worktree %s\nbranch refs/heads/main\n' %q ;; + "rev-parse --show-toplevel") printf '%%s\n' %q ;; +esac + `, primary, primary, primary)) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("START_ISSUE_GIT_LOG", log) + + err := removeWorktreeAndBranch(primary, "main") + if err == nil || !strings.Contains(err.Error(), "not a removable linked worktree") { + t.Fatalf("remove primary worktree error = %v", err) + } + if _, err := os.Stat(marker); err != nil { + t.Fatalf("primary worktree content was deleted: %v", err) + } + got, _ := os.ReadFile(log) + if strings.Contains(string(got), "worktree remove") || strings.Contains(string(got), "branch -D") { + t.Fatalf("primary worktree removal invoked git mutation:\n%s", got) + } +} + +func TestRemoveWorktreeAndBranchNeverFallsBackToRawDeletion(t *testing.T) { + bin, log, primary, linked := t.TempDir(), filepath.Join(t.TempDir(), "git.log"), t.TempDir(), t.TempDir() + marker := filepath.Join(linked, "keep") + if err := os.WriteFile(marker, []byte("must not be deleted"), 0644); err != nil { + t.Fatal(err) + } + writeExecutable(t, filepath.Join(bin, "git"), fmt.Sprintf(`#!/bin/sh +printf '%%s\n' "$*" >> "$START_ISSUE_GIT_LOG" +case "$1 $2 $3" in + "worktree list --porcelain") printf 'worktree %s\nbranch refs/heads/main\nworktree %s\nbranch refs/heads/feature/issue-34\n' %q %q ;; + "rev-parse --show-toplevel") printf '%%s\n' %q ;; +esac +if [ "$1 $2" = "worktree remove" ]; then exit 1; fi +`, primary, linked, primary, linked, primary)) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("START_ISSUE_GIT_LOG", log) + + err := removeWorktreeAndBranch(linked, "feature/issue-34") + if err == nil || !strings.Contains(err.Error(), "refusing to delete worktree path") { + t.Fatalf("failed linked worktree removal error = %v", err) + } + if _, err := os.Stat(marker); err != nil { + t.Fatalf("linked worktree content was deleted: %v", err) + } + got, _ := os.ReadFile(log) + if strings.Contains(string(got), "worktree prune") || strings.Contains(string(got), "branch -D") { + t.Fatalf("failed removal mutated stale registration or branch:\n%s", got) + } +} + +func TestReleaseAssetName(t *testing.T) { + for _, test := range []struct { + goos, goarch, want string + }{ + {"linux", "amd64", "start-issue-linux-amd64"}, + {"linux", "arm64", "start-issue-linux-arm64"}, + {"darwin", "amd64", "start-issue-darwin-amd64"}, + {"darwin", "arm64", "start-issue-darwin-arm64"}, + {"windows", "amd64", "start-issue-windows-amd64.exe"}, + } { + got, err := releaseAssetName(test.goos, test.goarch) + if err != nil || got != test.want { + t.Fatalf("releaseAssetName(%s, %s) = %q, %v; want %q", test.goos, test.goarch, got, err, test.want) + } + } + for _, test := range []struct{ goos, goarch string }{{"linux", "386"}, {"freebsd", "amd64"}, {"windows", "arm64"}} { + if got, err := releaseAssetName(test.goos, test.goarch); err == nil || got != "" || !strings.Contains(err.Error(), "unsupported release platform") { + t.Fatalf("releaseAssetName(%s, %s) = %q, %v; want unsupported platform error", test.goos, test.goarch, got, err) + } + } +} + +func TestPathConflictChoiceRejectsUnregisteredDirectoryWithoutDeleteOption(t *testing.T) { + path := filepath.Join(t.TempDir(), "ordinary-directory") + output := captureStdout(t, func() { + _, _ = pathConflictChoice(path, "", false, bufio.NewReader(strings.NewReader("2\n"))) + }) + _, err := pathConflictChoice(path, "", false, bufio.NewReader(strings.NewReader("2\n"))) + if err == nil || !strings.Contains(err.Error(), "Move or remove the directory manually") { + t.Fatalf("unregistered path conflict error = %v", err) + } + if strings.Contains(output, "Delete and recreate") || strings.Contains(output, "Choice:") { + t.Fatalf("unregistered path conflict offered deletion:\n%s", output) + } +} + +func TestDetachedWorktreeIsRegisteredAndRemovable(t *testing.T) { + bin, log, primary, detached := t.TempDir(), filepath.Join(t.TempDir(), "git.log"), t.TempDir(), t.TempDir() + writeExecutable(t, filepath.Join(bin, "git"), fmt.Sprintf(`#!/bin/sh +printf '%%s\n' "$*" >> "$START_ISSUE_GIT_LOG" +case "$1 $2 $3" in + "worktree list --porcelain") printf 'worktree %%s\nbranch refs/heads/main\n\nworktree %%s\nHEAD deadbeef\ndetached\n' %q %q ;; + "rev-parse --show-toplevel") printf '%%s\n' %q ;; +esac +`, primary, detached, primary)) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("START_ISSUE_GIT_LOG", log) + + branch, registered := worktreeRegistration(detached) + if !registered || branch != "" { + t.Fatalf("worktreeRegistration(detached) = %q, %t; want empty branch and registered", branch, registered) + } + output := captureStdout(t, func() { + choice, err := pathConflictChoice(detached, branch, registered, bufio.NewReader(strings.NewReader("2\n"))) + if err != nil || choice != "2" { + t.Fatalf("detached path conflict choice = %q, %v", choice, err) + } + }) + if !strings.Contains(output, "Registered branch: detached HEAD") || !strings.Contains(output, "Delete and recreate") { + t.Fatalf("detached worktree did not offer safe delete/recreate:\n%s", output) + } + if err := removeWorktreeAndBranch(detached, branch); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(log) + if err != nil || !strings.Contains(string(got), "worktree remove --force "+detached) { + t.Fatalf("detached worktree was not removed through git: %q, %v", got, err) + } + if strings.Contains(string(got), "branch -D") { + t.Fatalf("detached worktree removal deleted a branch:\n%s", got) + } +} + +func TestValidChecksum(t *testing.T) { + binary := []byte("start-issue") + checksum := sha256.Sum256(binary) + manifest := fmt.Sprintf("%x start-issue-darwin-arm64\n", checksum) + if !validChecksum(binary, "start-issue-darwin-arm64", manifest) { + t.Fatal("expected checksum to match") + } + if validChecksum(binary, "start-issue-linux-amd64", manifest) { + t.Fatal("unexpected checksum match") + } +} + +func TestCompareVersions(t *testing.T) { + for _, test := range []struct { + left, right string + want int + }{ + {left: "v1.2.0", right: "1.2.0", want: 0}, + {left: "1.2.1", right: "1.2.0", want: 1}, + {left: "1.1.9", right: "v1.2.0", want: -1}, + {left: "2.0.0-rc.1", right: "v2.0.0", want: -1}, + {left: "2.0.0-rc.2", right: "2.0.0-rc.1", want: 1}, + {left: "2.0.0-alpha", right: "2.0.0-alpha.1", want: -1}, + {left: "1.13.2-8-gabcdef", right: "v1.13.2", want: 1}, + {left: "1.13.2-8-gabcdef-dirty", right: "v1.13.2", want: 1}, + {left: "1.13.2-dirty", right: "v1.13.2", want: 1}, + } { + if got := compareVersions(test.left, test.right); got != test.want { + t.Errorf("compareVersions(%q, %q) = %d, want %d", test.left, test.right, got, test.want) + } + } +} + +func TestUpdateUsesConfiguredReleaseRepository(t *testing.T) { + bin, log := t.TempDir(), filepath.Join(t.TempDir(), "gh.log") + writeExecutable(t, filepath.Join(bin, "gh"), `#!/bin/sh +printf '%s\n' "$*" >> "$START_ISSUE_GH_LOG" +printf '%s\n' '{"tag_name":"v1.0.0"}' +`) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("START_ISSUE_GH_LOG", log) + t.Setenv("START_ISSUE_REPOSITORY", "fork/start-issue") + + previousVersion := version + version = "2.0.0" + defer func() { version = previousVersion }() + + if err := updateMode(options{}); err != nil { + t.Fatal(err) + } + called, err := os.ReadFile(log) + if err != nil || strings.TrimSpace(string(called)) != "auth status\napi repos/fork/start-issue/releases/latest" { + t.Fatalf("gh API call = %q, %v", called, err) + } +} + +func TestUpdateRequiresAuthenticatedGitHubCLI(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows update exits through its documented manual path") + } + bin := t.TempDir() + writeExecutable(t, filepath.Join(bin, "gh"), "#!/bin/sh\n[ \"$1\" = auth ] && exit 1\n") + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + + err := updateMode(options{}) + if err == nil || err.Error() != "gh not authenticated. Run: gh auth login" { + t.Fatalf("update authentication error = %v", err) + } +} + +func TestInstallRequiresGitHubCLI(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows installation is intentionally manual") + } + t.Setenv("PATH", t.TempDir()) + if err := installMode(false); err == nil || err.Error() != "gh CLI not found. Install: https://cli.github.com" { + t.Fatalf("install GitHub CLI error = %v", err) + } +} + +func TestUpdateRequiresGitHubCLI(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows update is intentionally manual") + } + t.Setenv("PATH", t.TempDir()) + if err := updateMode(options{}); err == nil || err.Error() != "gh CLI not found. Install: https://cli.github.com" { + t.Fatalf("update GitHub CLI error = %v", err) + } +} + +func TestCheckGitHubAccessReportsInstallationInstructions(t *testing.T) { + t.Setenv("PATH", t.TempDir()) + if err := checkGitHubAccess(); err == nil || err.Error() != "gh CLI not found. Install: https://cli.github.com" { + t.Fatalf("GitHub CLI error = %v", err) + } +} + +func TestRunModeUpdateDoesNotRequireHomeDirectory(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows update exits through its documented manual path") + } + bin := t.TempDir() + writeExecutable(t, filepath.Join(bin, "gh"), "#!/bin/sh\nprintf '%s\\n' '{\"tag_name\":\"v1.0.0\"}'\n") + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("HOME", "") + previousVersion := version + version = "1.0.0" + defer func() { version = previousVersion }() + + if err := runMode(options{mode: "update"}); err != nil { + t.Fatalf("update unexpectedly required HOME: %v", err) + } +} + +func TestUpdateRejectsReleaseWithoutTagName(t *testing.T) { + bin := t.TempDir() + writeExecutable(t, filepath.Join(bin, "gh"), "#!/bin/sh\nprintf '%s\\n' '{}'\n") + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + + err := updateMode(options{}) + if err == nil || !strings.Contains(err.Error(), "missing tag_name") { + t.Fatalf("update error = %v", err) + } +} + +func TestUpdateDryRunRejectsReleaseWithoutRequiredAssets(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows update exits through its documented manual path") + } + bin := t.TempDir() + writeExecutable(t, filepath.Join(bin, "gh"), "#!/bin/sh\nprintf '%s\\n' '{\"tag_name\":\"v9.0.0\",\"assets\":[]}'\n") + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + + previousVersion := version + version = "1.0.0" + defer func() { version = previousVersion }() + + err := updateMode(options{dryRun: true}) + if err == nil || !strings.Contains(err.Error(), "does not contain") || !strings.Contains(err.Error(), "checksums.txt") { + t.Fatalf("dry-run missing release assets error = %v", err) + } +} + +func TestVersionFromBuildInfoUsesGoInstallModuleVersion(t *testing.T) { + info := &debug.BuildInfo{Main: debug.Module{Version: "v2.3.4"}} + if got := versionFromBuildInfo(info); got != "2.3.4" { + t.Fatalf("versionFromBuildInfo() = %q, want 2.3.4", got) + } + if got := versionFromBuildInfo(&debug.BuildInfo{Main: debug.Module{Version: "(devel)"}}, "9.9.9"); got != "9.9.9" { + t.Fatalf("development version = %q, want fallback", got) + } + if got := versionFromBuildInfo(&debug.BuildInfo{Main: debug.Module{Version: "(devel)"}}); got != "dev" { + t.Fatalf("unversioned development build = %q, want dev", got) + } +} + +func TestRunningVersionUsesInjectedBuildVersion(t *testing.T) { + previousVersion := version + version = "v9.8.7" + defer func() { version = previousVersion }() + if got := runningVersion(); got != "9.8.7" { + t.Fatalf("runningVersion() = %q, want injected version", got) + } +} + +func TestParseInitOptions(t *testing.T) { + o, err := parse([]string{"init", "--project", "--force", "--prompt-file", "prompt.md"}) + if err != nil || o.mode != "init" || !o.project || !o.force || o.promptFile != "prompt.md" { + t.Fatalf("unexpected options: %#v, %v", o, err) + } +} + +func TestParseRejectsEmptyOptionValues(t *testing.T) { + for _, option := range []string{"--repo", "--base", "--worktree-dir", "--agent", "--model", "--prompt-file", "--prompt", "--command", "--prompt-output-file"} { + t.Run(option, func(t *testing.T) { + _, err := parse([]string{option, ""}) + if err == nil || err.Error() != option+" requires a value." { + t.Fatalf("parse(%s, empty) error = %v", option, err) + } + }) + } +} + +func TestInitRejectsEmptyRetainedAgentConfig(t *testing.T) { + path := filepath.Join(t.TempDir(), "agent") + if err := os.WriteFile(path, []byte("# configured later\n"), 0644); err != nil { + t.Fatal(err) + } + _, _, err := resolveInitAgent(path, "codex", false) + if err == nil || !strings.Contains(err.Error(), "Agent config is empty") { + t.Fatalf("resolveInitAgent error = %v", err) + } +} + +func TestInitRejectsEmptyRetainedModelConfig(t *testing.T) { + path := filepath.Join(t.TempDir(), "model") + if err := os.WriteFile(path, []byte("\n# configured later\n"), 0644); err != nil { + t.Fatal(err) + } + _, err := resolveInitModel(path, "claude-opus", false) + if err == nil || !strings.Contains(err.Error(), "Model config is empty") { + t.Fatalf("resolveInitModel error = %v", err) + } +} + +func TestInitRetainsExistingModelBeforeCLIModel(t *testing.T) { + path := filepath.Join(t.TempDir(), "model") + if err := os.WriteFile(path, []byte("configured-model\n"), 0644); err != nil { + t.Fatal(err) + } + model, err := resolveInitModel(path, "cli-model", false) + if err != nil || model != "configured-model" { + t.Fatalf("resolveInitModel = %q, %v", model, err) + } +} + +func TestInitValidatesCLIModel(t *testing.T) { + path := filepath.Join(t.TempDir(), "model") + + model, err := resolveInitModel(path, " gpt-5.2 ", true) + if err != nil || model != "gpt-5.2" { + t.Fatalf("resolveInitModel trimmed model = %q, %v", model, err) + } + + _, err = resolveInitModel(path, " ", true) + if err == nil || !strings.Contains(err.Error(), "Model config is empty") { + t.Fatalf("resolveInitModel whitespace-only model error = %v", err) + } +} + +func TestResolveModelNormalizesCLIValue(t *testing.T) { + model, source, err := resolveModel(t.TempDir(), " gpt-5 ") + if err != nil || model != "gpt-5" || source != "CLI" { + t.Fatalf("resolveModel trimmed CLI model = %q, %q, %v", model, source, err) + } + + _, source, err = resolveModel(t.TempDir(), " ") + if err == nil || source != "CLI" || !strings.Contains(err.Error(), "--model requires a non-empty value") { + t.Fatalf("resolveModel whitespace-only CLI model = source %q, error %v", source, err) + } +} + +func TestResolversRejectWhitespaceOnlyEnvironmentValues(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + root := t.TempDir() + + t.Setenv("START_ISSUE_AGENT", " \t\n ") + _, source, err := resolveAgent(root, "") + if source != "START_ISSUE_AGENT" || err == nil || err.Error() != "Agent config is empty. Valid agents: claude, codex, kimi, pi, none." { + t.Fatalf("resolveAgent whitespace-only environment value = source %q, error %v", source, err) + } + + t.Setenv("START_ISSUE_AGENT", "") + t.Setenv("START_ISSUE_MODEL", " \t\n ") + _, source, err = resolveModel(root, "") + if source != "START_ISSUE_MODEL" || err == nil || err.Error() != "Model config is empty. Remove the empty model config or set a value." { + t.Fatalf("resolveModel whitespace-only environment value = source %q, error %v", source, err) + } +} + +func TestParseRejectsMultipleCommandModes(t *testing.T) { + _, err := parse([]string{"init", "update"}) + if err == nil || !strings.Contains(err.Error(), "only one command mode") { + t.Fatalf("got %v", err) + } +} + +func TestSelectInitDirRequiresExplicitValidScopeInRepository(t *testing.T) { + root, home := t.TempDir(), t.TempDir() + for _, test := range []struct { + name, input string + want string + wantErr string + }{ + {name: "project", input: "project\n", want: filepath.Join(root, ".start-issue")}, + {name: "user", input: "2\n", want: filepath.Join(home, ".config", "start-issue")}, + {name: "invalid", input: "maybe\n", wantErr: "Invalid init scope"}, + {name: "eof", input: "", wantErr: "No init scope selected"}, + } { + t.Run(test.name, func(t *testing.T) { + got, err := selectInitDir(root, home, options{mode: "init"}, bufio.NewReader(strings.NewReader(test.input))) + if test.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), test.wantErr) { + t.Fatalf("error = %v, want %q", err, test.wantErr) + } + return + } + if err != nil || got != test.want { + t.Fatalf("got %q, %v; want %q", got, err, test.want) + } + }) + } +} + +func TestCheckGHAuthRequiresAnAuthenticatedSession(t *testing.T) { + bin := t.TempDir() + writeExecutable(t, filepath.Join(bin, "gh"), "#!/bin/sh\n[ \"$1\" = auth ] && [ \"$2\" = status ] && exit 1\nexit 0\n") + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + if err := checkGHAuth(); err == nil || !strings.Contains(err.Error(), "gh not authenticated") { + t.Fatalf("got %v", err) + } +} + +func TestVerifyStagedBinaryRequiresExpectedVersion(t *testing.T) { + staged := filepath.Join(t.TempDir(), "start-issue") + writeExecutable(t, staged, "#!/bin/sh\nif [ \"$1\" = --version ]; then echo 'start-issue v2.1.0'; fi\n") + if err := verifyStagedBinary(staged, "v2.1.0"); err != nil { + t.Fatal(err) + } + if err := verifyStagedBinary(staged, "v2.2.0"); err == nil || !strings.Contains(err.Error(), "does not match") { + t.Fatalf("got %v", err) + } +} + +func TestInstallVerifiedUpdateKeepsCurrentExecutableWhenStagingFails(t *testing.T) { + target := filepath.Join(t.TempDir(), "start-issue") + writeExecutable(t, target, "#!/bin/sh\n[ \"$1\" = --version ] && echo 'start-issue v2.0.0'\n") + wrongVersion := []byte("#!/bin/sh\n[ \"$1\" = --version ] && echo 'start-issue v2.1.1'\n") + if err := installVerifiedUpdate(target, wrongVersion, "v2.1.0"); err == nil { + t.Fatal("expected staged version verification error") + } + version, err := exec.Command(target, "--version").Output() + if err != nil || strings.TrimSpace(string(version)) != "start-issue v2.0.0" { + t.Fatalf("current executable changed: %q, %v", version, err) + } + if _, err := os.Stat(target + ".new"); !os.IsNotExist(err) { + t.Fatalf("staged executable was not removed: %v", err) + } +} + +func TestInstallVerifiedUpdateDoesNotFollowPredictableStagingSymlink(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "start-issue") + staging := target + ".new" + writeExecutable(t, target, "#!/bin/sh\n[ \"$1\" = --version ] && echo 'start-issue v2.0.0'\n") + if err := os.Symlink(target, staging); err != nil { + t.Fatal(err) + } + updated := []byte("#!/bin/sh\n[ \"$1\" = --version ] && echo 'start-issue v2.1.0'\n") + if err := installVerifiedUpdate(target, updated, "v2.1.0"); err != nil { + t.Fatal(err) + } + version, err := exec.Command(target, "--version").Output() + if err != nil || strings.TrimSpace(string(version)) != "start-issue v2.1.0" { + t.Fatalf("updated executable = %q, %v", version, err) + } + info, err := os.Lstat(staging) + if err != nil || info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("predictable staging symlink was modified: %v, %v", info, err) + } +} + +func TestInstallModeKeepsTargetWhenStagedVersionDoesNotMatchRelease(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows installation is intentionally manual") + } + home, bin := t.TempDir(), t.TempDir() + assetName, err := releaseAssetName(runtime.GOOS, runtime.GOARCH) + if err != nil { + t.Skipf("unsupported test platform: %v", err) + } + asset := []byte("#!/bin/sh\n[ \"$1\" = --version ] && echo 'start-issue v2.1.1'\n") + checksum := fmt.Sprintf("%x %s\n", sha256.Sum256(asset), assetName) + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Skipf("local HTTP listener unavailable: %v", err) + } + server := httptest.NewUnstartedServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case "/asset": + _, _ = response.Write(asset) + case "/checksums": + _, _ = response.Write([]byte(checksum)) + default: + http.NotFound(response, request) + } + })) + server.Listener = listener + server.Start() + defer server.Close() + release := filepath.Join(t.TempDir(), "release.json") + metadata := fmt.Sprintf(`{"tag_name":"v2.1.0","assets":[{"name":"%s","browser_download_url":"%s/asset"},{"name":"checksums.txt","browser_download_url":"%s/checksums"}]}`, assetName, server.URL, server.URL) + if err := os.WriteFile(release, []byte(metadata), 0644); err != nil { + t.Fatal(err) + } + writeExecutable(t, filepath.Join(bin, "gh"), "#!/bin/sh\ncat \"$START_ISSUE_TEST_RELEASE\"\n") + t.Setenv("HOME", home) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("START_ISSUE_TEST_RELEASE", release) + + err = installMode(false) + if err == nil || !strings.Contains(err.Error(), "does not match expected release") { + t.Fatalf("install error = %v", err) + } + target := filepath.Join(home, ".local", "bin", "start-issue") + if _, statErr := os.Stat(target); !os.IsNotExist(statErr) { + t.Fatalf("invalid staged binary was installed: %v", statErr) + } +} + +func TestNormalizePromptProposalStripsOnlyOuterFences(t *testing.T) { + got := normalizePromptProposal("```markdown\n# Prompt\n```go\nkeep\n```\n```") + if want := "# Prompt\n```go\nkeep\n```"; got != want { + t.Fatalf("got %q, want %q", got, want) + } + if got := normalizePromptProposal("# Prompt\n```\n"); got != "# Prompt\n```" { + t.Fatalf("unpaired fence changed: %q", got) + } +} + +func TestPromptImprovementOutputPathPreservesLegacyFileNames(t *testing.T) { + root := t.TempDir() + for _, test := range []struct { + name, path, want string + }{ + {"markdown", filepath.Join(root, "prompt.md"), filepath.Join(root, "prompt.improved.md")}, + {"other extension", filepath.Join(root, "prompt.txt"), filepath.Join(root, "prompt.txt.improved")}, + {"extensionless", filepath.Join(root, "prompt"), filepath.Join(root, "prompt.improved")}, + } { + t.Run(test.name, func(t *testing.T) { + if got := promptImprovementOutputPath(root, test.path, options{promptFile: test.path}); got != test.want { + t.Fatalf("promptImprovementOutputPath() = %q, want %q", got, test.want) + } + }) + } +} + +func TestInlinePromptNeverUsesDisplayLabelAsSourceFile(t *testing.T) { + root := t.TempDir() + labelPath := filepath.Join(root, "CLI --prompt") + if err := os.WriteFile(labelPath, []byte("unrelated file"), 0644); err != nil { + t.Fatal(err) + } + prompt, source, _, promptFile, err := resolvePrompt(root, "codex", options{prompt: "inline prompt"}) + if err != nil || prompt != "inline prompt" || source != "CLI --prompt" || promptFile != "" { + t.Fatalf("resolvePrompt() = %q, %q, %q, %v", prompt, source, promptFile, err) + } + if got, want := promptImprovementOutputPath(root, promptFile, options{}), filepath.Join(root, ".start-issue", "prompt.improved.md"); got != want { + t.Fatalf("inline prompt proposal path = %q, want %q", got, want) + } +} + +func TestImprovePromptPassesLegacyRequestToHelper(t *testing.T) { + bin, root, log := t.TempDir(), t.TempDir(), filepath.Join(t.TempDir(), "pi-request") + writeExecutable(t, filepath.Join(bin, "pi"), "#!/bin/sh\nprintf '%s' \"$*\" > \"$START_ISSUE_PROMPT_HELPER_LOG\"\nprintf 'improved prompt'\n") + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("START_ISSUE_PROMPT_HELPER_LOG", log) + + promptPath := filepath.Join(root, "prompt.md") + outputPath := filepath.Join(root, "proposal.md") + err := improvePrompt(root, "pi", "", "Current template", "CLI --prompt-file: "+promptPath, promptPath, options{promptFile: promptPath, promptOutput: outputPath}, issue{Title: "Issue title", Body: "Issue body"}, "owner/repo", "34", "bug, urgent") + if err != nil { + t.Fatal(err) + } + request, err := os.ReadFile(log) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + "Prompt source:\nCLI --prompt-file: " + promptPath, + "Repository:\nowner/repo", + "Current issue used as improvement context:\n- URL: https://github.com/owner/repo/issues/34\n- Number: 34\n- Title: Issue title\n- Labels: bug, urgent\n- Body:\nIssue body", + "Current prompt template:\n--- START PROMPT TEMPLATE ---\nCurrent template\n--- END PROMPT TEMPLATE ---", + } { + if !strings.Contains(string(request), want) { + t.Fatalf("helper request missing %q:\n%s", want, request) + } + } +} + +func TestImprovePromptRejectsEmptyProposal(t *testing.T) { + bin, root := t.TempDir(), t.TempDir() + writeExecutable(t, filepath.Join(bin, "pi"), "#!/bin/sh\nprintf ' \\n\\t \\n'\n") + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + outputPath := filepath.Join(root, "proposals", "prompt.md") + err := improvePrompt(root, "pi", "", "prompt", "built-in default", "", options{promptOutput: outputPath}, issue{Title: "Issue"}, "owner/repo", "1", "") + if err == nil || !strings.Contains(err.Error(), "proposal is empty") { + t.Fatalf("improvePrompt() error = %v", err) + } + if _, statErr := os.Stat(outputPath); !os.IsNotExist(statErr) { + t.Fatalf("empty proposal wrote output file: %v", statErr) + } +} + +func TestInstallBinaryRestoresExecutablePermissions(t *testing.T) { + target := filepath.Join(t.TempDir(), "start-issue") + if err := os.WriteFile(target, []byte("old"), 0644); err != nil { + t.Fatal(err) + } + previous, err := os.Stat(target) + if err != nil { + t.Fatal(err) + } + if err := installBinary(target, []byte("new")); err != nil { + t.Fatal(err) + } + info, err := os.Stat(target) + if err != nil { + t.Fatal(err) + } + if got := info.Mode().Perm(); got != 0755 { + t.Fatalf("installed mode = %o, want 755", got) + } + if os.SameFile(previous, info) { + t.Fatal("installBinary replaced the target in place instead of atomically renaming a staged binary") + } + if _, err := os.Stat(target + ".new"); !os.IsNotExist(err) { + t.Fatalf("staged binary was not removed: %v", err) + } +} + +func TestRunInitWarnsOnFailure(t *testing.T) { + bin, worktree := t.TempDir(), t.TempDir() + if err := os.WriteFile(filepath.Join(worktree, "init.sh"), []byte("ignored"), 0644); err != nil { + t.Fatal(err) + } + writeExecutable(t, filepath.Join(bin, "bash"), "#!/bin/sh\nexit 1\n") + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + output := captureStdout(t, func() { runInit(worktree, false) }) + if !strings.Contains(output, "Warning: init.sh exited with non-zero code") { + t.Fatalf("init failure warning missing:\n%s", output) + } +} + +func TestRenameZellijTabWarnsOnFailure(t *testing.T) { + bin := t.TempDir() + writeExecutable(t, filepath.Join(bin, "zellij-tab-status"), "#!/bin/sh\nexit 1\n") + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + output := captureStdout(t, func() { renameZellijTab("34", false) }) + if !strings.Contains(output, "Warning: Could not rename zellij tab with zellij-tab-status") { + t.Fatalf("zellij failure warning missing:\n%s", output) + } +} + +func TestRenameZellijTabDryRunReportsMissingOptionalCommand(t *testing.T) { + t.Setenv("PATH", t.TempDir()) + output := captureStdout(t, func() { renameZellijTab("34", true) }) + if !strings.Contains(output, "[DRY-RUN] Would skip zellij tab rename: zellij-tab-status not found") { + t.Fatalf("missing zellij dry-run message:\n%s", output) + } +} + +func TestUsageListsCompatibilityEntryPoints(t *testing.T) { + output := captureStdout(t, usage) + for _, want := range []string{ + "--command, -c ", + "--setup", + "--update", + "--install", + "--human-gate-help", + "Agent selection precedence:", + ".start-issue/agent in the git root", + "Prompt template precedence:", + "{ISSUE_URL}, {ISSUE_NUMBER}, {ISSUE_TITLE}, {ISSUE_BODY}, {ISSUE_LABELS}", + "start-issue https://github.com/owner/repo/issues/123", + } { + if !strings.Contains(output, want) { + t.Fatalf("help missing %q:\n%s", want, output) + } + } +} + +func TestAIBranchPromptPreservesTransliterationAndTagConstraints(t *testing.T) { + bin, log := t.TempDir(), filepath.Join(t.TempDir(), "prompt") + writeExecutable(t, filepath.Join(bin, "pi"), "#!/bin/sh\nlast=''\nfor arg do last=$arg; done\nprintf '%s' \"$last\" > '"+log+"'\nprintf '%s\\n' feature/issue-34-ispravit-tsap\n") + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + if _, err := aiBranchName("pi", "", t.TempDir(), "34", "[brief] Исправить ЦАП", "bug"); err != nil { + t.Fatal(err) + } + prompt, err := os.ReadFile(log) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"transliterate it to English", "Strip leading bracketed process/stage tags", "[brief]"} { + if !strings.Contains(string(prompt), want) { + t.Fatalf("prompt missing %q:\n%s", want, prompt) + } + } +} + +func TestAIBranchNamePreservesCompleteResponseLine(t *testing.T) { + bin := t.TempDir() + writeExecutable(t, filepath.Join(bin, "pi"), "#!/bin/sh\nprintf '%s\\n' 'Here is the branch: feature/issue-34-fix-login'\n") + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + + got, err := aiBranchName("pi", "", t.TempDir(), "34", "Fix login", "bug") + if err != nil { + t.Fatal(err) + } + if want := "Here is the branch: feature/issue-34-fix-login"; got != want { + t.Fatalf("got %q, want %q", got, want) + } +} + +func TestInstallDryRunDoesNotFetchOrWrite(t *testing.T) { + home, bin := t.TempDir(), t.TempDir() + marker := filepath.Join(t.TempDir(), "gh-ran") + writeExecutable(t, filepath.Join(bin, "gh"), "#!/bin/sh\ntouch '"+marker+"'\n") + t.Setenv("HOME", home) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + + if err := installMode(true); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatalf("dry-run fetched the release: %v", err) + } + if _, err := os.Stat(filepath.Join(home, ".local", "bin", "start-issue")); !os.IsNotExist(err) { + t.Fatalf("dry-run installed a binary: %v", err) + } +} + +func TestFirstRunOnboardingReusesBufferedInput(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + if err := maybeRunFirstRunOnboarding(false, "", bufio.NewReader(strings.NewReader("y\n2\ny\n"))); err != nil { + t.Fatal(err) + } + agent, err := os.ReadFile(filepath.Join(home, ".config", "start-issue", "agent")) + if err != nil || string(agent) != "codex\n" { + t.Fatalf("agent = %q, %v", agent, err) + } +} + +func TestFirstRunOnboardingSavesClaudeCommandPrompt(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + if err := maybeRunFirstRunOnboarding(false, "/debug", bufio.NewReader(strings.NewReader("y\n1\ny\n"))); err != nil { + t.Fatal(err) + } + prompt, err := os.ReadFile(filepath.Join(home, ".config", "start-issue", "prompt.md")) + if err != nil { + t.Fatal(err) + } + if got, want := string(prompt), "/debug {ISSUE_URL}\n"; got != want { + t.Fatalf("saved prompt = %q, want %q", got, want) + } +} + +func TestFirstRunOnboardingDeclinesWithoutSetup(t *testing.T) { + for _, response := range []string{"n\n", "no\n"} { + t.Run(strings.TrimSpace(response), func(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + if err := maybeRunFirstRunOnboarding(false, "", bufio.NewReader(strings.NewReader(response))); err != nil { + t.Fatal(err) + } + dir := filepath.Join(home, ".config", "start-issue") + if _, err := os.Stat(dir); err != nil { + t.Fatalf("first-run marker missing: %v", err) + } + for _, name := range []string{"agent", "prompt.md"} { + if _, err := os.Stat(filepath.Join(dir, name)); !os.IsNotExist(err) { + t.Fatalf("declining setup created %s: %v", name, err) + } + } + }) + } +} + +func TestRunPerformsFirstRunOnboardingBeforeGitValidation(t *testing.T) { + home, bin := t.TempDir(), t.TempDir() + t.Setenv("HOME", home) + t.Setenv("PATH", bin) + + var runErr error + output := captureStdout(t, func() { + runErr = runWithReader(options{issue: "1"}, bufio.NewReader(strings.NewReader("n\n"))) + }) + if runErr == nil || runErr.Error() != "git not found" { + t.Fatalf("run error = %v, want git validation failure", runErr) + } + if !strings.Contains(output, "Configuration is not initialized yet.") || !strings.Contains(output, "Run setup now? [Y/n]") { + t.Fatalf("first-run onboarding was not offered before git validation:\n%s", output) + } + if _, err := os.Stat(filepath.Join(home, ".config", "start-issue")); err != nil { + t.Fatalf("first-run marker missing: %v", err) + } +} + +func TestFirstRunOnboardingRejectsEOFAndInvalidResponse(t *testing.T) { + for _, response := range []string{"", "maybe\n"} { + t.Run(fmt.Sprintf("%q", response), func(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + err := maybeRunFirstRunOnboarding(false, "", bufio.NewReader(strings.NewReader(response))) + if err == nil { + t.Fatal("expected response error") + } + if _, statErr := os.Stat(filepath.Join(home, ".config", "start-issue")); !os.IsNotExist(statErr) { + t.Fatalf("invalid response initialized config: %v", statErr) + } + }) + } +} + +func TestSetupRejectsEOFAndHonorsNo(t *testing.T) { + for _, test := range []struct { + name, input string + wantErr bool + }{ + {name: "eof", input: "2\n", wantErr: true}, + {name: "no", input: "2\nno\n"}, + } { + t.Run(test.name, func(t *testing.T) { + home := t.TempDir() + err := setupMode(home, false, "", bufio.NewReader(strings.NewReader(test.input))) + if (err != nil) != test.wantErr { + t.Fatalf("setup error = %v, want error: %t", err, test.wantErr) + } + prompt := filepath.Join(home, ".config", "start-issue", "prompt.md") + if _, err := os.Stat(prompt); !os.IsNotExist(err) { + t.Fatalf("setup wrote prompt after %s: %v", test.name, err) + } + if _, err := os.Stat(filepath.Join(home, ".config", "start-issue")); err != nil { + t.Fatalf("setup did not create configuration marker after %s: %v", test.name, err) + } + }) + } +} + +func TestSetupReturnsConfigurationRemovalErrors(t *testing.T) { + for _, test := range []struct { + name, input, config, message string + }{ + {name: "prompt", input: "codex\nno\n", config: "prompt.md", message: "remove prompt template"}, + {name: "agent", input: "skip\nno\n", config: "agent", message: "remove agent config"}, + } { + t.Run(test.name, func(t *testing.T) { + home := t.TempDir() + path := filepath.Join(home, ".config", "start-issue", test.config) + if err := os.MkdirAll(path, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(path, "keep"), []byte("keep"), 0644); err != nil { + t.Fatal(err) + } + + err := setupMode(home, false, "", bufio.NewReader(strings.NewReader(test.input))) + if err == nil || !strings.Contains(err.Error(), test.message) { + t.Fatalf("setup error = %v", err) + } + }) + } +} + +func TestSetupPreviewsSelectedPromptBeforeConfirmation(t *testing.T) { + home := t.TempDir() + output := captureStdout(t, func() { + if err := setupMode(home, false, "", bufio.NewReader(strings.NewReader("2\ny\n"))); err != nil { + t.Fatal(err) + } + }) + preview := "Default prompt preview:\n" + defaultPortablePrompt() + if !strings.Contains(output, preview) { + t.Fatalf("setup output did not preview the selected prompt:\n%s", output) + } + if strings.Index(output, "Default prompt preview:") > strings.Index(output, "Save a default prompt?") { + t.Fatalf("setup asked for confirmation before previewing the prompt:\n%s", output) + } +} + +func TestSetupSavesClaudeCommandPrompt(t *testing.T) { + home := t.TempDir() + if err := setupMode(home, false, "/debug", bufio.NewReader(strings.NewReader("claude\ny\n"))); err != nil { + t.Fatal(err) + } + prompt, err := os.ReadFile(filepath.Join(home, ".config", "start-issue", "prompt.md")) + if err != nil { + t.Fatal(err) + } + if got, want := string(prompt), "/debug {ISSUE_URL}\n"; got != want { + t.Fatalf("saved prompt = %q, want %q", got, want) + } +} + +func TestSetupAcceptsLegacyAgentSelections(t *testing.T) { + for _, test := range []struct { + choice string + wantAgent string + }{ + {choice: "claude", wantAgent: "claude"}, + {choice: "Claude", wantAgent: "claude"}, + {choice: "codex", wantAgent: "codex"}, + {choice: "Codex", wantAgent: "codex"}, + {choice: "kimi", wantAgent: "kimi"}, + {choice: "Kimi", wantAgent: "kimi"}, + {choice: "pi", wantAgent: "pi"}, + {choice: "Pi", wantAgent: "pi"}, + {choice: "skip", wantAgent: ""}, + {choice: "Skip", wantAgent: ""}, + {choice: "", wantAgent: ""}, + } { + t.Run(test.choice, func(t *testing.T) { + home := t.TempDir() + if err := setupMode(home, false, "", bufio.NewReader(strings.NewReader(test.choice+"\nno\n"))); err != nil { + t.Fatal(err) + } + agent, err := os.ReadFile(filepath.Join(home, ".config", "start-issue", "agent")) + if test.wantAgent == "" { + if !os.IsNotExist(err) { + t.Fatalf("setup agent config = %q, %v; want no agent config", agent, err) + } + return + } + if err != nil { + t.Fatal(err) + } + if got := string(agent); got != test.wantAgent+"\n" { + t.Fatalf("setup agent config = %q, want %q", got, test.wantAgent+"\n") + } + }) + } +} + +func TestSetupDryRunPrintsInteractiveConfigPlanWithoutWriting(t *testing.T) { + home := t.TempDir() + dir := filepath.Join(home, ".config", "start-issue") + output := captureStdout(t, func() { + if err := setupMode(home, true, "", bufio.NewReader(strings.NewReader("5\nn\n"))); err != nil { + t.Fatal(err) + } + }) + for _, want := range []string{ + "Default prompt preview:\n/task-router:route-task {ISSUE_URL}", + "Would create configuration in: " + dir, + "Would remove prompt template: " + filepath.Join(dir, "prompt.md"), + "Would remove agent config: " + filepath.Join(dir, "agent"), + } { + if !strings.Contains(output, want) { + t.Fatalf("dry-run output missing %q:\n%s", want, output) + } + } + if _, err := os.Stat(dir); !os.IsNotExist(err) { + t.Fatalf("setup dry-run created configuration: %v", err) + } +} + +func TestEmptyRootDoesNotReadRelativeProjectConfig(t *testing.T) { + wd := t.TempDir() + previous, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + defer func() { _ = os.Chdir(previous) }() + if err := os.Chdir(wd); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(".start-issue", 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(".start-issue", "agent"), []byte("codex\n"), 0644); err != nil { + t.Fatal(err) + } + t.Setenv("HOME", t.TempDir()) + + agent, source, err := resolveAgent("", "") + if err != nil || agent != "claude" || source != "built-in default" { + t.Fatalf("got %q %q %v", agent, source, err) + } +} + +func TestResolvePromptPrefersProjectConfig(t *testing.T) { + root := t.TempDir() + dir := filepath.Join(root, ".start-issue") + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "prompt.md"), []byte("project {ISSUE_URL}"), 0644); err != nil { + t.Fatal(err) + } + got, source, location, promptFile, err := resolvePrompt(root, "codex", options{}) + if err != nil || got != "project {ISSUE_URL}" || source != filepath.Join(dir, "prompt.md") || location != filepath.Join(dir, "prompt.md") { + t.Fatalf("got %q %q %q %v", got, source, location, err) + } + if promptFile != filepath.Join(dir, "prompt.md") { + t.Fatalf("prompt file = %q, want project prompt path", promptFile) + } +} + +func TestResolvePromptTracksSourceAndLocationSeparately(t *testing.T) { + inline, source, location, inlinePromptFile, err := resolvePrompt(t.TempDir(), "codex", options{prompt: "hello"}) + if err != nil || inline != "hello" || source != "CLI --prompt" || location != "inline CLI argument" { + t.Fatalf("inline = %q %q %q %v", inline, source, location, err) + } + if inlinePromptFile != "" { + t.Fatalf("inline prompt file = %q, want empty", inlinePromptFile) + } + + promptFile := filepath.Join(t.TempDir(), "prompt with spaces.md") + if err := os.WriteFile(promptFile, []byte("from file"), 0644); err != nil { + t.Fatal(err) + } + _, source, location, promptFile, err = resolvePrompt(t.TempDir(), "codex", options{promptFile: promptFile}) + if err != nil || source != "CLI --prompt-file: "+promptFile || location != promptFile { + t.Fatalf("file = %q %q %v", source, location, err) + } + if promptFile == "" { + t.Fatal("file-backed prompt did not retain its source path") + } +} + +func TestInitScopeDoesNotImportOtherScopeAgent(t *testing.T) { + root, home, bin := t.TempDir(), t.TempDir(), t.TempDir() + writeExecutable(t, filepath.Join(bin, "git"), "#!/bin/sh\nprintf '%s\\n' '"+root+"'\n") + t.Setenv("HOME", home) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + if err := os.MkdirAll(filepath.Join(home, ".config", "start-issue"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(home, ".config", "start-issue", "agent"), []byte("codex\n"), 0644); err != nil { + t.Fatal(err) + } + + if err := runMode(options{mode: "init", project: true}); err != nil { + t.Fatal(err) + } + agent, err := os.ReadFile(filepath.Join(root, ".start-issue", "agent")) + if err != nil || string(agent) != "claude\n" { + t.Fatalf("project agent = %q, %v", agent, err) + } + + if err := os.RemoveAll(filepath.Join(home, ".config", "start-issue")); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, ".start-issue", "agent"), []byte("codex\n"), 0644); err != nil { + t.Fatal(err) + } + if err := runMode(options{mode: "init", user: true}); err != nil { + t.Fatal(err) + } + agent, err = os.ReadFile(filepath.Join(home, ".config", "start-issue", "agent")) + if err != nil || string(agent) != "claude\n" { + t.Fatalf("user agent = %q, %v", agent, err) + } +} + +func TestInitDryRunValidatesAndPrintsPlanWithoutWriting(t *testing.T) { + root, home, bin := t.TempDir(), t.TempDir(), t.TempDir() + writeExecutable(t, filepath.Join(bin, "git"), "#!/bin/sh\nprintf '%s\\n' '"+root+"'\n") + t.Setenv("HOME", home) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + + err := runMode(options{mode: "init", project: true, dryRun: true, agent: "unknown"}) + if err == nil || !strings.Contains(err.Error(), "Unknown agent") { + t.Fatalf("unknown agent error = %v", err) + } + err = runMode(options{mode: "init", project: true, dryRun: true, promptFile: filepath.Join(root, "missing.md")}) + if err == nil || !errors.Is(err, fs.ErrNotExist) { + t.Fatalf("missing prompt error = %v", err) + } + + output := captureStdout(t, func() { + err = runMode(options{mode: "init", project: true, dryRun: true, agent: "codex"}) + }) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + "Would create configuration in: " + filepath.Join(root, ".start-issue"), + "Would write agent config: " + filepath.Join(root, ".start-issue", "agent"), + "Would write prompt template: " + filepath.Join(root, ".start-issue", "prompt.md"), + } { + if !strings.Contains(output, want) { + t.Fatalf("dry-run output missing %q:\n%s", want, output) + } + } + if _, statErr := os.Stat(filepath.Join(root, ".start-issue")); !os.IsNotExist(statErr) { + t.Fatalf("dry-run created config: %v", statErr) + } +} + +func TestInitDefaultPromptUsesAgentPersistedAtTarget(t *testing.T) { + root, home, bin := t.TempDir(), t.TempDir(), t.TempDir() + configDir := filepath.Join(root, ".start-issue") + if err := os.MkdirAll(configDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(configDir, "agent"), []byte("codex\n"), 0644); err != nil { + t.Fatal(err) + } + writeExecutable(t, filepath.Join(bin, "git"), "#!/bin/sh\nprintf '%s\\n' '"+root+"'\n") + t.Setenv("HOME", home) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + + if err := runMode(options{mode: "init", project: true, agent: "claude"}); err != nil { + t.Fatal(err) + } + prompt, err := os.ReadFile(filepath.Join(configDir, "prompt.md")) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(prompt), "/task-router:route-task") { + t.Fatalf("Codex config received Claude prompt: %q", prompt) + } + if !strings.Contains(string(prompt), "Implement GitHub issue {ISSUE_URL}") { + t.Fatalf("got unexpected prompt: %q", prompt) + } +} + +func TestInitDefaultClaudePromptUsesCommand(t *testing.T) { + root, home, bin := t.TempDir(), t.TempDir(), t.TempDir() + writeExecutable(t, filepath.Join(bin, "git"), "#!/bin/sh\nprintf '%s\\n' '"+root+"'\n") + t.Setenv("HOME", home) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + + if err := runMode(options{mode: "init", project: true, command: "/custom:route"}); err != nil { + t.Fatal(err) + } + prompt, err := os.ReadFile(filepath.Join(root, ".start-issue", "prompt.md")) + if err != nil { + t.Fatal(err) + } + if got, want := string(prompt), "/custom:route {ISSUE_URL}\n"; got != want { + t.Fatalf("prompt = %q, want %q", got, want) + } +} + +func TestInitPromptFileNormalizesTrailingNewlines(t *testing.T) { + root, home, bin, source := t.TempDir(), t.TempDir(), t.TempDir(), filepath.Join(t.TempDir(), "source.md") + writeExecutable(t, filepath.Join(bin, "git"), "#!/bin/sh\nprintf '%s\\n' '"+root+"'\n") + if err := os.WriteFile(source, []byte("Prompt {ISSUE_URL}\n\n"), 0644); err != nil { + t.Fatal(err) + } + t.Setenv("HOME", home) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + + if err := runMode(options{mode: "init", project: true, promptFile: source}); err != nil { + t.Fatal(err) + } + prompt, err := os.ReadFile(filepath.Join(root, ".start-issue", "prompt.md")) + if err != nil { + t.Fatal(err) + } + if got, want := string(prompt), "Prompt {ISSUE_URL}\n"; got != want { + t.Fatalf("prompt = %q, want %q", got, want) + } +} + +func TestLaunchArgsNoneIsEmpty(t *testing.T) { + if got := launchArgs("none", "", "", ""); len(got) != 0 { + t.Fatalf("got %q", got) + } +} + +func TestPrintLaunchShellQuotesArguments(t *testing.T) { + output := captureStdout(t, func() { + printLaunch("codex", "", "/tmp/work tree", "# prompt; touch not-run") + }) + if !strings.Contains(output, "codex --cd '/tmp/work tree' --dangerously-bypass-approvals-and-sandbox '# prompt; touch not-run'") { + t.Fatalf("launch output is not shell quoted:\n%s", output) + } + if got := shellJoin([]string{"", "~", "#prompt"}); got != "'' '~' '#prompt'" { + t.Fatalf("shellJoin = %q", got) + } +} + +func TestPrintLaunchLargePromptDiagnostics(t *testing.T) { + prompt := strings.Repeat("x", 4001) + output := captureStdout(t, func() { + printLaunch("codex", "", "/tmp/work tree", prompt) + }) + for _, want := range []string{ + "Prompt length: 4001 chars", + "Prompt omitted from command display because it is large.", + "Set START_ISSUE_DUMP_PROMPT=1 to print the full rendered prompt.", + "", + } { + if !strings.Contains(output, want) { + t.Fatalf("dry-run diagnostics missing %q:\n%s", want, output) + } + } + if strings.Contains(output, prompt) { + t.Fatalf("large rendered prompt was not omitted from command display:\n%s", output) + } +} + +func TestPrintLaunchCountsUnicodePromptCharacters(t *testing.T) { + prompt := strings.Repeat("я", 3000) + output := captureStdout(t, func() { + printLaunch("codex", "", "/tmp/work tree", prompt) + }) + if !strings.Contains(output, "Prompt length: 3000 chars") { + t.Fatalf("unicode prompt character count is wrong:\n%s", output) + } + if strings.Contains(output, "Prompt omitted from command display because it is large.") { + t.Fatalf("unicode prompt below the character threshold was omitted:\n%s", output) + } +} + +func TestPrintLaunchShowsWorktreeCWDForClaudeAndPi(t *testing.T) { + for _, agent := range []string{"claude", "pi"} { + t.Run(agent, func(t *testing.T) { + output := captureStdout(t, func() { + printLaunch(agent, "", "/tmp/work tree", "prompt") + }) + if !strings.Contains(output, "Would run: cd '/tmp/work tree' && "+agent) { + t.Fatalf("dry-run launch plan omits worktree cwd:\n%s", output) + } + }) + } +} + +func TestCanonicalPathMakesRelativeWorktreeAbsolute(t *testing.T) { + if got := canonicalPath(filepath.Join("worktrees", "feature", "issue-34")); !filepath.IsAbs(got) { + t.Fatalf("worktree path is relative: %q", got) + } +} + +func TestLaunchPreservesAgentExitCode(t *testing.T) { + bin := t.TempDir() + writeExecutable(t, filepath.Join(bin, "codex"), "#!/bin/sh\nexit 42\n") + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + + err := launch("codex", "", t.TempDir(), "prompt") + var exit exitError + if !errors.As(err, &exit) || exit.code != 42 { + t.Fatalf("got %T %v", err, err) + } +} + +func TestLaunchPreservesSignalDerivedExitCode(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX signal exit statuses are not available on Windows") + } + bin := t.TempDir() + writeExecutable(t, filepath.Join(bin, "codex"), "#!/bin/sh\nkill -INT $$\n") + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + + err := launch("codex", "", t.TempDir(), "prompt") + var exit exitError + if !errors.As(err, &exit) || exit.code != 130 { + t.Fatalf("got %T %v", err, err) + } +} + +func TestLaunchUsesAdapterSpecificWorkingDirectory(t *testing.T) { + bin, worktree, log := t.TempDir(), t.TempDir(), filepath.Join(t.TempDir(), "cwd") + for _, agent := range []string{"claude", "codex", "kimi", "pi"} { + writeExecutable(t, filepath.Join(bin, agent), "#!/bin/sh\npwd > \"$START_ISSUE_CWD_LOG\"\n") + } + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("START_ISSUE_CWD_LOG", log) + caller, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + for _, agent := range []string{"claude", "codex", "kimi", "pi"} { + t.Run(agent, func(t *testing.T) { + if err := launch(agent, "", worktree, "prompt"); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(log) + if err != nil { + t.Fatal(err) + } + want := caller + if agent == "claude" || agent == "kimi" || agent == "pi" { + want = worktree + } + if strings.TrimSpace(string(got)) != want { + t.Fatalf("working directory = %q, want %q", strings.TrimSpace(string(got)), want) + } + }) + } +} + +func TestHelperArgsAreNonInteractive(t *testing.T) { + pi := helperArgs("pi", "", "/repo", "prompt") + if got := fmt.Sprint(pi); got != "[pi --print --no-tools --no-session prompt]" { + t.Fatalf("pi helper args: %s", got) + } + kimi := helperArgs("kimi", "model", "/repo", "prompt") + if got := fmt.Sprint(kimi); got != "[kimi --model model -p prompt]" { + t.Fatalf("kimi helper args: %s", got) + } +} + +func TestHumanGateSavesThreadIDBeforeDone(t *testing.T) { + worktree, bin := t.TempDir(), t.TempDir() + writeFakeCodex(t, bin) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("START_ISSUE_RUN_ID", "done") + t.Setenv("CODEX_EVENTS", `{"type":"thread.started","thread_id":"thread-done"}`) + t.Setenv("CODEX_LAST", "STATUS: DONE") + t.Setenv("START_ISSUE_FAKE_CODEX_REJECT_ASK_FOR_APPROVAL", "1") + + if err := humanGate("", worktree, "prompt", false); err != nil { + t.Fatal(err) + } + threadID, err := os.ReadFile(filepath.Join(worktree, ".start-issue", "runs", "done", "thread-id")) + if err != nil || string(threadID) != "thread-done\n" { + t.Fatalf("thread-id = %q, %v", threadID, err) + } +} + +func TestHumanGateSavesThreadIDWhenFinalMessageIsMissing(t *testing.T) { + worktree, bin := t.TempDir(), t.TempDir() + writeFakeCodex(t, bin) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("START_ISSUE_RUN_ID", "missing-last-message") + t.Setenv("CODEX_EVENTS", `{"type":"thread.started","thread_id":"thread-recovery"}`) + t.Setenv("CODEX_SKIP_LAST", "1") + + err := humanGate("", worktree, "prompt", false) + if err == nil || !strings.Contains(err.Error(), "No recognized final status found") { + t.Fatalf("humanGate error = %v, want missing final-status error", err) + } + threadID, readErr := os.ReadFile(filepath.Join(worktree, ".start-issue", "runs", "missing-last-message", "thread-id")) + if readErr != nil || string(threadID) != "thread-recovery\n" { + t.Fatalf("thread-id = %q, %v", threadID, readErr) + } +} + +func TestRunChecksForGitBeforeRepositoryValidation(t *testing.T) { + t.Setenv("PATH", t.TempDir()) + + err := runWithReader(options{}, bufio.NewReader(strings.NewReader(""))) + if err == nil || err.Error() != "git not found" { + t.Fatalf("runWithReader error = %v, want git dependency error", err) + } +} + +func TestHumanGateDryRunShowsAllStateArtifacts(t *testing.T) { + worktree := t.TempDir() + t.Setenv("START_ISSUE_RUN_ID", "plan") + dir := filepath.Join(worktree, ".start-issue", "runs", "plan") + output := captureStdout(t, func() { + if err := humanGate("", worktree, "prompt", true); err != nil { + t.Fatal(err) + } + }) + for _, want := range []string{ + "--output-last-message " + filepath.Join(dir, "last-message.txt"), + "> " + filepath.Join(dir, "events.jsonl"), + "Would write captured thread ID: " + filepath.Join(dir, "thread-id"), + } { + if !strings.Contains(output, want) { + t.Fatalf("dry-run output missing %q:\n%s", want, output) + } + } + if strings.Contains(output, "--ask-for-approval") { + t.Fatalf("dry-run includes obsolete --ask-for-approval argument:\n%s", output) + } + if _, err := os.Stat(dir); !os.IsNotExist(err) { + t.Fatalf("human-gate dry-run created state directory: %v", err) + } +} + +func TestHumanGatePreservesCallerWorkingDirectory(t *testing.T) { + worktree, bin, log := t.TempDir(), t.TempDir(), filepath.Join(t.TempDir(), "cwd") + writeFakeCodex(t, bin) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("START_ISSUE_CWD_LOG", log) + t.Setenv("START_ISSUE_RUN_ID", "cwd") + t.Setenv("CODEX_EVENTS", `{"type":"thread.started","thread_id":"thread-cwd"}`) + t.Setenv("CODEX_LAST", "STATUS: DONE") + want, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := humanGate("", worktree, "prompt", false); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(log) + if err != nil { + t.Fatal(err) + } + if strings.TrimSpace(string(got)) != want { + t.Fatalf("working directory = %q, want %q", strings.TrimSpace(string(got)), want) + } +} + +func TestHumanGateRejectsDoneWithoutThreadID(t *testing.T) { + worktree, bin := t.TempDir(), t.TempDir() + writeFakeCodex(t, bin) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("START_ISSUE_RUN_ID", "missing-thread") + t.Setenv("CODEX_EVENTS", `{"type":"item.completed"}`) + t.Setenv("CODEX_LAST", "STATUS: DONE") + + err := humanGate("", worktree, "prompt", false) + if err == nil || !strings.Contains(err.Error(), "did not capture thread_id") { + t.Fatalf("got %v", err) + } +} + +func TestHumanGateResumeFailureReturnsExitCodeTwo(t *testing.T) { + worktree, bin := t.TempDir(), t.TempDir() + writeFakeCodex(t, bin) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("START_ISSUE_RUN_ID", "resume-failure") + t.Setenv("CODEX_EVENTS", `{"type":"thread.started","thread_id":"thread-resume"}`) + t.Setenv("CODEX_LAST", "STATUS: HUMAN_GATE") + t.Setenv("CODEX_RESUME_EXIT", "1") + + err := humanGate("", worktree, "prompt", false) + var exit exitError + if !errors.As(err, &exit) || exit.code != 2 { + t.Fatalf("got %T %v", err, err) + } +} + +func writeExecutable(t *testing.T, path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0755); err != nil { + t.Fatal(err) + } +} + +func writeFakeCodex(t *testing.T, bin string) { + t.Helper() + writeExecutable(t, filepath.Join(bin, "codex"), `#!/bin/sh +if [ "$START_ISSUE_FAKE_CODEX_REJECT_ASK_FOR_APPROVAL" = "1" ] && [ "${*#*--ask-for-approval}" != "$*" ]; then + printf '%s\n' "unexpected obsolete --ask-for-approval flag" >&2 + exit 1 +fi +if [ -n "$START_ISSUE_CWD_LOG" ]; then + pwd > "$START_ISSUE_CWD_LOG" +fi +if [ "$1" = "exec" ]; then + last="" + while [ "$#" -gt 0 ]; do + if [ "$1" = "--output-last-message" ]; then + last="$2" + shift 2 + continue + fi + shift + done + printf '%s\n' "$CODEX_EVENTS" + if [ "$CODEX_SKIP_LAST" != "1" ]; then + printf '%s\n' "$CODEX_LAST" > "$last" + fi + exit 0 +fi +if [ "$1" = "resume" ]; then + exit "${CODEX_RESUME_EXIT:-0}" +fi +exit 1 +`) +} + +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + previous := os.Stdout + reader, writer, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stdout = writer + defer func() { os.Stdout = previous }() + fn() + if err := writer.Close(); err != nil { + t.Fatal(err) + } + output, err := io.ReadAll(reader) + if err != nil { + t.Fatal(err) + } + return string(output) +} diff --git a/cmd/start-issue/parity_integration_test.go b/cmd/start-issue/parity_integration_test.go new file mode 100644 index 0000000..b1412d0 --- /dev/null +++ b/cmd/start-issue/parity_integration_test.go @@ -0,0 +1,1169 @@ +package main + +import ( + "crypto/sha256" + "errors" + "fmt" + "net" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "sort" + "strings" + "testing" +) + +// TestCLIParityHelper runs the public command in a subprocess so the parity +// cases exercise argument parsing, external command fakes, and side effects. +func TestCLIParityHelper(t *testing.T) { + if os.Getenv("START_ISSUE_PARITY_HELPER") != "1" { + return + } + for index, arg := range os.Args { + if arg == "--" { + os.Args = append([]string{"start-issue"}, os.Args[index+1:]...) + main() + return + } + } + t.Fatal("missing CLI argument separator") +} + +func TestCLIParityIssueFetchConfigWorktreeAndReuse(t *testing.T) { + fixture := newParityFixture(t) + + projectConfig := filepath.Join(fixture.repo, ".start-issue") + if err := os.MkdirAll(projectConfig, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(projectConfig, "agent"), []byte("none\n"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(fixture.home, ".config", "start-issue", "agent"), []byte("codex\n"), 0644); err != nil { + t.Fatal(err) + } + + output, err := fixture.run("", "1", "--dry-run", "--no-init") + if err != nil { + t.Fatalf("dry-run failed: %v\n%s", err, output) + } + for _, want := range []string{ + "Agent: none", + "Agent source: " + filepath.Join(projectConfig, "agent"), + "Fetching issue #1 from owner/repo", + "Would run: git worktree add -b feature/issue-1-add-login-button", + } { + if !strings.Contains(output, want) { + t.Fatalf("dry-run output missing %q:\n%s", want, output) + } + } + if log, err := os.ReadFile(fixture.ghLog); err != nil || !strings.Contains(string(log), "auth status") { + t.Fatalf("gh authentication preflight not recorded: %q, %v", log, err) + } + + output, err = fixture.run("", "1", "--agent", "none", "--no-init") + if err != nil { + t.Fatalf("creation failed: %v\n%s", err, output) + } + if log, err := os.ReadFile(fixture.gitLog); err != nil || !strings.Contains(string(log), "worktree add -b feature/issue-1-add-login-button") { + t.Fatalf("worktree creation not recorded: %q, %v", log, err) + } + + worktree := canonicalPath(filepath.Join(fixture.worktrees, "feature", "issue-1-add-login-button")) + if err := os.MkdirAll(worktree, 0755); err != nil { + t.Fatal(err) + } + fixture.pathBranch = true + output, err = fixture.run("1\n", "1", "--agent", "none", "--no-init", "--worktree-dir", fixture.worktrees) + if err != nil { + t.Fatalf("reuse failed: %v\n%s", err, output) + } + if !strings.Contains(output, "Worktree ready at: "+worktree) { + t.Fatalf("reuse output does not identify existing worktree:\n%s", output) + } +} + +func TestCLIPrintsVersionBannerOnExecutionPaths(t *testing.T) { + for _, test := range []struct { + name string + input string + args []string + }{ + {name: "issue workflow", args: []string{"1", "--agent", "none", "--no-init", "--dry-run"}}, + {name: "init", args: []string{"init", "--project", "--force", "--agent", "none"}}, + {name: "setup", input: "\n\n", args: []string{"setup", "--dry-run"}}, + // The fixture's release response is intentionally incomplete, but the + // banner must precede any update diagnostic. + {name: "update", args: []string{"update", "--dry-run"}}, + {name: "human-gate help", args: []string{"--human-gate-help"}}, + } { + t.Run(test.name, func(t *testing.T) { + output, _ := newParityFixture(t).run(test.input, test.args...) + if !strings.HasPrefix(output, "start-issue v") { + t.Fatalf("output does not start with the version banner:\n%s", output) + } + }) + } +} + +func TestCLIReusesOneReaderForOnboardingAndConflictResolution(t *testing.T) { + fixture := newParityFixture(t) + if err := os.RemoveAll(filepath.Join(fixture.home, ".config", "start-issue")); err != nil { + t.Fatal(err) + } + fixture.branchExists = true + if err := os.MkdirAll(fixture.fakeWorktree(), 0755); err != nil { + t.Fatal(err) + } + + output, err := fixture.run("n\n1\n", "1", "--agent", "none", "--no-init") + if err != nil { + t.Fatalf("onboarding then reuse failed: %v\n%s", err, output) + } + if !strings.Contains(output, "Worktree ready at: "+fixture.fakeWorktree()) { + t.Fatalf("conflict response was not available after onboarding:\n%s", output) + } +} + +func TestCLIDiagnosticsReportWorktreeDirectorySource(t *testing.T) { + fixture := newParityFixture(t) + + output, err := fixture.run("", "1", "--agent", "none", "--no-init", "--dry-run") + if err != nil { + t.Fatalf("environment-source dry-run failed: %v\n%s", err, output) + } + if !strings.Contains(output, "Worktree directory: "+fixture.worktrees+" (START_ISSUE_WORKTREE_DIR)") { + t.Fatalf("environment worktree source missing from diagnostics:\n%s", output) + } + + output, err = fixture.run("", "1", "--agent", "none", "--no-init", "--dry-run", "--worktree-dir", fixture.worktrees) + if err != nil { + t.Fatalf("CLI-source dry-run failed: %v\n%s", err, output) + } + if !strings.Contains(output, "Worktree directory: "+fixture.worktrees+" (CLI)") { + t.Fatalf("CLI worktree source missing from diagnostics:\n%s", output) + } +} + +func TestCLIDiagnosticsReportPromptSourceAndLocation(t *testing.T) { + fixture := newParityFixture(t) + prompt := filepath.Join(t.TempDir(), "prompt.md") + if err := os.WriteFile(prompt, []byte("Implement {ISSUE_URL}\n"), 0644); err != nil { + t.Fatal(err) + } + + output, err := fixture.run("", "1", "--agent", "none", "--no-init", "--dry-run", "--prompt-file", prompt) + if err != nil { + t.Fatalf("prompt-file dry-run failed: %v\n%s", err, output) + } + for _, want := range []string{"Prompt source: CLI --prompt-file: " + prompt, "Prompt location: " + prompt} { + if !strings.Contains(output, want) { + t.Fatalf("prompt diagnostics missing %q:\n%s", want, output) + } + } +} + +func TestCLIParityInitHookAndNoInit(t *testing.T) { + baseline := extractBashParityBaseline(t) + for _, test := range []struct { + name string + args []string + want bool + }{ + {name: "runs init hook", args: []string{"1", "--agent", "none"}, want: true}, + {name: "skips init hook", args: []string{"1", "--agent", "none", "--no-init"}, want: false}, + } { + t.Run(test.name, func(t *testing.T) { + bashFixture, goFixture := newParityFixture(t), newParityFixture(t) + bashFixture.initMarker = filepath.Join(t.TempDir(), "bash-init-ran") + goFixture.initMarker = filepath.Join(t.TempDir(), "go-init-ran") + + want := bashFixture.runBaseline(t, baseline, "", test.args...) + got := goFixture.runResult("", test.args...) + if want.exitCode != got.exitCode { + t.Fatalf("init case exit code differs: Bash=%d Go=%d", want.exitCode, got.exitCode) + } + for _, marker := range []string{bashFixture.initMarker, goFixture.initMarker} { + _, err := os.Stat(marker) + if (err == nil) != test.want { + t.Fatalf("init marker %s present=%t, want %t (stat error: %v)", marker, err == nil, test.want, err) + } + } + }) + } +} + +func TestCLIParityPreservesCaseSensitiveLabelMatching(t *testing.T) { + baseline := extractBashParityBaseline(t) + bashFixture, goFixture := newParityFixture(t), newParityFixture(t) + bashFixture.issueLabel = "Bug" + goFixture.issueLabel = "Bug" + args := []string{"1", "--agent", "none", "--no-init", "--dry-run"} + + want := bashFixture.runBaseline(t, baseline, "", args...) + got := goFixture.runResult("", args...) + assertParityResult(t, want, got, []parityOutputRecord{ + lineRecord("labels", `Labels: Bug`), + branchRecord(), + }) +} + +func TestCLIParityLargePromptDiagnostics(t *testing.T) { + baseline := extractBashParityBaseline(t) + bashFixture, goFixture := newParityFixture(t), newParityFixture(t) + prompt := strings.Repeat("x", 4001) + args := []string{"1", "--agent", "codex", "--no-init", "--dry-run", "--prompt", prompt} + + want := bashFixture.runBaseline(t, baseline, "", args...) + got := goFixture.runResult("", args...) + assertParityResult(t, want, got, []parityOutputRecord{ + lineRecord("prompt length", `Prompt length: 4001 chars`), + lineRecord("prompt omission", `Prompt omitted from command display because it is large\.`), + lineRecord("prompt omission guidance", `Set START_ISSUE_DUMP_PROMPT=1 to print the full rendered prompt\.`), + shellEscapedLineRecord("abbreviated rendered prompt", `\\?`), + }) +} + +func TestParityNormalizationPreservesSemanticRootIdentity(t *testing.T) { + fixture := newParityFixture(t) + // Worktree parents are allowed to be nested under a repository. The + // normalizer must retain that semantic ownership instead of reducing both + // paths to an indistinguishable temporary-directory token. + fixture.worktrees = filepath.Join(fixture.repo, "worktrees") + if err := os.MkdirAll(fixture.worktrees, 0755); err != nil { + t.Fatal(err) + } + for path := range map[string]struct{}{ + filepath.Join(fixture.home, "home-only"): {}, + filepath.Join(fixture.repo, "repo-only"): {}, + filepath.Join(fixture.worktrees, "worktree-only"): {}, + } { + if err := os.WriteFile(path, nil, 0644); err != nil { + t.Fatal(err) + } + } + + normalized := normalizeRawParityOutput(strings.Join([]string{ + filepath.Join(fixture.home, "home-only"), + filepath.Join(fixture.repo, "repo-only"), + filepath.Join(fixture.worktrees, "worktree-only"), + }, "\n"), fixture) + for _, want := range []string{ + "/home-only", + "/repo-only", + "/worktree-only", + } { + if !strings.Contains(normalized, want) { + t.Fatalf("normalized output missing %q:\n%s", want, normalized) + } + } + + filesystem := strings.Join(parityFilesystem(fixture), "\n") + for _, want := range []string{ + "/home-only", + "/repo-only", + "/worktree-only", + } { + if !strings.Contains(filesystem, want) { + t.Fatalf("filesystem record missing %q:\n%s", want, filesystem) + } + } +} + +func TestBaselineAndGoParityForCriticalIssueWorkflows(t *testing.T) { + baseline := extractBashParityBaseline(t) + for _, test := range []struct { + name string + args []string + input string + setup func(t *testing.T, fixture *parityFixture) + strict bool + intentionalDifferenceID string + records []parityOutputRecord + assertOutcome func(t *testing.T, result parityResult) + assertBaseline func(t *testing.T, result parityResult) + assertGo func(t *testing.T, result parityResult) + }{ + { + name: "help", + args: []string{"--help"}, + strict: false, + assertOutcome: func(t *testing.T, result parityResult) { + assertParityOutputContains(t, result, "Usage: start-issue [options]", "--agent") + }, + }, + { + name: "invalid-input", + args: []string{"--not-an-option"}, + strict: true, + records: []parityOutputRecord{lineRecord("unknown-option diagnostic", `Unknown option: --not-an-option`)}, + assertOutcome: func(t *testing.T, result parityResult) { + if result.exitCode == 0 { + t.Fatal("invalid option unexpectedly succeeded") + } + assertParityOutputContains(t, result, "Unknown option: --not-an-option") + }, + }, + { + name: "configuration-precedence", + args: []string{"1", "--no-init", "--dry-run"}, + records: []parityOutputRecord{ + lineRecord("agent", `Agent: .+`), lineRecord("agent source", `Agent source: .+`), + lineRecord("model", `Model: .+`), lineRecord("model source", `Model source: .+`), + lineRecord("worktree directory", `Worktree directory: .+`), lineRecord("prompt source", `Prompt source: .+`), + lineRecord("issue fetch", `Fetching issue #[0-9]+ from .+`), lineRecord("title", `Title: .+`), + lineRecord("labels", `Labels: .+`), branchRecord(), lineRecord("worktree path", `Path: .+`), + lineRecord("base branch", `Base:.*`), lineRecord("worktree creation", `\[DRY-RUN\] Would run: git worktree add .+`), + lineRecord("ready worktree", `Worktree ready at: .+`), + }, + setup: func(t *testing.T, fixture *parityFixture) { + config := filepath.Join(fixture.repo, ".start-issue") + if err := os.MkdirAll(config, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(config, "agent"), []byte("none\n"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(fixture.home, ".config", "start-issue", "agent"), []byte("codex\n"), 0644); err != nil { + t.Fatal(err) + } + }, + strict: true, + }, + { + name: "dry-run", args: []string{"1", "--agent", "none", "--no-init", "--dry-run"}, strict: true, + records: []parityOutputRecord{ + lineRecord("agent", `Agent: .+`), lineRecord("agent source", `Agent source: .+`), + lineRecord("worktree directory", `Worktree directory: .+`), lineRecord("prompt source", `Prompt source: .+`), + lineRecord("issue fetch", `Fetching issue #[0-9]+ from .+`), lineRecord("title", `Title: .+`), + lineRecord("labels", `Labels: .+`), branchRecord(), lineRecord("worktree path", `Path: .+`), + lineRecord("base branch", `Base:.*`), lineRecord("worktree creation", `\[DRY-RUN\] Would run: git worktree add .+`), + lineRecord("ready worktree", `Worktree ready at: .+`), + }, + }, + { + name: "worktree-creation", args: []string{"1", "--agent", "none", "--no-init"}, strict: true, + records: []parityOutputRecord{ + lineRecord("agent", `Agent: .+`), lineRecord("agent source", `Agent source: .+`), + lineRecord("worktree directory", `Worktree directory: .+`), lineRecord("prompt source", `Prompt source: .+`), + lineRecord("issue fetch", `Fetching issue #[0-9]+ from .+`), lineRecord("title", `Title: .+`), + lineRecord("labels", `Labels: .+`), branchRecord(), lineRecord("worktree path", `Path: .+`), + lineRecord("base branch", `Base:.*`), lineRecord("ready worktree", `Worktree ready at: .+`), + }, + }, + { + name: "branch-worktree-reuse", + args: []string{"1", "--agent", "none", "--no-init"}, + input: "1\n", + records: []parityOutputRecord{ + lineRecord("agent", `Agent: .+`), lineRecord("agent source", `Agent source: .+`), + lineRecord("worktree directory", `Worktree directory: .+`), lineRecord("prompt source", `Prompt source: .+`), + lineRecord("issue fetch", `Fetching issue #[0-9]+ from .+`), branchRecord(), lineRecord("worktree path", `Path: .+`), + lineRecord("existing worktree", `Existing worktree: .+`), lineRecord("ready worktree", `Worktree ready at: .+`), + }, + setup: func(t *testing.T, fixture *parityFixture) { + fixture.branchExists = true + if err := os.MkdirAll(fixture.fakeWorktree(), 0755); err != nil { + t.Fatal(err) + } + }, + strict: true, + }, + { + name: "branch-conflict-suffix", + args: []string{"1", "--agent", "none", "--no-init"}, + input: "2\n", + records: []parityOutputRecord{ + lineRecord("agent", `Agent: .+`), lineRecord("worktree directory", `Worktree directory: .+`), + lineRecord("issue fetch", `Fetching issue #[0-9]+ from .+`), branchRecord(), lineRecord("worktree path", `Path: .+`), + lineRecord("new branch", `New branch name: .+`), lineRecord("ready worktree", `Worktree ready at: .+`), + }, + setup: func(t *testing.T, fixture *parityFixture) { + fixture.branchExists = true + }, + strict: true, + }, + { + name: "worktree-path-reuse", + args: []string{"1", "--agent", "none", "--no-init"}, + input: "1\n", + records: []parityOutputRecord{ + lineRecord("agent", `Agent: .+`), lineRecord("worktree directory", `Worktree directory: .+`), + lineRecord("issue fetch", `Fetching issue #[0-9]+ from .+`), branchRecord(), lineRecord("worktree path", `Path: .+`), + lineRecord("registered branch", `Registered branch: .+`), lineRecord("ready worktree", `Worktree ready at: .+`), + }, + setup: func(t *testing.T, fixture *parityFixture) { + fixture.pathBranch = true + if err := os.MkdirAll(fixture.fakeWorktree(), 0755); err != nil { + t.Fatal(err) + } + }, + strict: true, + }, + { + name: "worktree-path-conflict-dry-run", + args: []string{"1", "--agent", "none", "--no-init", "--dry-run"}, + input: "1\n", + // ID-01 is the approved dry-run safety difference documented in + // memory-bank/features/FT-016/decision-log.md. + intentionalDifferenceID: "ID-01", + setup: func(t *testing.T, fixture *parityFixture) { + fixture.pathBranch = true + if err := os.MkdirAll(fixture.fakeWorktree(), 0755); err != nil { + t.Fatal(err) + } + }, + strict: false, + // Bash consumed the supplied choice in dry-run. The Go command's + // documented safety contract instead reports that it would prompt, + // without selecting a destructive or reuse action. + assertBaseline: func(t *testing.T, result parityResult) { + assertParityOutputContains(t, result, "Worktree path already exists") + }, + assertGo: func(t *testing.T, result parityResult) { + assertParityOutputContains(t, result, "Worktree path exists; would prompt for reuse or delete/recreate") + }, + }, + } { + t.Run(test.name, func(t *testing.T) { + bashFixture, goFixture := newParityFixture(t), newParityFixture(t) + if test.setup != nil { + test.setup(t, &bashFixture) + test.setup(t, &goFixture) + } + if test.intentionalDifferenceID != "" && (test.strict || test.assertBaseline == nil || test.assertGo == nil) { + t.Fatalf("intentional difference %s must declare distinct baseline and Go expectations", test.intentionalDifferenceID) + } + want := bashFixture.runBaseline(t, baseline, test.input, test.args...) + got := goFixture.runResult(test.input, test.args...) + if test.strict { + assertParityResult(t, want, got, test.records) + } + if test.intentionalDifferenceID != "" { + assertParitySideEffects(t, want, got) + } + if test.assertOutcome != nil { + t.Run("bash-contract", func(t *testing.T) { test.assertOutcome(t, want) }) + t.Run("go-contract", func(t *testing.T) { test.assertOutcome(t, got) }) + } + if test.assertBaseline != nil { + t.Run("bash-contract", func(t *testing.T) { test.assertBaseline(t, want) }) + } + if test.assertGo != nil { + t.Run("go-contract", func(t *testing.T) { test.assertGo(t, got) }) + } + }) + } + + for _, agent := range []string{"claude", "codex", "kimi", "pi", "none"} { + t.Run("agent-launch-"+agent, func(t *testing.T) { + bashFixture, goFixture := newParityFixture(t), newParityFixture(t) + args := []string{"1", "--agent", agent, "--model", "fixture-model", "--no-init", "--dry-run"} + want := bashFixture.runBaseline(t, baseline, "", args...) + got := goFixture.runResult("", args...) + assertAgentLaunchParity(t, want, got, agent) + assertAgentLaunchContract(t, want, agent) + assertAgentLaunchContract(t, got, agent) + }) + } +} + +type parityResult struct { + exitCode int + rawOutput string + gitLog string + ghLog string + filesystem []string +} + +// parityOutputRecord belongs to one named parity case. It compares a +// user-observable record that is stable across runtimes while letting the case +// retain its own output contract. We do not use a global output whitelist: +// every strict case explicitly declares the records it must preserve. +type parityOutputRecord struct { + name string + pattern *regexp.Regexp + normalize func(string) string +} + +func lineRecord(name, expression string) parityOutputRecord { + return parityOutputRecord{name: name, pattern: regexp.MustCompile(expression)} +} + +func branchRecord() parityOutputRecord { + return parityOutputRecord{ + name: "branch", + pattern: regexp.MustCompile(`Branch: .+`), + normalize: func(record string) string { + // Only Bash's elapsed-time decoration differs; the branch and its + // source remain part of the comparison. + return regexp.MustCompile(` \([0-9]+s, `).ReplaceAllString(record, " (") + }, + } +} + +func shellEscapedLineRecord(name, expression string) parityOutputRecord { + return parityOutputRecord{ + name: name, + pattern: regexp.MustCompile(expression), + // Bash shell-escapes the placeholder in its displayed command, while + // Go prints the rendered prompt directly. The placeholder contents are + // the contract; its command-display quoting is not. + normalize: func(record string) string { + return strings.ReplaceAll(record, `\`, "") + }, + } +} + +func assertParityOutputContains(t *testing.T, result parityResult, values ...string) { + t.Helper() + for _, value := range values { + if !strings.Contains(result.rawOutput, value) { + t.Fatalf("output missing %q:\n%s", value, result.rawOutput) + } + } +} + +func assertAgentLaunchContract(t *testing.T, result parityResult, agent string) { + t.Helper() + assertParityOutputContains(t, result, "Agent: "+agent) + if agent == "none" { + assertParityOutputContains(t, result, "Worktree ready at:", "Suggested agent commands:") + return + } + commands := map[string]string{ + "claude": "claude --model fixture-model --dangerously-skip-permissions", + "codex": "codex --model fixture-model --cd", + "kimi": "cd /", + "pi": "pi --model fixture-model", + } + assertParityOutputContains(t, result, "[DRY-RUN] Would run:", commands[agent]) +} + +func assertAgentLaunchParity(t *testing.T, want, got parityResult, agent string) { + t.Helper() + if want.exitCode != got.exitCode { + t.Fatalf("%s launch exit code differs: Bash=%d Go=%d", agent, want.exitCode, got.exitCode) + } + if bashRecord, goRecord := agentLaunchRecord(want.rawOutput, agent), agentLaunchRecord(got.rawOutput, agent); bashRecord != goRecord { + t.Fatalf("%s launch contract differs:\nBash:\n%s\nGo:\n%s", agent, bashRecord, goRecord) + } +} + +func agentLaunchRecord(output, agent string) string { + var records []string + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "Agent: ") { + records = append(records, line) + } + if agent != "none" && strings.Contains(line, "[DRY-RUN] Would run:") && strings.Contains(line, agent+" ") { + records = append(records, launchAdapterPrefix(line, agent)) + } + if agent == "none" && (strings.Contains(line, "Worktree ready at:") || line == "Suggested agent commands:") { + records = append(records, line) + } + } + return strings.Join(records, "\n") +} + +func launchAdapterPrefix(command, agent string) string { + markers := map[string]string{ + "claude": "--dangerously-skip-permissions", + "codex": "--dangerously-bypass-approvals-and-sandbox", + "kimi": "-p", + "pi": "pi --model fixture-model", + } + marker := markers[agent] + if index := strings.Index(command, marker); index >= 0 { + return command[:index+len(marker)] + } + return command +} + +func extractBashParityBaseline(t *testing.T) string { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("the Bash baseline oracle is POSIX-only") + } + root := repoRoot(t) + return filepath.Join(root, "cmd", "start-issue", "testdata", "bash-v1", "scripts", "start-issue") +} + +func assertParityResult(t *testing.T, want, got parityResult, records []parityOutputRecord) { + t.Helper() + if want.exitCode != got.exitCode { + t.Fatalf("exit code differs: Bash=%d Go=%d\nBash output:\n%s\nGo output:\n%s", want.exitCode, got.exitCode, want.rawOutput, got.rawOutput) + } + if len(records) == 0 { + t.Fatal("strict parity case has no declared output records") + } + for _, record := range records { + wantRecord := parityRecord(t, want.rawOutput, record) + gotRecord := parityRecord(t, got.rawOutput, record) + if wantRecord != gotRecord { + t.Fatalf("%s output differs:\nBash: %s\nGo: %s", record.name, wantRecord, gotRecord) + } + } + assertParitySideEffects(t, want, got) +} + +func assertParitySideEffects(t *testing.T, want, got parityResult) { + t.Helper() + if want.gitLog != got.gitLog || want.ghLog != got.ghLog { + t.Fatalf("fake command logs differ:\nBash git=%q gh=%q\nGo git=%q gh=%q", want.gitLog, want.ghLog, got.gitLog, got.ghLog) + } + if strings.Join(want.filesystem, "\n") != strings.Join(got.filesystem, "\n") { + t.Fatalf("filesystem state differs:\nBash=%v\nGo=%v", want.filesystem, got.filesystem) + } +} + +func parityRecord(t *testing.T, output string, record parityOutputRecord) string { + t.Helper() + match := record.pattern.FindString(output) + if match == "" { + t.Fatalf("%s record missing from output:\n%s", record.name, output) + } + match = strings.TrimSpace(match) + if record.normalize != nil { + match = record.normalize(match) + } + return match +} + +func TestCLIParityDryRunResolvesBranchConflicts(t *testing.T) { + baseline := extractBashParityBaseline(t) + for _, test := range []struct { + name, input string + setup func(*testing.T, *parityFixture) + records []parityOutputRecord + }{ + { + name: "reuse attached worktree", + input: "1\n", + setup: func(t *testing.T, fixture *parityFixture) { + fixture.branchExists = true + if err := os.MkdirAll(fixture.fakeWorktree(), 0755); err != nil { + t.Fatal(err) + } + }, + records: []parityOutputRecord{ + lineRecord("existing worktree", `Existing worktree: .+`), + lineRecord("ready worktree", `Worktree ready at: .+`), + }, + }, + { + name: "suffix branch", + input: "2\n", + setup: func(_ *testing.T, fixture *parityFixture) { + fixture.branchExists = true + }, + records: []parityOutputRecord{ + lineRecord("new branch", `New branch name: .+`), + lineRecord("worktree creation", `\[DRY-RUN\] Would run: git worktree add .+`), + lineRecord("ready worktree", `Worktree ready at: .+`), + }, + }, + { + name: "delete and recreate", + input: "3\n", + setup: func(_ *testing.T, fixture *parityFixture) { + fixture.branchExists = true + }, + records: []parityOutputRecord{ + lineRecord("worktree creation", `\[DRY-RUN\] Would run: git worktree add .+`), + lineRecord("ready worktree", `Worktree ready at: .+`), + }, + }, + } { + t.Run(test.name, func(t *testing.T) { + bashFixture, goFixture := newParityFixture(t), newParityFixture(t) + test.setup(t, &bashFixture) + test.setup(t, &goFixture) + args := []string{"1", "--agent", "none", "--no-init", "--dry-run"} + want := bashFixture.runBaseline(t, baseline, test.input, args...) + got := goFixture.runResult(test.input, args...) + assertParityResult(t, want, got, test.records) + }) + } +} + +func TestCLIAIBranchReportsOneAIRecord(t *testing.T) { + fixture := newParityFixture(t) + writeExecutable(t, filepath.Join(fixture.bin, "pi"), "#!/bin/sh\nprintf '%s\\n' feature/issue-1-add-login-button\n") + + output, err := fixture.run("", "1", "--agent", "pi", "--ai", "--no-init", "--dry-run") + if err != nil { + t.Fatalf("AI dry-run failed: %v\n%s", err, output) + } + branchRecords := 0 + for _, line := range strings.Split(output, "\n") { + if strings.TrimSpace(line) == "Branch: feature/issue-1-add-login-button (ai:pi)" { + branchRecords++ + } + } + if branchRecords != 1 { + t.Fatalf("AI branch was reported %d times:\n%s", branchRecords, output) + } + if !strings.Contains(output, "Branch: feature/issue-1-add-login-button (ai:pi)") || strings.Contains(output, "Branch: feature/issue-1-add-login-button (fast)") { + t.Fatalf("AI branch source is contradictory:\n%s", output) + } +} + +func TestCLIParityInstallerWorkflow(t *testing.T) { + root := repoRoot(t) + dir := t.TempDir() + asset := filepath.Join(dir, "start-issue") + writeExecutable(t, asset, "#!/bin/sh\n[ \"$1\" = --version ] && echo 'start-issue v2.1.0'\n") + checksum := sha256.Sum256([]byte("#!/bin/sh\n[ \"$1\" = --version ] && echo 'start-issue v2.1.0'\n")) + manifest := filepath.Join(dir, "checksums.txt") + if err := os.WriteFile(manifest, []byte(fmt.Sprintf("%x start-issue\n", checksum)), 0644); err != nil { + t.Fatal(err) + } + target := filepath.Join(dir, "bin", "start-issue") + command := exec.Command(requireBash(t), filepath.Join(root, "install.sh")) + command.Env = append(os.Environ(), + "START_ISSUE_ASSET_URL=file://"+asset, + "START_ISSUE_CHECKSUM_URL=file://"+manifest, + "TARGET="+target, + "BINDIR="+filepath.Dir(target), + ) + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("installer failed: %v\n%s", err, output) + } + versionOutput, err := exec.Command(target, "--version").CombinedOutput() + if err != nil || strings.TrimSpace(string(versionOutput)) != "start-issue v2.1.0" { + t.Fatalf("installed version = %q, %v", versionOutput, err) + } +} + +func TestInstallerChecksumNameExcludesURLQuery(t *testing.T) { + bash := requireBash(t) + assetName, err := releaseAssetName(runtime.GOOS, runtime.GOARCH) + if err != nil { + t.Skipf("unsupported test platform: %v", err) + } + asset := []byte("#!/bin/sh\n[ \"$1\" = --version ] && echo 'start-issue v2.1.0'\n") + checksum := fmt.Sprintf("%x %s\n", sha256.Sum256(asset), assetName) + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Skipf("local HTTP listener unavailable: %v", err) + } + server := httptest.NewUnstartedServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case "/" + assetName: + if request.URL.Query().Get("token") != "presigned" { + http.Error(response, "missing token", http.StatusBadRequest) + return + } + _, _ = response.Write(asset) + case "/checksums.txt": + _, _ = response.Write([]byte(checksum)) + default: + http.NotFound(response, request) + } + })) + server.Listener = listener + server.Start() + defer server.Close() + + target := filepath.Join(t.TempDir(), "bin", "start-issue") + command := exec.Command(bash, filepath.Join(repoRoot(t), "install.sh")) + command.Env = append(os.Environ(), + "START_ISSUE_ASSET_URL="+server.URL+"/"+assetName+"?token=presigned", + "START_ISSUE_CHECKSUM_URL="+server.URL+"/checksums.txt", + "TARGET="+target, + "BINDIR="+filepath.Dir(target), + ) + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("installer rejected the checksummed presigned asset URL: %v\n%s", err, output) + } +} + +func TestInstallerDefaultsEachURLIndependently(t *testing.T) { + bash := requireBash(t) + root := repoRoot(t) + bin := t.TempDir() + writeExecutable(t, filepath.Join(bin, "curl"), "#!/bin/sh\nexit 1\n") + assetName, err := releaseAssetName(runtime.GOOS, runtime.GOARCH) + if err != nil { + t.Skipf("unsupported test platform: %v", err) + } + defaultAsset := "https://github.com/dapi/start-issue/releases/latest/download/" + assetName + defaultChecksum := "https://github.com/dapi/start-issue/releases/latest/download/checksums.txt" + + for _, test := range []struct { + name, asset, checksum, wantAsset, wantChecksum string + }{ + { + name: "custom asset keeps default checksum", + asset: "https://example.test/custom/start-issue", + wantAsset: "https://example.test/custom/start-issue", + wantChecksum: defaultChecksum, + }, + { + name: "custom checksum keeps default asset", + checksum: "https://example.test/custom/checksums.txt", + wantAsset: defaultAsset, + wantChecksum: "https://example.test/custom/checksums.txt", + }, + } { + t.Run(test.name, func(t *testing.T) { + command := exec.Command(bash, filepath.Join(root, "install.sh"), "--debug") + command.Env = append(os.Environ(), + "START_ISSUE_ASSET_URL="+test.asset, + "START_ISSUE_CHECKSUM_URL="+test.checksum, + "BINDIR="+t.TempDir(), + "TARGET="+filepath.Join(t.TempDir(), "start-issue"), + "PATH="+bin+string(os.PathListSeparator)+os.Getenv("PATH"), + ) + output, err := command.CombinedOutput() + if err == nil { + t.Fatal("installer unexpectedly succeeded with a failing curl fixture") + } + for _, want := range []string{"Asset URL: " + test.wantAsset, "Checksum URL: " + test.wantChecksum} { + if !strings.Contains(string(output), want) { + t.Fatalf("installer did not resolve %q:\n%s", want, output) + } + } + }) + } +} + +func requireBash(t *testing.T) string { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("install.sh is a POSIX Bash installer") + } + bash, err := exec.LookPath("bash") + if err != nil { + t.Skip("Bash is unavailable; installer integration test is not applicable") + } + return bash +} + +func TestCLIParityUpdateWorkflowPreservesInvocationSymlink(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows update is intentionally manual") + } + root := repoRoot(t) + dir, bin := t.TempDir(), t.TempDir() + target := filepath.Join(dir, "start-issue-target") + invocation := filepath.Join(dir, "start-issue") + build := exec.Command("go", "build", "-o", target, "./cmd/start-issue") + build.Dir = root + if output, err := build.CombinedOutput(); err != nil { + t.Fatalf("build update fixture: %v\n%s", err, output) + } + if err := os.Symlink(target, invocation); err != nil { + t.Fatal(err) + } + assetName, err := releaseAssetName(runtime.GOOS, runtime.GOARCH) + if err != nil { + t.Skipf("unsupported test platform: %v", err) + } + asset := []byte("#!/bin/sh\nif [ \"$1\" = --version ]; then echo 'start-issue v2.1.0'; exit 0; fi\n") + checksum := fmt.Sprintf("%x %s\n", sha256.Sum256(asset), assetName) + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Skipf("local HTTP listener unavailable: %v", err) + } + server := httptest.NewUnstartedServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case "/asset": + _, _ = response.Write(asset) + case "/checksums": + _, _ = response.Write([]byte(checksum)) + default: + http.NotFound(response, request) + } + })) + server.Listener = listener + server.Start() + defer server.Close() + release := filepath.Join(dir, "release.json") + metadata := fmt.Sprintf(`{"tag_name":"v2.1.0","assets":[{"name":"%s","browser_download_url":"%s/asset"},{"name":"checksums.txt","browser_download_url":"%s/checksums"}]}`, assetName, server.URL, server.URL) + if err := os.WriteFile(release, []byte(metadata), 0644); err != nil { + t.Fatal(err) + } + writeExecutable(t, filepath.Join(bin, "gh"), "#!/bin/sh\nif [ \"$1\" = auth ]; then exit 0; fi\ncat \"$START_ISSUE_FAKE_RELEASE\"\n") + command := exec.Command(invocation, "update") + command.Env = append(os.Environ(), "PATH="+bin+string(os.PathListSeparator)+os.Getenv("PATH"), "START_ISSUE_FAKE_RELEASE="+release) + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("update failed: %v\n%s", err, output) + } + info, err := os.Lstat(invocation) + if err != nil { + t.Fatal(err) + } + if info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("update replaced invocation symlink %s instead of its target", invocation) + } + versionOutput, err := exec.Command(invocation, "--version").CombinedOutput() + if err != nil || strings.TrimSpace(string(versionOutput)) != "start-issue v2.1.0" { + t.Fatalf("updated version = %q, %v", versionOutput, err) + } +} + +func TestV1UpdateBridgeInstallsVerifiedPlatformBinary(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("the v1 update bridge is a POSIX compatibility asset") + } + root, dir := repoRoot(t), t.TempDir() + assetName, err := releaseAssetName(runtime.GOOS, runtime.GOARCH) + if err != nil { + t.Skipf("unsupported test platform: %v", err) + } + asset := filepath.Join(dir, assetName) + assetBody := []byte("#!/bin/sh\n[ \"$1\" = --version ] && echo 'start-issue v2.0.0'\n") + if err := os.WriteFile(asset, assetBody, 0755); err != nil { + t.Fatal(err) + } + checksums := filepath.Join(dir, "checksums.txt") + if err := os.WriteFile(checksums, []byte(fmt.Sprintf("%x %s\n", sha256.Sum256(assetBody), assetName)), 0644); err != nil { + t.Fatal(err) + } + bridge := filepath.Join(dir, "start-issue") + body, err := os.ReadFile(filepath.Join(root, "scripts", "v1-upgrade-shim")) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(bridge, body, 0755); err != nil { + t.Fatal(err) + } + command := exec.Command(requireBash(t), bridge, "--version") + command.Env = append(os.Environ(), "START_ISSUE_UPGRADE_ASSET_URL=file://"+asset, "START_ISSUE_UPGRADE_CHECKSUM_URL=file://"+checksums) + output, err := command.CombinedOutput() + if err != nil || strings.TrimSpace(string(output)) != "start-issue v2.0.0" { + t.Fatalf("v1 bridge = %q, %v", output, err) + } + installed, err := os.ReadFile(bridge) + if err != nil || string(installed) != string(assetBody) { + t.Fatalf("v1 bridge did not replace itself with the verified binary: %v", err) + } +} + +func TestGoReleaserBridgeChecksumNamesPublishedAsset(t *testing.T) { + config, err := os.ReadFile(filepath.Join(repoRoot(t), ".goreleaser.yaml")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(config), "(cd .release && sha256sum start-issue > start-issue.sha256)") { + t.Fatal("v1 bridge checksum must name start-issue, the published asset") + } +} + +type parityFixture struct { + home, repo, bin, worktrees, gitLog, ghLog, initMarker string + issueLabel string + branchExists, pathBranch bool +} + +func (fixture parityFixture) fakeWorktree() string { + return canonicalPath(filepath.Join(fixture.worktrees, "feature", "issue-1-add-login-button")) +} + +func newParityFixture(t *testing.T) parityFixture { + t.Helper() + fixture := parityFixture{home: t.TempDir(), repo: t.TempDir(), bin: t.TempDir(), worktrees: t.TempDir(), gitLog: filepath.Join(t.TempDir(), "git.log"), ghLog: filepath.Join(t.TempDir(), "gh.log")} + if err := os.MkdirAll(filepath.Join(fixture.home, ".config", "start-issue"), 0755); err != nil { + t.Fatal(err) + } + writeExecutable(t, filepath.Join(fixture.bin, "git"), `#!/bin/sh +if [ "$1" = "-C" ]; then + printf '%s\n' "$START_ISSUE_TEST_REPO" + exit 0 +fi +case "$1 $2 $3" in + "rev-parse --show-toplevel ") printf '%s\n' "$START_ISSUE_TEST_REPO" ;; + "remote get-url origin") printf '%s\n' 'git@github.com:owner/repo.git' ;; + "symbolic-ref refs/remotes/origin/HEAD") printf '%s\n' 'refs/remotes/origin/master' ;; + show-ref\ --verify\ *) [ "${START_ISSUE_FAKE_BRANCH_EXISTS:-}" = 1 ] && [ "$4" = "refs/heads/feature/issue-1-add-login-button" ] && exit 0; exit 1 ;; + "worktree list --porcelain") + if [ "${START_ISSUE_FAKE_BRANCH_EXISTS:-}" = 1 ] || [ "${START_ISSUE_FAKE_PATH_BRANCH:-}" = 1 ]; then + printf 'worktree %s\nbranch refs/heads/feature/issue-1-add-login-button\n' "$START_ISSUE_FAKE_WORKTREE" + fi + ;; + "fetch origin master") ;; + "worktree add -b") + mkdir -p "$5" + if [ -n "${START_ISSUE_INIT_MARKER:-}" ]; then + printf '%s\n' '#!/bin/sh' 'touch "$START_ISSUE_INIT_MARKER"' > "$5/init.sh" + chmod +x "$5/init.sh" + fi + printf '%s\n' "$*" >> "$START_ISSUE_GIT_LOG" + ;; +esac +`) + writeExecutable(t, filepath.Join(fixture.bin, "gh"), `#!/bin/sh +printf '%s\n' "$*" >> "$START_ISSUE_GH_LOG" +if [ "$1" = auth ] && [ "$2" = status ]; then exit 0; fi +if [ "$1" = api ]; then + printf '{"title":"Add login button","body":"Fixture body","labels":[{"name":"%s"}]}\n' "${START_ISSUE_FAKE_LABEL:-feature}" +fi +`) + writeExecutable(t, filepath.Join(fixture.bin, "jq"), `#!/bin/sh +case "$*" in + *".title"*) printf '%s\n' 'Add login button' ;; + *".body"*) printf '%s\n' 'Fixture body' ;; + *".labels"*) printf '%s\n' "${START_ISSUE_FAKE_LABEL:-feature}" ;; +esac +`) + writeExecutable(t, filepath.Join(fixture.bin, "zellij-tab-status"), "#!/bin/sh\nexit 0\n") + return fixture +} + +func (fixture parityFixture) run(input string, args ...string) (string, error) { + command := exec.Command(os.Args[0], append([]string{"-test.run=^TestCLIParityHelper$", "--"}, args...)...) + return fixture.runCommand(command, input) +} + +func (fixture parityFixture) runResult(input string, args ...string) parityResult { + output, err := fixture.run(input, args...) + return fixture.result(output, err) +} + +func (fixture parityFixture) runBaseline(t *testing.T, baseline, input string, args ...string) parityResult { + command := exec.Command(requireBash(t), append([]string{baseline}, args...)...) + output, err := fixture.runCommand(command, input) + return fixture.result(output, err) +} + +func (fixture parityFixture) result(output string, err error) parityResult { + gitLog, _ := os.ReadFile(fixture.gitLog) + ghLog, _ := os.ReadFile(fixture.ghLog) + return parityResult{ + exitCode: commandExitCode(err), + rawOutput: normalizeRawParityOutput(output, fixture), + gitLog: normalizeRawParityOutput(string(gitLog), fixture), + ghLog: normalizeRawParityOutput(string(ghLog), fixture), + filesystem: parityFilesystem(fixture), + } +} + +func normalizeRawParityOutput(output string, fixture parityFixture) string { + stripANSI := regexp.MustCompile(`\x1b\[[0-9;]*m`) + output = stripANSI.ReplaceAllString(output, "") + // Preserve which semantic root owns a path. In particular, the worktree + // parent can be located inside the repository or home directory, so its + // more-specific path must be substituted before its containing root. + for _, root := range parityRoots(fixture) { + for _, path := range root.paths() { + output = strings.ReplaceAll(output, path, root.token) + } + } + return output +} + +func commandExitCode(err error) int { + if err == nil { + return 0 + } + var exited *exec.ExitError + if errors.As(err, &exited) { + return exited.ExitCode() + } + return -1 +} + +func parityFilesystem(fixture parityFixture) []string { + var paths []string + for _, root := range parityRoots(fixture) { + _ = filepath.Walk(root.path, func(path string, info os.FileInfo, err error) error { + if err != nil || path == root.path { + return err + } + rel, relErr := filepath.Rel(root.path, path) + if relErr == nil { + if info.IsDir() { + rel += "/" + } + paths = append(paths, root.token+"/"+filepath.ToSlash(rel)) + } + return nil + }) + } + sort.Strings(paths) + return paths +} + +type parityRoot struct { + token, path string +} + +func (root parityRoot) paths() []string { + paths := []string{canonicalPath(root.path), root.path} + sort.SliceStable(paths, func(i, j int) bool { return len(paths[i]) > len(paths[j]) }) + return uniqueStrings(paths) +} + +func parityRoots(fixture parityFixture) []parityRoot { + roots := []parityRoot{ + {token: "", path: fixture.home}, + {token: "", path: fixture.repo}, + {token: "", path: fixture.worktrees}, + } + // Nested roots require the longest source paths to be normalized first; + // otherwise replacing a parent would erase the child's identity. + sort.SliceStable(roots, func(i, j int) bool { + return len(canonicalPath(roots[i].path)) > len(canonicalPath(roots[j].path)) + }) + return roots +} + +func uniqueStrings(values []string) []string { + seen := make(map[string]struct{}, len(values)) + result := make([]string, 0, len(values)) + for _, value := range values { + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + result = append(result, value) + } + return result +} + +func (fixture parityFixture) runCommand(command *exec.Cmd, input string) (string, error) { + command.Dir = fixture.repo + command.Stdin = strings.NewReader(input) + command.Env = append(os.Environ(), + "START_ISSUE_PARITY_HELPER=1", + "HOME="+fixture.home, + "PATH="+fixture.bin+string(os.PathListSeparator)+os.Getenv("PATH"), + "START_ISSUE_TEST_REPO="+fixture.repo, + "START_ISSUE_GIT_LOG="+fixture.gitLog, + "START_ISSUE_GH_LOG="+fixture.ghLog, + "START_ISSUE_WORKTREE_DIR="+fixture.worktrees, + "START_ISSUE_INIT_MARKER="+fixture.initMarker, + ) + if fixture.branchExists { + command.Env = append(command.Env, "START_ISSUE_FAKE_BRANCH_EXISTS=1") + } + if fixture.pathBranch { + command.Env = append(command.Env, "START_ISSUE_FAKE_PATH_BRANCH=1") + } + if fixture.branchExists || fixture.pathBranch { + command.Env = append(command.Env, "START_ISSUE_FAKE_WORKTREE="+fixture.fakeWorktree()) + } + if fixture.issueLabel != "" { + command.Env = append(command.Env, "START_ISSUE_FAKE_LABEL="+fixture.issueLabel) + } + output, err := command.CombinedOutput() + return string(output), err +} + +func repoRoot(t *testing.T) string { + t.Helper() + current, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + return filepath.Clean(filepath.Join(current, "..", "..")) +} diff --git a/cmd/start-issue/testdata/bash-v1/README.md b/cmd/start-issue/testdata/bash-v1/README.md new file mode 100644 index 0000000..47becd9 --- /dev/null +++ b/cmd/start-issue/testdata/bash-v1/README.md @@ -0,0 +1,6 @@ +# Frozen Bash parity baseline + +This directory preserves the pre-Go `scripts/` tree from commit +`d658db620836c4113e5a49326b5c69012c3e1f18`. The parity integration tests use +it as their immutable Bash oracle, so the test suite does not require Git +history to be available at runtime. diff --git a/scripts/build-start-issue b/cmd/start-issue/testdata/bash-v1/scripts/build-start-issue similarity index 100% rename from scripts/build-start-issue rename to cmd/start-issue/testdata/bash-v1/scripts/build-start-issue diff --git a/scripts/bump-version b/cmd/start-issue/testdata/bash-v1/scripts/bump-version similarity index 100% rename from scripts/bump-version rename to cmd/start-issue/testdata/bash-v1/scripts/bump-version diff --git a/cmd/start-issue/testdata/bash-v1/scripts/check_memory_bank_index.py b/cmd/start-issue/testdata/bash-v1/scripts/check_memory_bank_index.py new file mode 100755 index 0000000..4120466 --- /dev/null +++ b/cmd/start-issue/testdata/bash-v1/scripts/check_memory_bank_index.py @@ -0,0 +1,845 @@ +#!/usr/bin/env python3 +"""Audit markdown navigation integrity for a memory-bank-like documentation tree.""" + +from __future__ import annotations + +import argparse +import json +import os +import posixpath +import re +import sys +from collections import defaultdict, deque +from pathlib import Path + + +IGNORED_DIRS = {".git", ".hg", ".svn", ".venv", "node_modules", "tmp", "log", "vendor"} + +DEFAULT_SCOPE_ROOT = "memory-bank" +DEFAULT_MAX_DEPTH = 3 + +FENCED_CODE_BLOCK_RE = re.compile(r"```.*?```", re.DOTALL) +MARKDOWN_LINK_RE = re.compile(r"(? argparse.Namespace: + parser = argparse.ArgumentParser( + description="Audit markdown navigation integrity for a memory-bank-like documentation tree." + ) + parser.add_argument( + "--repo-root", + help="Filesystem path to the repository root. Defaults to the current directory when it contains the scope root.", + ) + parser.add_argument( + "--scope-root", + default=DEFAULT_SCOPE_ROOT, + help="Repository-relative directory to audit. Default: %(default)s", + ) + parser.add_argument( + "--entrypoint", + action="append", + default=[], + help=( + "Markdown document to use as a navigation entrypoint. Accepts repo-relative paths " + "and scope-relative paths; ambiguous unqualified paths are resolved inside the scope first. " + "Use ./PATH or /PATH for explicit repo-root paths. Repeat the option to pass several files." + ), + ) + parser.add_argument( + "--max-depth", + type=int, + default=DEFAULT_MAX_DEPTH, + help="Maximum allowed navigation depth in link hops before the document becomes a warning. Default: %(default)s", + ) + parser.add_argument( + "--json", + action="store_true", + help="Emit a machine-readable JSON report instead of text.", + ) + args = parser.parse_args() + if args.max_depth < 0: + parser.error("--max-depth must be greater than or equal to 0") + return args + + +def normalize_scope_root(scope_root: str) -> str: + normalized = posixpath.normpath(scope_root.strip()) + if normalized in {"", "."}: + raise ValueError("--scope-root must point to a repository-relative directory") + return normalized.rstrip("/") + + +def resolve_repo_root(repo_root_arg: str | None, scope_root: str) -> Path: + if repo_root_arg: + return Path(repo_root_arg).resolve() + + candidates = [Path.cwd().resolve()] + if "__file__" in globals(): + script_path = Path(__file__) + if str(script_path) not in {"", ""}: + candidates.append(script_path.resolve().parents[1]) + + for candidate in candidates: + if (candidate / scope_root).exists(): + return candidate + + return candidates[0] + + +def discover_markdown_files(repo_root: Path) -> dict[str, Path]: + files: dict[str, Path] = {} + for root, dirs, filenames in os.walk(repo_root): + dirs[:] = [directory for directory in dirs if directory not in IGNORED_DIRS] + for filename in filenames: + if not filename.endswith(".md"): + continue + full_path = Path(root, filename) + relative_path = full_path.relative_to(repo_root).as_posix() + files[relative_path] = full_path + return files + + +def read_text(path: Path) -> str: + return path.read_text(encoding="utf-8", errors="ignore") + + +def strip_fenced_code_blocks(text: str) -> str: + return FENCED_CODE_BLOCK_RE.sub("", text) + + +def extract_markdown_link_destination(raw_url: str) -> str: + url = raw_url.strip() + if not url: + return "" + + if url.startswith("<"): + closing_index = url.find(">") + if closing_index != -1: + return url[1:closing_index].strip() + + return url.split(None, 1)[0].strip("<>") + + +def strip_frontmatter_value(value: str) -> str: + return value.strip().strip("\"'") + + +def parse_frontmatter_list_item(item: str) -> object: + if item.startswith("{") and item.endswith("}"): + fields: dict[str, str] = {} + for part in item.strip("{}").split(","): + if ":" not in part: + continue + key, value = part.split(":", 1) + fields[key.strip()] = strip_frontmatter_value(value) + return fields + + if item.startswith("path:"): + return {"path": strip_frontmatter_value(item.split(":", 1)[1])} + + return strip_frontmatter_value(item) + + +def parse_frontmatter(text: str) -> dict[str, object]: + match = FRONTMATTER_RE.match(text) + if not match: + return {} + + frontmatter: dict[str, object] = {} + current_key: str | None = None + for line in match.group(1).splitlines(): + if not line: + continue + stripped_line = line.strip() + if line.startswith((" ", "\t")) and stripped_line.startswith("- ") and current_key: + current_value = frontmatter.setdefault(current_key, []) + if not isinstance(current_value, list): + current_value = [] + frontmatter[current_key] = current_value + current_value.append(parse_frontmatter_list_item(stripped_line[2:].strip())) + continue + if line.startswith((" ", "\t", "-")) or ":" not in line: + continue + + key, value = line.split(":", 1) + current_key = key.strip() + stripped_value = value.strip() + if stripped_value: + frontmatter[current_key] = strip_frontmatter_value(stripped_value) + else: + frontmatter[current_key] = "" + return frontmatter + + +def normalize_internal_markdown_target(source_path: str, raw_url: str) -> str | None: + url = extract_markdown_link_destination(raw_url) + if not url or url.startswith(("http://", "https://", "mailto:", "#")): + return None + + url = url.split("#", 1)[0].split("?", 1)[0].strip() + if not url: + return None + + extension = posixpath.splitext(url)[1].lower() + if extension and extension != ".md": + return None + + base_dir = posixpath.dirname(source_path) + if url.startswith("/"): + resolved = posixpath.normpath(url.lstrip("/")) + else: + resolved = posixpath.normpath(posixpath.join(base_dir, url)) + + if not extension: + resolved = posixpath.join(resolved, "README.md") + + return resolved + + +def normalize_cli_document_path(raw_path: str) -> str | None: + path = raw_path.strip().strip("<>") + if not path: + return None + + path = path.split("#", 1)[0].split("?", 1)[0].strip() + if not path: + return None + + extension = posixpath.splitext(path)[1].lower() + if extension and extension != ".md": + return None + + normalized = posixpath.normpath(path.lstrip("/")) + if normalized in {"", "."}: + return None + + if not extension: + normalized = posixpath.join(normalized, "README.md") + + return normalized + + +def extract_internal_markdown_links(source_path: str, text: str) -> list[str]: + stripped_text = strip_fenced_code_blocks(text) + links: list[str] = [] + for match in MARKDOWN_LINK_RE.finditer(stripped_text): + target = normalize_internal_markdown_target(source_path, match.group(2)) + if target is None: + continue + links.append(target) + return links + + +def extract_derived_from_paths(frontmatter: dict[str, object]) -> list[str]: + raw_value = frontmatter.get("derived_from") + if raw_value is None: + return [] + + values = raw_value if isinstance(raw_value, list) else [raw_value] + paths: list[str] = [] + for value in values: + if isinstance(value, str): + if value: + paths.append(value) + continue + if isinstance(value, dict): + path = value.get("path") + if isinstance(path, str) and path: + paths.append(path) + return paths + + +def validate_frontmatter_dependencies( + documents: dict[str, dict[str, object]], + scope_root: str, +) -> list[dict[str, str]]: + known_paths = set(documents) + issues: list[dict[str, str]] = [] + + for source_path, document in documents.items(): + if not is_scoped_markdown(source_path, scope_root): + continue + + frontmatter = document["frontmatter"] + assert isinstance(frontmatter, dict) + for raw_path in extract_derived_from_paths(frontmatter): + target = normalize_internal_markdown_target(source_path, raw_path) + if target is None: + continue + if target not in known_paths: + issues.append( + { + "source": source_path, + "field": "derived_from", + "value": raw_path, + "target": target, + } + ) + + return issues + + +def load_documents(repo_root: Path) -> dict[str, dict[str, object]]: + documents: dict[str, dict[str, object]] = {} + for relative_path, full_path in discover_markdown_files(repo_root).items(): + text = read_text(full_path) + documents[relative_path] = { + "full_path": full_path, + "text": text, + "frontmatter": parse_frontmatter(text), + } + return documents + + +def is_scoped_markdown(path: str, scope_root: str) -> bool: + return path.startswith(f"{scope_root}/") and path.endswith(".md") + + +def is_scoped_readme(path: str, scope_root: str) -> bool: + return is_scoped_markdown(path, scope_root) and posixpath.basename(path) == "README.md" + + +def resolve_entrypoint_path(entrypoint: str, scope_root: str, known_paths: set[str]) -> tuple[str | None, str]: + primary_candidate = normalize_cli_document_path(entrypoint) + + scoped_input = posixpath.join(scope_root, entrypoint.lstrip("/")) + scoped_candidate = normalize_cli_document_path(scoped_input) + normalized_input = entrypoint.strip().strip("<>") + explicit_repo_root = normalized_input.startswith(("/", "./")) + already_scoped = primary_candidate is not None and ( + primary_candidate == scope_root or primary_candidate.startswith(f"{scope_root}/") + ) + + if not explicit_repo_root and not already_scoped and scoped_candidate and scoped_candidate in known_paths: + return scoped_candidate, entrypoint + + if primary_candidate and primary_candidate in known_paths: + return primary_candidate, entrypoint + + if scoped_candidate and scoped_candidate in known_paths: + return scoped_candidate, entrypoint + + fallback = primary_candidate or scoped_candidate or entrypoint + return None, fallback + + +def derive_entrypoints( + documents: dict[str, dict[str, object]], + scope_root: str, + configured_entrypoints: list[str], +) -> tuple[list[str], list[str]]: + known_paths = set(documents) + resolved: list[str] = [] + missing: list[str] = [] + + if configured_entrypoints: + for entrypoint in configured_entrypoints: + resolved_path, missing_hint = resolve_entrypoint_path(entrypoint, scope_root, known_paths) + if resolved_path is None: + missing.append(missing_hint) + continue + if resolved_path not in resolved: + resolved.append(resolved_path) + return resolved, missing + + default_entrypoint = f"{scope_root}/README.md" + if default_entrypoint in known_paths: + return [default_entrypoint], [] + + return [], [default_entrypoint] + + +def build_link_graph( + documents: dict[str, dict[str, object]], + scope_root: str, +) -> tuple[dict[str, set[str]], dict[str, set[str]], dict[str, set[str]]]: + outgoing: dict[str, set[str]] = defaultdict(set) + incoming_in_scope: dict[str, set[str]] = defaultdict(set) + broken_links: dict[str, set[str]] = defaultdict(set) + known_paths = set(documents) + + for source_path, document in documents.items(): + text = document["text"] + assert isinstance(text, str) + for target in extract_internal_markdown_links(source_path, text): + if target in known_paths: + outgoing[source_path].add(target) + if ( + source_path != target + and is_scoped_markdown(source_path, scope_root) + and is_scoped_markdown(target, scope_root) + ): + incoming_in_scope[target].add(source_path) + elif is_scoped_markdown(source_path, scope_root): + broken_links[source_path].add(target) + + return outgoing, incoming_in_scope, broken_links + + +def derive_index_paths(documents: dict[str, dict[str, object]], scope_root: str) -> list[str]: + index_paths: list[str] = [] + for path, document in documents.items(): + if not is_scoped_markdown(path, scope_root): + continue + frontmatter = document["frontmatter"] + assert isinstance(frontmatter, dict) + if frontmatter.get("doc_function") == "index": + index_paths.append(path) + return sorted(index_paths) + + +def derive_expected_readme_indices(documents: dict[str, dict[str, object]], scope_root: str) -> list[str]: + readmes: list[str] = [] + for path, document in documents.items(): + if not is_scoped_readme(path, scope_root): + continue + frontmatter = document["frontmatter"] + assert isinstance(frontmatter, dict) + if frontmatter.get("doc_function") == "template": + continue + if frontmatter.get("doc_kind") == "feature-support" and frontmatter.get("doc_function") == "reference": + continue + readmes.append(path) + return sorted(readmes) + + +def annotation_text_for_child_links(index_path: str, text: str) -> list[tuple[str, str]]: + section_prefix = posixpath.dirname(index_path) + stripped_lines = strip_fenced_code_blocks(text).splitlines() + annotations: list[tuple[str, str]] = [] + + index_line = 0 + while index_line < len(stripped_lines): + line = stripped_lines[index_line] + match = BULLET_LINK_RE.match(line) + if not match: + index_line += 1 + continue + + target = normalize_internal_markdown_target(index_path, match.group(2)) + if target is None: + index_line += 1 + continue + + child_prefix = f"{section_prefix}/" + if section_prefix and not target.startswith(child_prefix): + index_line += 1 + continue + + fragments: list[str] = [] + inline_annotation = MARKDOWN_LINK_RE.sub("", line).strip(" -\t:") + if inline_annotation: + fragments.append(inline_annotation) + + continuation_index = index_line + 1 + while continuation_index < len(stripped_lines): + continuation = stripped_lines[continuation_index] + if not continuation.strip(): + break + if continuation.startswith((" ", "\t")): + fragments.append(continuation.strip()) + continuation_index += 1 + continue + break + + annotations.append((target, " ".join(fragments).strip())) + index_line += 1 + + return annotations + + +def validate_index_document(index_path: str, documents: dict[str, dict[str, object]]) -> list[str]: + document = documents.get(index_path) + if document is None: + return ["missing expected index file"] + + text = document["text"] + frontmatter = document["frontmatter"] + assert isinstance(text, str) + assert isinstance(frontmatter, dict) + + issues: list[str] = [] + if not frontmatter: + issues.append("missing YAML frontmatter") + purpose = frontmatter.get("purpose", "") + if not isinstance(purpose, str) or not purpose.strip(): + issues.append("missing 'purpose' in frontmatter") + if frontmatter.get("doc_function") != "index": + issues.append("expected `doc_function: index`") + + for target, annotation in annotation_text_for_child_links(index_path, text): + normalized_annotation = re.sub(r"\s+", " ", annotation).strip(" -:\t").lower() + basename = posixpath.basename(target).lower() + basename_without_extension = posixpath.splitext(basename)[0] + if not normalized_annotation: + issues.append(f"missing annotation for child link -> {target}") + continue + if normalized_annotation in {basename, basename_without_extension}: + issues.append(f"annotation repeats filename for child link -> {target}") + continue + if len(normalized_annotation) < 12: + issues.append(f"annotation too short for child link -> {target}") + + return issues + + +def expected_parent_index(path: str, index_paths: set[str], scope_root: str) -> str | None: + if not is_scoped_markdown(path, scope_root): + return None + + current_dir = posixpath.dirname(path) + if posixpath.basename(path) == "README.md": + current_dir = posixpath.dirname(current_dir) + + while current_dir and current_dir != ".": + candidate = posixpath.join(current_dir, "README.md") + if candidate in index_paths and candidate != path: + return candidate + parent_dir = posixpath.dirname(current_dir) + if parent_dir == current_dir: + break + current_dir = parent_dir + + return None + + +def build_navigation_reachability( + outgoing: dict[str, set[str]], + navigation_nodes: set[str], + entrypoints: list[str], +) -> dict[str, dict[str, object]]: + reachable: dict[str, dict[str, object]] = {} + navigation_depths: dict[str, int] = {} + queue: deque[str] = deque() + + for entrypoint in entrypoints: + reachable[entrypoint] = {"depth": 0, "route": [entrypoint]} + navigation_depths[entrypoint] = 0 + queue.append(entrypoint) + + while queue: + current = queue.popleft() + current_info = reachable[current] + current_depth = navigation_depths[current] + current_route = current_info["route"] + assert isinstance(current_route, list) + + for target in sorted(outgoing.get(current, set())): + candidate_depth = current_depth + 1 + candidate_route = current_route + [target] + best = reachable.get(target) + if best is None or candidate_depth < best["depth"]: + reachable[target] = {"depth": candidate_depth, "route": candidate_route} + if target in navigation_nodes: + best_depth = navigation_depths.get(target) + if best_depth is None or candidate_depth < best_depth: + navigation_depths[target] = candidate_depth + queue.append(target) + + return reachable + + +def flatten_broken_links(broken_links: dict[str, set[str]]) -> list[dict[str, str]]: + items: list[dict[str, str]] = [] + for source_path in sorted(broken_links): + for target in sorted(broken_links[source_path]): + items.append({"source": source_path, "target": target}) + return items + + +def build_report( + repo_root: Path, + scope_root: str, + entrypoints: list[str], + missing_entrypoints: list[str], + max_depth: int, + documents: dict[str, dict[str, object]], +) -> dict[str, object]: + scoped_markdown_paths = sorted(path for path in documents if is_scoped_markdown(path, scope_root)) + index_paths = derive_index_paths(documents, scope_root) + expected_readme_indices = derive_expected_readme_indices(documents, scope_root) + outgoing, incoming_in_scope, broken_links = build_link_graph(documents, scope_root) + + report: dict[str, object] = { + "format_version": 1, + "repo_root": str(repo_root), + "scope_root": scope_root, + "entrypoints": entrypoints, + "missing_entrypoints": missing_entrypoints, + "max_depth": max_depth, + "stats": { + "markdown_files_in_scope": len(scoped_markdown_paths), + "index_documents_in_scope": len(index_paths), + }, + "errors": { + "config": [], + "broken_links": flatten_broken_links(broken_links), + "frontmatter_dependencies": validate_frontmatter_dependencies(documents, scope_root), + "orphans": [], + "unreachable": [], + "index_contract": [], + }, + "warnings": { + "deep_reachable": [], + }, + } + + config_errors = report["errors"]["config"] + assert isinstance(config_errors, list) + if missing_entrypoints: + config_errors.append( + { + "message": "Configured entrypoints are missing.", + "paths": missing_entrypoints, + } + ) + if not scoped_markdown_paths: + config_errors.append( + { + "message": "Scope contains no markdown files.", + "paths": [scope_root], + } + ) + if not entrypoints: + config_errors.append( + { + "message": "No valid entrypoints were resolved.", + "paths": missing_entrypoints or [f"{scope_root}/README.md"], + } + ) + + index_paths_set = set(index_paths) + entrypoint_set = set(entrypoints) + + if entrypoints and scoped_markdown_paths: + navigation_nodes = set(index_paths) | entrypoint_set + reachable = build_navigation_reachability(outgoing, navigation_nodes, entrypoints) + + for path in scoped_markdown_paths: + inbound_sources = sorted(incoming_in_scope.get(path, set())) + parent_index = expected_parent_index(path, index_paths_set, scope_root) + if path not in entrypoint_set and not inbound_sources: + report["errors"]["orphans"].append( + { + "path": path, + "expected_parent_index": parent_index, + "inbound_links": inbound_sources, + } + ) + + reachability = reachable.get(path) + if reachability is None: + report["errors"]["unreachable"].append( + { + "path": path, + "expected_parent_index": parent_index, + "inbound_links": inbound_sources, + } + ) + continue + + depth = reachability["depth"] + route = reachability["route"] + assert isinstance(depth, int) + assert isinstance(route, list) + if depth > max_depth: + report["warnings"]["deep_reachable"].append( + { + "path": path, + "depth": depth, + "max_depth": max_depth, + "expected_parent_index": parent_index, + "route": route, + } + ) + + for index_path in expected_readme_indices: + issues = validate_index_document(index_path, documents) + if not issues: + continue + report["errors"]["index_contract"].append( + { + "path": index_path, + "issues": issues, + "expected_parent_index": expected_parent_index(index_path, index_paths_set, scope_root), + } + ) + + warnings = report["warnings"]["deep_reachable"] + assert isinstance(warnings, list) + warnings.sort(key=lambda item: (item["depth"], item["path"])) + + stats = report["stats"] + assert isinstance(stats, dict) + errors = report["errors"] + assert isinstance(errors, dict) + stats.update( + { + "broken_link_count": len(errors["broken_links"]), + "frontmatter_dependency_issue_count": len(errors["frontmatter_dependencies"]), + "orphan_count": len(errors["orphans"]), + "unreachable_count": len(errors["unreachable"]), + "index_contract_issue_count": len(errors["index_contract"]), + "deep_reachable_warning_count": len(warnings), + "entrypoint_count": len(entrypoints), + } + ) + + has_errors = any( + bool(errors[key]) + for key in ("config", "broken_links", "frontmatter_dependencies", "orphans", "unreachable", "index_contract") + ) + report["exit_code"] = 1 if has_errors else 0 + return report + + +def format_route(route: list[str]) -> str: + return " -> ".join(route) + + +def print_text_report(report: dict[str, object]) -> None: + print("Memory-bank link audit") + print(f"Repo root: {report['repo_root']}") + print(f"Scope root: {report['scope_root']}") + print(f"Entrypoints: {', '.join(report['entrypoints']) or '(none)'}") + print(f"Navigation depth threshold: {report['max_depth']}") + + stats = report["stats"] + errors = report["errors"] + warnings = report["warnings"] + assert isinstance(stats, dict) + assert isinstance(errors, dict) + assert isinstance(warnings, dict) + + print(f"Markdown files in scope: {stats['markdown_files_in_scope']}") + print(f"Index documents in scope: {stats['index_documents_in_scope']}") + print() + + config_errors = errors["config"] + assert isinstance(config_errors, list) + if config_errors: + print("Configuration errors:") + for item in config_errors: + print(f" - {item['message']}") + for path in item["paths"]: + print(f" * {path}") + print() + + broken_links = errors["broken_links"] + assert isinstance(broken_links, list) + if broken_links: + print("Broken internal markdown links:") + for item in broken_links: + print(f" - {item['source']} -> {item['target']}") + print() + else: + print("OK: no broken internal markdown links in scope.") + print() + + frontmatter_dependencies = errors["frontmatter_dependencies"] + assert isinstance(frontmatter_dependencies, list) + if frontmatter_dependencies: + print("Broken frontmatter markdown dependencies:") + for item in frontmatter_dependencies: + print(f" - {item['source']} {item['field']}: {item['value']} -> {item['target']}") + print() + else: + print("OK: no broken frontmatter markdown dependencies in scope.") + print() + + orphans = errors["orphans"] + assert isinstance(orphans, list) + if orphans: + print("Orphan markdown files in scope:") + for item in orphans: + print(f" - {item['path']}") + print(f" expected_parent_index: {item['expected_parent_index'] or '(none)'}") + print() + else: + print("OK: no orphan markdown files in scope.") + print() + + unreachable = errors["unreachable"] + assert isinstance(unreachable, list) + if unreachable: + print("Markdown files missing from index navigation:") + for item in unreachable: + print(f" - {item['path']}") + print(f" expected_parent_index: {item['expected_parent_index'] or '(none)'}") + inbound_links = item["inbound_links"] + if inbound_links: + print(f" inbound_links: {', '.join(inbound_links)}") + print() + else: + print("OK: all scoped markdown files are reachable from the configured entrypoints via index navigation.") + print() + + deep_reachable = warnings["deep_reachable"] + assert isinstance(deep_reachable, list) + if deep_reachable: + print("Warnings: documents reachable only deeper than the configured threshold:") + for item in deep_reachable: + print(f" - {item['path']}") + print(f" depth: {item['depth']}") + print(f" expected_parent_index: {item['expected_parent_index'] or '(none)'}") + print(f" route: {format_route(item['route'])}") + print() + else: + print("OK: no documents are reachable only deeper than the configured threshold.") + print() + + index_contract = errors["index_contract"] + assert isinstance(index_contract, list) + print("Index compliance:") + if index_contract: + for item in index_contract: + print(f" - {item['path']}") + for issue in item["issues"]: + print(f" * {issue}") + print() + else: + print(" - OK") + print() + + exit_code = report["exit_code"] + assert isinstance(exit_code, int) + result = "FAIL" if exit_code else "OK" + print(f"Result: {result}") + print("Machine-readable output: re-run with --json for a structured report suitable for auto-indexing.") + + +def main() -> int: + args = parse_args() + + try: + scope_root = normalize_scope_root(args.scope_root) + except ValueError as error: + print(str(error), file=sys.stderr) + return 1 + + repo_root = resolve_repo_root(args.repo_root, scope_root) + documents = load_documents(repo_root) + entrypoints, missing_entrypoints = derive_entrypoints(documents, scope_root, args.entrypoint) + report = build_report( + repo_root=repo_root, + scope_root=scope_root, + entrypoints=entrypoints, + missing_entrypoints=missing_entrypoints, + max_depth=args.max_depth, + documents=documents, + ) + + if args.json: + print(json.dumps(report, ensure_ascii=False, indent=2)) + else: + print_text_report(report) + + exit_code = report["exit_code"] + assert isinstance(exit_code, int) + return exit_code + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/lib/start_issue/agent.sh b/cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/agent.sh similarity index 95% rename from scripts/lib/start_issue/agent.sh rename to cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/agent.sh index e1f0069..9265a1c 100644 --- a/scripts/lib/start_issue/agent.sh +++ b/cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/agent.sh @@ -97,6 +97,7 @@ build_human_gate_command() { codex exec --model "$MODEL" --cd "$WORKTREE_PATH" + --ask-for-approval never --sandbox workspace-write --json --output-last-message "$HUMAN_GATE_LAST_MESSAGE_PATH" @@ -106,6 +107,7 @@ build_human_gate_command() { HUMAN_GATE_CMD=( codex exec --cd "$WORKTREE_PATH" + --ask-for-approval never --sandbox workspace-write --json --output-last-message "$HUMAN_GATE_LAST_MESSAGE_PATH" @@ -321,9 +323,9 @@ generate_improved_prompt_template() { ;; kimi) if [[ -n "$MODEL" ]]; then - output=$(kimi --model "$MODEL" --work-dir "$PROJECT_ROOT" --quiet -p "$request" 2>/dev/null) || return 1 + output=$(cd "$PROJECT_ROOT" && kimi --model "$MODEL" -p "$request" 2>/dev/null) || return 1 else - output=$(kimi --work-dir "$PROJECT_ROOT" --quiet -p "$request" 2>/dev/null) || return 1 + output=$(cd "$PROJECT_ROOT" && kimi -p "$request" 2>/dev/null) || return 1 fi ;; pi) @@ -378,9 +380,9 @@ Reply with ONLY the branch name." ;; kimi) if [[ -n "$MODEL" ]]; then - output=$(kimi --model "$MODEL" --work-dir "$PROJECT_ROOT" --quiet -p "$prompt" 2>/dev/null) || return 1 + output=$(cd "$PROJECT_ROOT" && kimi --model "$MODEL" -p "$prompt" 2>/dev/null) || return 1 else - output=$(kimi --work-dir "$PROJECT_ROOT" --quiet -p "$prompt" 2>/dev/null) || return 1 + output=$(cd "$PROJECT_ROOT" && kimi -p "$prompt" 2>/dev/null) || return 1 fi ;; pi) @@ -449,10 +451,11 @@ build_launch_command() { fi ;; kimi) + LAUNCH_CWD="$WORKTREE_PATH" if [[ -n "$MODEL" ]]; then - LAUNCH_CMD=(kimi --model "$MODEL" --work-dir "$WORKTREE_PATH" --yolo -p "$AGENT_PROMPT") + LAUNCH_CMD=(kimi --model "$MODEL" -p "$AGENT_PROMPT") else - LAUNCH_CMD=(kimi --work-dir "$WORKTREE_PATH" --yolo -p "$AGENT_PROMPT") + LAUNCH_CMD=(kimi -p "$AGENT_PROMPT") fi ;; pi) diff --git a/scripts/lib/start_issue/cli.sh b/cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/cli.sh similarity index 100% rename from scripts/lib/start_issue/cli.sh rename to cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/cli.sh diff --git a/scripts/lib/start_issue/config.sh b/cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/config.sh similarity index 100% rename from scripts/lib/start_issue/config.sh rename to cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/config.sh diff --git a/scripts/lib/start_issue/github.sh b/cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/github.sh similarity index 100% rename from scripts/lib/start_issue/github.sh rename to cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/github.sh diff --git a/scripts/lib/start_issue/init.sh b/cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/init.sh similarity index 100% rename from scripts/lib/start_issue/init.sh rename to cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/init.sh diff --git a/scripts/lib/start_issue/output.sh b/cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/output.sh similarity index 96% rename from scripts/lib/start_issue/output.sh rename to cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/output.sh index 0f394ac..4e85953 100644 --- a/scripts/lib/start_issue/output.sh +++ b/cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/output.sh @@ -331,7 +331,7 @@ print_manual_next_steps() { echo "Suggested agent commands:" echo " claude" echo " codex --cd $(shell_join "$WORKTREE_PATH")" - echo " kimi --work-dir $(shell_join "$WORKTREE_PATH")" + echo " (cd $(shell_join "$WORKTREE_PATH") && kimi)" echo " pi" } @@ -367,9 +367,9 @@ print_dry_run_launch_command() { ;; kimi) if [[ -n "$MODEL" ]]; then - cmd=$(shell_join kimi --model "$MODEL" --work-dir "$WORKTREE_PATH" --yolo -p "") + cmd=$(shell_join kimi --model "$MODEL" -p "") else - cmd=$(shell_join kimi --work-dir "$WORKTREE_PATH" --yolo -p "") + cmd=$(shell_join kimi -p "") fi ;; pi) @@ -414,9 +414,9 @@ print_dry_run_human_gate_command() { echo " Prompt omitted from command display because it is large." echo " Set START_ISSUE_DUMP_PROMPT=1 to print the full rendered prompt." if [[ -n "$MODEL" ]]; then - cmd=$(shell_join codex exec --model "$MODEL" --cd "$WORKTREE_PATH" --sandbox workspace-write --json --output-last-message "$HUMAN_GATE_LAST_MESSAGE_PATH" "") + cmd=$(shell_join codex exec --model "$MODEL" --cd "$WORKTREE_PATH" --ask-for-approval never --sandbox workspace-write --json --output-last-message "$HUMAN_GATE_LAST_MESSAGE_PATH" "") else - cmd=$(shell_join codex exec --cd "$WORKTREE_PATH" --sandbox workspace-write --json --output-last-message "$HUMAN_GATE_LAST_MESSAGE_PATH" "") + cmd=$(shell_join codex exec --cd "$WORKTREE_PATH" --ask-for-approval never --sandbox workspace-write --json --output-last-message "$HUMAN_GATE_LAST_MESSAGE_PATH" "") fi else cmd="$(shell_join "${HUMAN_GATE_CMD[@]}") < <(rendered prompt via stdin)" diff --git a/scripts/lib/start_issue/pipeline.sh b/cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/pipeline.sh similarity index 100% rename from scripts/lib/start_issue/pipeline.sh rename to cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/pipeline.sh diff --git a/scripts/lib/start_issue/release.sh b/cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/release.sh similarity index 100% rename from scripts/lib/start_issue/release.sh rename to cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/release.sh diff --git a/scripts/lib/start_issue/update.sh b/cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/update.sh similarity index 100% rename from scripts/lib/start_issue/update.sh rename to cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/update.sh diff --git a/scripts/lib/start_issue/utils.sh b/cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/utils.sh similarity index 100% rename from scripts/lib/start_issue/utils.sh rename to cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/utils.sh diff --git a/scripts/lib/start_issue/worktree.sh b/cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/worktree.sh similarity index 100% rename from scripts/lib/start_issue/worktree.sh rename to cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/worktree.sh diff --git a/scripts/prepare-release b/cmd/start-issue/testdata/bash-v1/scripts/prepare-release similarity index 100% rename from scripts/prepare-release rename to cmd/start-issue/testdata/bash-v1/scripts/prepare-release diff --git a/scripts/start-issue b/cmd/start-issue/testdata/bash-v1/scripts/start-issue similarity index 99% rename from scripts/start-issue rename to cmd/start-issue/testdata/bash-v1/scripts/start-issue index 83e68f8..ac010ee 100755 --- a/scripts/start-issue +++ b/cmd/start-issue/testdata/bash-v1/scripts/start-issue @@ -25,7 +25,7 @@ set -euo pipefail -VERSION="1.13.3" +VERSION="1.13.2" RED='\033[0;31m' GREEN='\033[0;32m' diff --git a/doc/spec.md b/doc/spec.md index d5f97bb..20ad2d7 100644 --- a/doc/spec.md +++ b/doc/spec.md @@ -3,10 +3,10 @@ ## Обзор **Название**: `start-issue` -**Тип**: Bash CLI с модульной shell-архитектурой +**Тип**: Go CLI **Назначение**: автоматизировать начало работы над GitHub issue: получить issue через `gh`, опционально переименовать zellij tab через `zellij-tab-status`, создать git worktree, при необходимости запустить `init.sh` и запустить выбранный coding agent. -Для разработки код остается модульным в `scripts/lib/start_issue/`, но install/distribution path должен собирать self-contained single-file script. +Runtime, build и тесты реализованы на Go; distribution path публикует platform-specific single-file binaries. ## Поддерживаемые агенты @@ -22,20 +22,15 @@ ## Внутренняя архитектура -Публичный CLI contract остается за `scripts/start-issue`, но внутренняя реализация разбита на shell-модули в `scripts/lib/start_issue/`. +Публичный CLI contract реализует `cmd/start-issue`. Внутренняя реализация разделена на focused Go helpers в одном command package. -| Модуль | Ответственность | +| Граница | Ответственность | |--------|-----------------| -| `cli.sh` | CLI parsing и нормализация входных флагов | -| `config.sh` | Разрешение agent/prompt config и prompt rendering | -| `github.sh` | Parse issue input, detect repo/base branch, fetch issue metadata | -| `release.sh` | Release download, checksum verification, and version normalization helpers shared by install/update flows | -| `update.sh` | Self-update mode, latest-release lookup, version comparison, and install orchestration | -| `worktree.sh` | Branch naming, worktree planning, worktree/init/zellij side effects | -| `agent.sh` | Agent adapter contract: validate, branch-name generation, prompt improvement, launch command | -| `output.sh` | Help, status rendering, dry-run output, session header | -| `init.sh` | Workflow конфигурационного `init` | -| `pipeline.sh` | Явная orchestration pipeline | +| CLI/config/prompt | Parse input, config resolution и prompt rendering | +| Repository/worktree | Parse issue input, repo/base detection, GitHub metadata и worktree lifecycle | +| Agent adapters | Validation, branch-name generation, prompt improvement и launch commands | +| Release/update | Asset selection, checksum/version verification и install orchestration | +| Output/onboarding | Help, status rendering, dry-run output, `init` и `setup` workflows | Agent-specific behavior должен быть централизован за единым adapter boundary: @@ -44,7 +39,7 @@ Agent-specific behavior должен быть централизован за е - generate branch name in `--ai` - improve prompt template in `--improve-prompt` -Если будущие изменения потребуют nested configuration, richer lifecycle subcommands (`resume`, `list`, `cleanup`) или полноценный structured output, это считается порогом для оценки Python core вместо дальнейшего роста Bash. +Будущие изменения должны сохранять эти границы в Go, не создавая второй runtime или дублирующие implementation paths. ## Входные данные @@ -78,10 +73,10 @@ Agent-specific behavior должен быть централизован за е | `--improve-prompt` | Сгенерировать reviewable proposal улучшенного prompt template и выйти до создания worktree | false | | `--human-gate` | Codex-only batch mode для issue workflow с resume на `STATUS: HUMAN_GATE` | false | | `--human-gate-help` | Показать отдельную справку по human-gate mode | false | -| `--prompt-output-file` | Путь proposal-файла для `--improve-prompt` | Для prompt-файла: рядом с source как `*.improved.md`; иначе `.start-issue/prompt.improved.md` | +| `--prompt-output-file` | Путь proposal-файла для `--improve-prompt` | Для `.md`: рядом с source как `*.improved.md`; для остальных файлов: `.improved`; иначе `.start-issue/prompt.improved.md` | | `--no-init` | Пропустить запуск `init.sh` | false | | `--command` / `-c` | Совместимый Claude command для дефолтного Claude prompt | `/task-router:route-task` | -| `--ai` | Генерировать имя ветки выбранным агентом | false, используется быстрая bash-эвристика | +| `--ai` | Генерировать имя ветки выбранным агентом | false, используется быстрая Go-эвристика | | `--project` | Для `init`: записать конфигурацию проекта в `.start-issue` | интерактивный выбор | | `--user` | Для `init`: записать пользовательскую конфигурацию в `~/.config/start-issue` | интерактивный выбор | | `--force` | Для `init`: перезаписать существующие `agent`, `prompt.md` и при необходимости сбросить `model` к unset | false | @@ -137,7 +132,7 @@ git rev-parse --show-toplevel 4. Записать результат в reviewable proposal-файл. 5. Завершить выполнение до переименования Zellij tab, генерации branch, создания worktree, запуска `init.sh` и запуска agent session. -Режим не перезаписывает active prompt template. Если active prompt взят из файла, proposal по умолчанию пишется рядом с ним как `*.improved.md`. Если active prompt built-in или inline, proposal по умолчанию пишется в `.start-issue/prompt.improved.md` в git top-level directory. `--prompt-output-file` задает путь явно. +Режим не перезаписывает active prompt template. Если active prompt взят из файла `.md`, proposal по умолчанию пишется рядом с ним как `*.improved.md`; для любого другого имени файла к исходному пути добавляется `.improved`. Если active prompt built-in или inline, proposal по умолчанию пишется в `.start-issue/prompt.improved.md` в git top-level directory. `--prompt-output-file` задает путь явно. Если proposal-файл уже существует, скрипт завершается с ошибкой, чтобы не перезаписать reviewable артефакт. `--agent none` в этом режиме невалиден. @@ -148,7 +143,7 @@ git rev-parse --show-toplevel - project scope: `{git-root}/.start-issue/agent`, optional `{git-root}/.start-issue/model` и `{git-root}/.start-issue/prompt.md` - user scope: `~/.config/start-issue/agent`, optional `~/.config/start-issue/model` и `~/.config/start-issue/prompt.md` -Если не передан `--project` или `--user`, команда интерактивно спрашивает scope. Project scope требует запуск внутри git repository; user scope может выполняться вне git repository. Режим `init` не требует issue, `gh` или `jq`. +Если не передан `--project` или `--user`, команда интерактивно спрашивает scope. Project scope требует запуск внутри git repository; user scope может выполняться вне git repository. Режим `init` не требует issue или `gh`. По умолчанию записывается agent `claude` и стандартный Claude prompt. `--agent` меняет записываемый agent; `--model` записывает sibling `model` config; `--prompt` или `--prompt-file` меняют записываемый prompt. Если `--model` не передан, built-in behavior остается unset и новый `model` файл не создается. Если выбран не `claude` и prompt явно не задан, записывается portable prompt. Если существующий `agent` сохраняется без `--force`, default prompt выбирается по сохраненному agent, а не по built-in default или CLI override. @@ -161,7 +156,7 @@ git rev-parse --show-toplevel Контракт режима: 1. Режим работает только с `~/.config/start-issue` и не пишет project config в `.start-issue`. -2. Режим не требует issue, git repository, `gh` или `jq`. +2. Режим не требует issue, git repository или `gh`. 3. Если директория `~/.config/start-issue` отсутствует, она создается в начале setup. 4. Команда спрашивает default agent: `claude`, `codex`, `kimi`, `pi` или `skip`. 5. `skip` означает, что файл `~/.config/start-issue/agent` должен отсутствовать. @@ -192,16 +187,12 @@ git rev-parse --show-toplevel 3. Текущая установленная версия берется из executable, который пользователь реально запустил. 4. Перед сравнением версии нормализуются удалением одного опционального префикса `v`. 5. Если текущая версия равна latest release или новее него, команда завершается с кодом `0` и не переустанавливает бинарник. -6. Если latest release новее, команда скачивает `start-issue` и `start-issue.sha256`, проверяет checksum и устанавливает обновление в тот же executable path. +6. Если latest release новее, команда скачивает binary для текущей платформы и `checksums.txt`, проверяет checksum и staged `--version`, затем устанавливает обновление в resolved target running executable. 7. Ошибки release lookup, download, checksum verification и install являются фатальными и должны давать понятное сообщение. Зависимости режима: - `gh` CLI с авторизованной GitHub session -- `jq` -- `curl` или `wget` -- `install` -- один из `sha256sum`, `shasum` или `openssl` ## Codex human-gate mode @@ -351,7 +342,7 @@ Templating правила: Если первый positional argument равен `setup` или включен `--setup`: -1. Не требовать git repository, `gh`, `jq` или issue input. +1. Не требовать git repository, `gh` или issue input. 2. Создать `~/.config/start-issue`, если директория отсутствует. 3. Спросить default agent: `claude`, `codex`, `kimi`, `pi` или `skip`. 4. Если выбран `skip`, не создавать `~/.config/start-issue/agent`. @@ -363,7 +354,7 @@ Templating правила: ### Фаза 1: Валидация и парсинг 1. Распарсить CLI arguments. -2. Проверить зависимости: `git`, `gh`, `jq`, авторизацию `gh`. +2. Проверить зависимости: `git`, `gh`, авторизацию `gh`. 3. Проверить, что текущая директория внутри git repo. 4. Определить project root через `git rev-parse --show-toplevel`. 5. Распарсить issue URL или issue number. @@ -390,7 +381,7 @@ Templating правила: Если включен update mode: 1. Не требовать `git` и не проверять текущую директорию как git repository. -2. Проверить зависимости update workflow: `gh`, `jq`, `install` и download/checksum tooling. +2. Проверить зависимость update workflow: `gh`. 3. Получить latest release metadata через: ```bash @@ -400,13 +391,13 @@ gh api "repos/dapi/start-issue/releases/latest" 4. Извлечь: - `tag_name` -- `browser_download_url` для asset `start-issue` -- `browser_download_url` для asset `start-issue.sha256` +- `browser_download_url` для binary текущей платформы +- `browser_download_url` для `checksums.txt` 5. Нормализовать installed version и `tag_name`, удалив один опциональный префикс `v`. 6. Если versions равны, завершиться с понятным сообщением `already up to date`. 7. Если installed version новее latest published release, завершиться с кодом `0` и сообщением, что update не нужен. -8. Если latest published release новее, скачать оба asset, проверить checksum и установить обновление в путь текущего executable. +8. Если latest published release новее, скачать оба asset, проверить checksum и staged `--version`, затем установить обновление в resolved target текущего executable. ### Фаза 2: Получение issue @@ -439,7 +430,7 @@ zellij-tab-status --set-name "#{ISSUE_NUMBER}" ### Фаза 4: Имя ветки -По умолчанию используется быстрая bash-эвристика. +По умолчанию используется быстрая Go-эвристика. Правила типа ветки: @@ -459,7 +450,7 @@ zellij-tab-status --set-name "#{ISSUE_NUMBER}" {type}/issue-{number}-{kebab-case-title} ``` -`--ai` пытается сгенерировать имя ветки через выбранный agent в non-interactive mode и fallback-ится на bash-эвристику при ошибке или невалидном формате. Если задана explicit model, adapter обязан передать ее в non-interactive command вместо тихого игнорирования. +`--ai` пытается сгенерировать имя ветки через выбранный agent в non-interactive mode и fallback-ится на Go-эвристику при ошибке или невалидном формате. Если задана explicit model, adapter обязан передать ее в non-interactive command вместо тихого игнорирования. ### Фаза 5: Создание worktree @@ -512,7 +503,7 @@ codex: exec codex [--model "$MODEL"] --cd "$WORKTREE_PATH" --dangerously-bypass-approvals-and-sandbox "$PROMPT" kimi: - exec kimi [--model "$MODEL"] --work-dir "$WORKTREE_PATH" --yolo -p "$PROMPT" + cd "$WORKTREE_PATH" && exec kimi [--model "$MODEL"] -p "$PROMPT" pi: cd "$WORKTREE_PATH" @@ -545,7 +536,6 @@ none: | Не в git repo | `Not in a git repository` | | `gh` отсутствует | `gh CLI not found. Install: https://cli.github.com` | | `gh` не авторизован | `gh not authenticated. Run: gh auth login` | -| `jq` отсутствует | `jq not found. Please install jq.` | | Issue не найден | `Issue #{number} not found in {owner}/{repo}` | | Agent неизвестен | `Unknown agent: {agent}` | | Model config пустая | `Model config is empty. Remove the empty model config or set a value.` | @@ -601,12 +591,10 @@ start-issue --human-gate-help Обязательные: -- `bash` - `git` - `gh` CLI с авторизованной GitHub session -- `jq` -Для `start-issue setup` обязателен только `bash`. Для `start-issue init --user` обязателен только `bash`. Для `start-issue init --project` нужны `bash` и `git`. +Для `start-issue setup` и `start-issue init --user` не требуется внешний CLI. Для `start-issue init --project` нужен `git`. Опциональные: diff --git a/docs/agent-examples.md b/docs/agent-examples.md index 361612e..6ccfafa 100644 --- a/docs/agent-examples.md +++ b/docs/agent-examples.md @@ -80,7 +80,7 @@ start-issue 123 --agent kimi The script creates the worktree, renders the portable prompt, and launches: ```bash -kimi --work-dir "$WORKTREE_PATH" --yolo -p "$PROMPT" +cd "$WORKTREE_PATH" && kimi -p "$PROMPT" ``` Use an explicit Kimi model: diff --git a/docs/agent-examples.ru.md b/docs/agent-examples.ru.md index 8ca0cce..10270d2 100644 --- a/docs/agent-examples.ru.md +++ b/docs/agent-examples.ru.md @@ -80,7 +80,7 @@ start-issue 123 --agent kimi Скрипт создает worktree, рендерит portable prompt и запускает: ```bash -kimi --work-dir "$WORKTREE_PATH" --yolo -p "$PROMPT" +cd "$WORKTREE_PATH" && kimi -p "$PROMPT" ``` Использовать явную model Kimi: diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..29a45d9 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/dapi/start-issue/v2 + +go 1.24 diff --git a/install.sh b/install.sh index cab18ee..9708538 100755 --- a/install.sh +++ b/install.sh @@ -6,8 +6,8 @@ REPO="${START_ISSUE_REPOSITORY:-dapi/start-issue}" PREFIX="${PREFIX:-$HOME/.local}" BINDIR="${BINDIR:-$PREFIX/bin}" TARGET="${TARGET:-$BINDIR/start-issue}" -ASSET_URL="${START_ISSUE_ASSET_URL:-https://github.com/$REPO/releases/latest/download/start-issue}" -CHECKSUM_URL="${START_ISSUE_CHECKSUM_URL:-https://github.com/$REPO/releases/latest/download/start-issue.sha256}" +ASSET_URL="${START_ISSUE_ASSET_URL:-}" +CHECKSUM_URL="${START_ISSUE_CHECKSUM_URL:-}" DEBUG=0 log() { @@ -81,6 +81,7 @@ release_install_verified_asset() { local tmpdir local tmpfile local checksum_file + local asset_name local expected_checksum local actual_checksum local cleanup_cmd @@ -91,7 +92,7 @@ release_install_verified_asset() { trap "$cleanup_cmd" RETURN tmpfile="$tmpdir/start-issue" - checksum_file="$tmpdir/start-issue.sha256" + checksum_file="$tmpdir/checksums.txt" if declare -F debug >/dev/null 2>&1; then debug "Fetching $asset_url -> $tmpfile" @@ -105,7 +106,10 @@ release_install_verified_asset() { if declare -F debug >/dev/null 2>&1; then debug "Verifying checksum" fi - expected_checksum="$(awk '{ print $1; exit }' "$checksum_file")" + asset_name="${asset_url%%\?*}" + asset_name="${asset_name%%\#*}" + asset_name="${asset_name##*/}" + expected_checksum="$(awk -v asset="$asset_name" '$2 == asset || $2 == "*" asset { print $1; exit }' "$checksum_file")" actual_checksum="$(release_sha256_file "$tmpfile")" if [[ -z "$expected_checksum" ]]; then @@ -154,6 +158,15 @@ parse_args() { main() { parse_args "$@" + if [[ -z "$ASSET_URL" || -z "$CHECKSUM_URL" ]]; then + os="$(uname -s | tr '[:upper:]' '[:lower:]')" + case "$(uname -m)" in x86_64|amd64) arch=amd64 ;; arm64|aarch64) arch=arm64 ;; *) die "Unsupported architecture: $(uname -m)" ;; esac + case "$os" in linux|darwin) ;; *) die "Unsupported OS: $os" ;; esac + asset="start-issue-$os-$arch" + ASSET_URL="${ASSET_URL:-https://github.com/$REPO/releases/latest/download/$asset}" + CHECKSUM_URL="${CHECKSUM_URL:-https://github.com/$REPO/releases/latest/download/checksums.txt}" + fi + if [[ "$DEBUG" -eq 1 ]]; then PS4='+ install.sh:${LINENO}: ' set -x diff --git a/memory-bank/domain/context-map.md b/memory-bank/domain/context-map.md index f2d68cf..9f15025 100644 --- a/memory-bank/domain/context-map.md +++ b/memory-bank/domain/context-map.md @@ -58,7 +58,7 @@ canonical_for: ## Open Boundary Questions -- `OQ-01` Whether future lifecycle commands should stay in Bash modules or move - to a Python core. +- `OQ-01` How future lifecycle commands should be decomposed into focused Go + helpers without weakening the existing ownership boundaries. - `OQ-02` Whether non-Codex agents will expose enough resumable batch semantics to generalize human-gate mode. diff --git a/memory-bank/domain/glossary.md b/memory-bank/domain/glossary.md index 1472aeb..f7dae8c 100644 --- a/memory-bank/domain/glossary.md +++ b/memory-bank/domain/glossary.md @@ -29,7 +29,7 @@ canonical_for: | `worktree parent directory` | Directory under which issue worktrees are created | `--worktree-dir`, `START_ISSUE_WORKTREE_DIR` | Specific worktree path | | `agent` | Selected coding CLI adapter: `claude`, `codex`, `kimi`, `pi`, or `none` | Config and launch | Model | | `model` | Optional model string passed to adapters that support explicit model args | Config and launch | Agent | -| `agent adapter` | Internal boundary that validates agent support and builds agent-specific commands | `scripts/lib/start_issue/agent.sh` | Public agent CLI implementation | +| `agent adapter` | Internal Go helper boundary that validates agent support and builds agent-specific commands | `cmd/start-issue` | Public agent CLI implementation | | `prompt template` | Text with supported placeholders rendered into an agent prompt | Prompt resolution and launch | Final rendered prompt | | `prompt source` | Where the active prompt came from: CLI, env, project config, user config, or built-in default | Config output and dry-run | Prompt location | | `project config` | `.start-issue/*` files under the git root | Repository-local defaults | User config | diff --git a/memory-bank/domain/model.md b/memory-bank/domain/model.md index 5b60772..34f5109 100644 --- a/memory-bank/domain/model.md +++ b/memory-bank/domain/model.md @@ -51,10 +51,10 @@ canonical_for: | Concept | Canonical owner | Allowed writers | Allowed readers | Notes | | --- | --- | --- | --- | --- | -| `Configuration` | `config.sh` and `doc/spec.md` | CLI/config code and docs updates | Pipeline, output, adapters | Help and dry-run must stay aligned | -| `WorktreePlan` | `worktree.sh` and feature docs | Worktree lifecycle changes | Pipeline, tests | Reuse must be exact and safe | -| `AgentAdapter` | `agent.sh` | Agent feature work | Pipeline, branch naming, prompt improvement | Keep adapter-specific logic centralized | -| `ReleaseMetadata` | `release.sh` / `update.sh` | Release/update features | Installer, update workflow | Latest source is GitHub Releases | +| `Configuration` | Go configuration helpers and `doc/spec.md` | CLI/config code and docs updates | Pipeline, output, adapters | Help and dry-run must stay aligned | +| `WorktreePlan` | Go worktree helpers and feature docs | Worktree lifecycle changes | Pipeline, tests | Reuse must be exact and safe | +| `AgentAdapter` | Go agent adapter helpers | Agent feature work | Pipeline, branch naming, prompt improvement | Keep adapter-specific logic centralized | +| `ReleaseMetadata` | Go release/update helpers | Release/update features | Installer, update workflow | Latest source is GitHub Releases | | `FeaturePackage` | `memory-bank/flows/feature-flow.md` | Agents and maintainers | Future feature work | Legacy packages are grandfathered | ## Model Boundaries diff --git a/memory-bank/domain/rules.md b/memory-bank/domain/rules.md index 5e57bd9..dda387d 100644 --- a/memory-bank/domain/rules.md +++ b/memory-bank/domain/rules.md @@ -33,15 +33,15 @@ canonical_for: | Policy ID | Policy | Input | Output / Verdict | Owner | | --- | --- | --- | --- | --- | -| `POL-01` | Branch type selection | Issue labels | `feature/`, `fix/`, `docs/`, `refactor/`, `test/`, `chore/`, or `hotfix/` prefix | `worktree.sh` | -| `POL-02` | Model support | Resolved agent and optional model | Adapter command includes model or fails if unsupported | `agent.sh` | -| `POL-03` | Release comparison | Installed version and latest tag | no-op, update, or fail | `update.sh` | -| `POL-04` | First-run gate | Missing `~/.config/start-issue` on ordinary launch | Run setup or create marker directory, then continue | `pipeline.sh` / `init.sh` | +| `POL-01` | Branch type selection | Issue labels | `feature/`, `fix/`, `docs/`, `refactor/`, `test/`, `chore/`, or `hotfix/` prefix | Go worktree helper | +| `POL-02` | Model support | Resolved agent and optional model | Adapter command includes model or fails if unsupported | Go agent adapter | +| `POL-03` | Release comparison | Installed version and latest tag | no-op, update, or fail | Go update helper | +| `POL-04` | First-run gate | Missing `~/.config/start-issue` on ordinary launch | Run setup or create marker directory, then continue | Go orchestration/config helpers | ## Cross-Context Rules - `XDR-01` Changes to public CLI behavior must update help text, README, - Russian README/spec when relevant, and Bats coverage together. + Russian README/spec when relevant, and Go test coverage together. - `XDR-02` Changes to release behavior must update release docs, release/update tests, and install/update code together. - `XDR-03` Changes to memory-bank structure must keep diff --git a/memory-bank/engineering/README.md b/memory-bank/engineering/README.md index 93815e2..ea208ce 100644 --- a/memory-bank/engineering/README.md +++ b/memory-bank/engineering/README.md @@ -13,19 +13,18 @@ audience: humans_and_agents # Engineering Documentation Index -`memory-bank/engineering/` contains the implementation rules for the modular -Bash CLI and its documentation/test workflow. +`memory-bank/engineering/` contains the implementation rules for the Go CLI +and its documentation/test workflow. -- [Engineering Architecture Patterns](architecture.md) - shell module - boundaries, adapter boundary, config ownership, failure handling, and Bash - boundary. +- [Engineering Architecture Patterns](architecture.md) - package boundaries, + adapter boundary, config ownership, failure handling, and process boundary. - [Frontend Engineering](frontend.md) - current CLI UI surfaces and the policy for any future non-CLI UI. -- [Testing Policy](testing-policy.md) - canonical local checks, Bats coverage, +- [Testing Policy](testing-policy.md) - canonical local checks, Go coverage, memory-bank audit, sufficient coverage, and manual-only exceptions. - [Autonomy Boundaries](autonomy-boundaries.md) - what agents may do autonomously, what needs supervision, and when to escalate. -- [Coding Style](coding-style.md) - shell/documentation style, tooling, and +- [Coding Style](coding-style.md) - Go/documentation style, tooling, and change discipline. - [Git Workflow](git-workflow.md) - default branch, worktrees, commits, PR handoff, and release tags. diff --git a/memory-bank/engineering/architecture.md b/memory-bank/engineering/architecture.md index 3d93405..11a0740 100644 --- a/memory-bank/engineering/architecture.md +++ b/memory-bank/engineering/architecture.md @@ -2,7 +2,7 @@ title: Engineering Architecture Patterns doc_kind: engineering doc_function: canonical -purpose: "Architecture rules for the start-issue Bash CLI, module boundaries, adapters, failures, and configuration ownership." +purpose: "Architecture rules for the start-issue Go CLI, module boundaries, adapters, failures, and configuration ownership." derived_from: - ../dna/governance.md - ../domain/context-map.md @@ -14,29 +14,22 @@ audience: humans_and_agents # Engineering Architecture Patterns -`start-issue` is implemented as modular Bash source under -`scripts/lib/start_issue/` and distributed as a bundled single-file executable -through `scripts/build-start-issue`. +`start-issue` is a Go CLI rooted at `cmd/start-issue` and distributed as a +platform-specific compiled binary. ## Module Boundaries | Module / Layer | Owns | Must not depend on directly | | --- | --- | --- | -| `scripts/start-issue` | Entrypoint/bootstrap and version constant | Feature-specific behavior that belongs in modules | -| `cli.sh` | Argument parsing, mode flags, normalized workflow state | Agent command syntax, GitHub API calls | -| `config.sh` | Agent/model/prompt/worktree config resolution and prompt rendering | Worktree side effects, release update | -| `github.sh` | Issue parsing, repo/base detection, issue metadata fetch | Agent launch, config file writes | -| `worktree.sh` | Branch naming, worktree planning, safe create/reuse/delete, optional zellij/init side effects | Agent internals | -| `agent.sh` | Adapter validation, launch commands, AI branch naming, prompt improvement, Codex human-gate helpers | CLI precedence, GitHub issue fetching | -| `release.sh` | Download/checksum/version helpers shared by installer/update | Normal issue workflow | -| `update.sh` | Self-update mode orchestration | Git worktree or issue state | -| `init.sh` | `init`, `setup`, and first-run onboarding helpers | GitHub issue fetch, agent launch | -| `output.sh` | Help, status, dry-run, human-gate help, user-facing messages | Core side effects | -| `pipeline.sh` | Top-level orchestration order | Adapter-specific command construction | +| `cmd/start-issue` | Entrypoint, CLI parsing, orchestration, and version injection | Native GitHub or agent protocol clients | +| configuration helpers | Agent/model/prompt/worktree config resolution and prompt rendering | Worktree side effects, release update | +| repository helpers | Issue parsing, repo/base detection, issue metadata fetch, and worktree lifecycle | Agent launch, config file writes | +| agent adapter helpers | Validation and launch command construction | CLI precedence and issue fetching | +| release helpers | Asset selection, checksum verification, version comparison, and self-update | Normal issue workflow | ## Adapter Boundary -Agent-specific behavior must stay centralized in `agent.sh`: +Agent-specific behavior must stay centralized in Go adapter helpers: - supported agent validation; - launch command construction; @@ -58,7 +51,7 @@ Configuration precedence is part of the public contract: 4. environment 5. built-in default -The owner module is `config.sh`; user-facing descriptions must stay aligned in +The owner is the Go configuration layer; user-facing descriptions must stay aligned in [ops/config.md](../ops/config.md), [README.md](../../README.md), and [doc/spec.md](../../doc/spec.md). @@ -72,15 +65,7 @@ The owner module is `config.sh`; user-facing descriptions must stay aligned in directory. - Never continue after a worktree safety validation failure. -## Bash Boundary +## Process Boundary -Bash remains the core while: - -- CLI modes stay small and explicit; -- configuration remains file/env/string based; -- local tests can cover behavior deterministically; -- output is primarily human-readable. - -Reevaluate a Python core if future work requires nested configuration, richer -lifecycle commands such as `resume/list/cleanup`, structured machine-readable -output, or complex state persistence. +`git`, `gh`, and supported agent CLIs remain external processes. Go owns the +application lifecycle, filesystem operations, release verification, and tests. diff --git a/memory-bank/engineering/autonomy-boundaries.md b/memory-bank/engineering/autonomy-boundaries.md index 40f6028..01dee16 100644 --- a/memory-bank/engineering/autonomy-boundaries.md +++ b/memory-bank/engineering/autonomy-boundaries.md @@ -21,9 +21,9 @@ audience: humans_and_agents Agents may do these without asking when they are in scope: -- edit shell modules, tests, and memory-bank docs; +- edit Go modules, tests, and memory-bank docs; - run local checks including `make test`; -- add focused Bats tests for changed behavior; +- add focused Go tests for changed behavior; - create or update feature packages; - fix memory-bank link/index issues found by the audit; - update docs/spec/help when directly required by a behavior change. @@ -37,7 +37,7 @@ Proceed, but surface the plan/result clearly: - prompt contract changes; - new agent adapter behavior; - migration from legacy feature package layout to new feature-flow layout; -- broad refactors across several shell modules. +- broad refactors across several Go packages. ## Escalation diff --git a/memory-bank/engineering/coding-style.md b/memory-bank/engineering/coding-style.md index 9f8bbb2..9ca3290 100644 --- a/memory-bank/engineering/coding-style.md +++ b/memory-bank/engineering/coding-style.md @@ -14,28 +14,23 @@ audience: humans_and_agents ## General Rules -- Follow the surrounding shell style before introducing a new pattern. +- Follow idiomatic Go style and run `gofmt` before committing. - Keep changes scoped to the requested behavior and touched module boundary. - Add comments only for non-obvious why/boundary conditions. - Prefer explicit user-facing errors over surprising implicit fallback. - Keep docs, help, spec, and tests aligned for public behavior changes. -## Bash Rules +## Go Rules -- Use `set -euo pipefail` semantics where already established by the script. -- Quote variable expansions unless the local pattern deliberately relies on word - splitting. -- Keep adapter-specific command syntax in `agent.sh`. -- Keep config precedence and prompt rendering in `config.sh`. -- Keep top-level ordering in `pipeline.sh`. -- Do not grow `scripts/start-issue` with feature logic; source modules should - remain the implementation home. +- Keep adapter-specific command syntax in dedicated Go helpers. +- Keep config precedence and prompt rendering separate from worktree side effects. +- Return wrapped errors at external process and filesystem boundaries. ## Tooling Contract -- Formatter: none configured; preserve existing formatting. -- Linter: `shellcheck` through `make test`. -- Test runner: Bats through `make test`. +- Formatter: `gofmt`. +- Linter: `go vet` through `make test`. +- Test runner: Go tests through `make test`. - Documentation audit: `scripts/check_memory_bank_index.py --max-depth 4`. ## Documentation Style diff --git a/memory-bank/engineering/frontend.md b/memory-bank/engineering/frontend.md index e8928a3..019adec 100644 --- a/memory-bank/engineering/frontend.md +++ b/memory-bank/engineering/frontend.md @@ -31,7 +31,7 @@ the CLI: flags, prompts, help text, dry-run output, and agent launch commands. - Keep normal help concise; use dedicated help for complex modes such as `--human-gate-help`. - When adding interactive prompts, support non-interactive test coverage through - Bats input simulation. + Go test input simulation. ## If A Frontend Is Added Later diff --git a/memory-bank/engineering/testing-policy.md b/memory-bank/engineering/testing-policy.md index 0da6599..2df750b 100644 --- a/memory-bank/engineering/testing-policy.md +++ b/memory-bank/engineering/testing-policy.md @@ -2,7 +2,7 @@ title: Testing Policy doc_kind: engineering doc_function: canonical -purpose: "Testing policy for start-issue: required local checks, Bats coverage, release checks, memory-bank audit, and manual-only exceptions." +purpose: "Testing policy for start-issue: required Go checks, release checks, memory-bank audit, and manual-only exceptions." derived_from: - ../dna/governance.md - ../flows/feature-flow.md @@ -33,24 +33,24 @@ make test `make test` runs: -1. `bash -n scripts/start-issue` -2. `shellcheck install.sh scripts/start-issue scripts/build-start-issue scripts/bump-version scripts/prepare-release scripts/lib/start_issue/*.sh test/e2e/*.sh` -3. `python3 scripts/check_memory_bank_index.py --max-depth 4` -4. `git diff --check` -5. `bats test` +1. `gofmt` cleanliness for `cmd/` +2. `go vet ./...` +3. `go test ./...` +4. `python3 scripts/check_memory_bank_index.py --max-depth 4` +5. `git diff --check` ## Test Stack -- Shell syntax: `bash -n` -- Static analysis: `shellcheck` -- Behavior/regression tests: Bats under `test/` +- Formatting: `gofmt` +- Static analysis: `go vet` +- Behavior/regression tests: Go tests under `cmd/` and future Go packages - Opt-in real-agent E2E smoke tests: scripts under `test/e2e/`, run manually and never in CI - Memory-bank navigation: `scripts/check_memory_bank_index.py` - Whitespace/conflict-marker check: `git diff --check` ## Core Rules -- Any deterministic behavior change needs automated Bats coverage. +- Any deterministic behavior change needs automated Go test coverage. - Any public CLI contract change must update help/output assertions and docs. - Any config precedence change must test the winning source and displayed source. - Any worktree lifecycle change must test safe reuse/reject/delete behavior. @@ -65,12 +65,12 @@ make test - Legacy `feature.md` packages own their existing `SC-*`, `CHK-*`, and `EVID-*` until migrated. - `implementation-plan.md` owns concrete test commands and sequencing. -- Bats tests own executable regression behavior. +- Go tests own executable regression behavior. ## Sufficient Coverage Coverage is sufficient when the changed behavior is exercised at the CLI level -or at the closest practical shell helper boundary, and failure behavior is +or at the closest practical Go helper boundary, and failure behavior is covered when it affects user trust or data safety. Line coverage is not a target. Scenario coverage matters more: @@ -98,16 +98,15 @@ feature plan or final handoff. ## Simplify Review -After tests pass, review for shell complexity: +After tests pass, review for implementation complexity: - avoid scattered agent-specific branching; -- prefer small functions with explicit inputs over implicit global mutation where - practical within the existing Bash style; +- prefer small functions with explicit inputs over implicit global mutation; - avoid abstraction unless it removes real duplication or clarifies a boundary; - keep user-facing output stable and direct. ## Verification Context Separation 1. Functional verification: run relevant tests or `make test`. -2. Simplify review: inspect the changed shell/docs for unnecessary complexity. +2. Simplify review: inspect the changed Go code/docs for unnecessary complexity. 3. Acceptance: map results back to `SC-*`/`CHK-*` or the user request. diff --git a/memory-bank/features/FT-017/README.md b/memory-bank/features/FT-017/README.md index 6aad750..682ccb9 100644 --- a/memory-bank/features/FT-017/README.md +++ b/memory-bank/features/FT-017/README.md @@ -31,3 +31,7 @@ Git delivery. - [implementation-plan.md](implementation-plan.md) Grounded execution sequence, test strategy, checkpoints, and approval gate for live GitHub-writing verification. + +- [decision-log.md](decision-log.md) + Historical release-distribution decisions retained from the earlier FT-017 + migration package. diff --git a/memory-bank/features/FT-017/decision-log.md b/memory-bank/features/FT-017/decision-log.md new file mode 100644 index 0000000..af7ca3f --- /dev/null +++ b/memory-bank/features/FT-017/decision-log.md @@ -0,0 +1,108 @@ +--- +title: "FT-017: Decision Log" +doc_kind: feature-support +doc_function: reference +purpose: "Records FPF analysis and accepted local decisions for FT-017. It does not own feature scope, selected design, acceptance criteria, or execution sequence." +derived_from: + - brief.md + - ../../../.github/workflows/ci.yml + - ../../../.github/workflows/release.yml + - ../../../install.sh +status: active +audience: humans_and_agents +must_not_define: + - ft_016_scope + - ft_016_selected_design + - ft_016_acceptance_criteria + - implementation_sequence +--- + +# FT-017: Decision Log + +## Purpose and Ownership + +This log records why `DEC-01` remains open. The canonical owner of the blocker and the verify contract is [brief.md](brief.md). A selected solution belongs in a future `design.md`, not here. + +## DL-01 — Multi-platform Go release distribution contract + +**Status:** accepted on 2026-07-22 by feature requester. + +### FPF framing + +- **Bounded context:** distribution is separate from CLI-semantic parity. It owns the relationship among a compiled artifact, release assets, installer/update selection, and the platform on which a user executes the artifact. +- **Evidence boundary:** facts below come only from the current repository and issue #34. The issue requests a Go binary but defines neither supported OS/architecture targets nor asset-selection rules. +- **Decision criterion:** provide Go releases for the requester-selected operating systems with explicit platform assets, verifiable integrity, and no inferred reduction of platform support. + +### Available facts + +1. `install.sh` downloads one fixed asset named `start-issue` and one fixed checksum file named `start-issue.sha256`. +2. `.github/workflows/release.yml` builds the current sole release asset on `ubuntu-latest`. +3. `.github/workflows/ci.yml` verifies installation on both `ubuntu-latest` and `macos-latest`. +4. A Bash release artifact is portable across those CI operating systems; a Go executable is platform-specific. +5. Issue #34 requires Go to become the primary distribution artifact and requires installation/release workflows to publish it successfully, but does not state the intended OS/architecture matrix or compatibility policy. + +### Decision + +| Area | Accepted contract | +| --- | --- | +| Target matrix | `linux/amd64`, `linux/arm64`, `darwin/amd64`, `darwin/arm64`, and `windows/amd64`. The operating systems are requester-selected; the architecture set follows the explicit `dapi/port-selector` release pattern. | +| Build/release | Use GoReleaser v2 with `CGO_ENABLED=0`, one statically built executable per target, `start-issue--` asset names, and a SHA-256 `checksums.txt` manifest. During the v1-to-v2 cutover, also upload a `start-issue` bridge and its `start-issue.sha256` checksum for the v1 updater. | +| POSIX install | Adapt the referenced install-script strategy: detect `uname -s`/`uname -m`, select the matching asset, download it, verify its checksum from `checksums.txt`, and install it under the public name `start-issue`. | +| Windows delivery | Publish `start-issue-windows-amd64.exe` as a first-class release asset and document manual download/PATH installation. The existing POSIX shell installer is not a Windows installer. | +| Cutover | No separate human release-approval gate. The normal tag-triggered release proceeds only after `CHK-01` through `CHK-03` are green. | + +### Resolution rationale + +The requester directly chose macOS, Linux, and Windows and delegated release-strategy selection to this feature. The selected GoReleaser layout and target architecture set are grounded in the referenced `dapi/port-selector` repository: its `.goreleaser.yml` uses the exact five targets, `CGO_ENABLED=0`, binary-format archives, and `checksums.txt`; its installer performs POSIX OS/architecture detection. The decision preserves explicit asset integrity while avoiding a false claim that the POSIX installer supports Windows. + +### Rejected alternatives + +- A single cross-platform `start-issue` Go asset is rejected: compiled Go executables are platform-specific. +- A narrower target matrix is rejected: the requester selected all three operating systems and the referenced strategy supplies the matching explicit matrix. +- A release approval gate is rejected: the requester explicitly said it is unnecessary; automated evidence gates remain mandatory. + +## DL-02 — Go toolchain and Windows update boundary + +**Status:** accepted on 2026-07-22 by feature owner under delegated release-strategy choice. + +### FPF framing and facts + +- The toolchain is an execution-environment contract, not a user-facing CLI capability; it must be deterministic in local, CI, and release paths. +- The referenced `dapi/port-selector` release pattern pins `go 1.21` in `go.mod` and GitHub Actions. This repository currently has no Go toolchain contract. +- A POSIX process can replace its executable through the existing install/update style; Windows generally locks a running executable. The referenced release strategy documents a Windows binary download rather than a shell installer. + +### Decision + +1. Pin Go `1.24` in `go.mod`, `mise.toml`, and CI/release setup for this migration. +2. The initial Windows contract is binary release plus manual installation and manual update: `start-issue update` on Windows must not try to overwrite its running `.exe`; it returns a clear instruction naming the matching release asset. POSIX retains verified automatic install/update behavior. + +### Rationale and risk control + +Go 1.24 is the explicit baseline because its linker emits a Mach-O `LC_UUID`, which current macOS releases require. The Windows manual-update behavior avoids an unsafe or undeclared helper-process design. It is a documented platform-specific delivery difference, not a hidden parity exception, because the Bash baseline has no Windows runtime contract. + +## DL-03 — ID-01 dry-run worktree-path conflict handling + +**Status:** accepted on 2026-07-24 by the feature requester. + +### Case and approved expectation + +- **Stable case ID:** `ID-01` +- **Parity case:** `worktree-path-conflict-dry-run` in + `cmd/start-issue/parity_integration_test.go` +- **Bash baseline expectation:** accepts the supplied conflict choice during + `--dry-run` and reports `Worktree path already exists` before continuing + down the selected reuse path. +- **Go expectation:** reports `Worktree path exists; would prompt for reuse or + delete/recreate` without consuming a choice or selecting a reuse/delete + outcome. + +### User-visible rationale and acceptance + +When a worktree path conflicts, the Go dry-run tells the user that a choice is +still required. This avoids presenting one stdin-supplied choice as the +determined outcome of a non-executing command and, importantly, avoids the +legacy path in which the delete/recreate selection can reach mutation logic +before Bash's later dry-run check. The different dry-run diagnostic is +user-visible and is intentionally accepted for `ID-01`; all other observable +records, fake-command logs, and filesystem state remain subject to `CHK-01` +parity. diff --git a/memory-bank/features/FT-018/README.md b/memory-bank/features/FT-018/README.md new file mode 100644 index 0000000..538fc81 --- /dev/null +++ b/memory-bank/features/FT-018/README.md @@ -0,0 +1,19 @@ +--- +title: "FT-018: Agent CLI launch compatibility" +doc_kind: feature +doc_function: index +purpose: "Navigation for the agent-adapter compatibility feature split from issue #34." +derived_from: + - brief.md +status: active +audience: humans_and_agents +--- + +# FT-018: Agent CLI launch compatibility + +This package tracks the agent-launch contract extracted from issue #34 after +the installed Kimi Code CLI rejected the legacy `--work-dir` option. + +- [brief.md](brief.md) — problem, scope, and acceptance contract. +- [design.md](design.md) — selected per-agent command and cwd mapping. +- [implementation-plan.md](implementation-plan.md) — grounded execution and verification plan. diff --git a/memory-bank/features/FT-018/brief.md b/memory-bank/features/FT-018/brief.md new file mode 100644 index 0000000..6d3ca34 --- /dev/null +++ b/memory-bank/features/FT-018/brief.md @@ -0,0 +1,96 @@ +--- +title: "FT-018: Agent CLI launch compatibility" +doc_kind: feature +doc_function: canonical +purpose: "Canonical brief for keeping start-issue agent launches compatible with the installed agent CLIs." +derived_from: + - ../../flows/feature-flow.md + - ../../product/context.md + - ../../domain/glossary.md + - ../../engineering/testing-policy.md + - ../../../doc/spec.md +status: active +delivery_status: in_progress +audience: humans_and_agents +must_not_define: + - implementation_sequence +--- + +# FT-018: Agent CLI launch compatibility + +## What + +### Problem + +The Kimi Code CLI currently installed in the user's environment does not +accept the legacy `--work-dir` option. Its current prompt mode also rejects +`--yolo` together with `--prompt`. `start-issue` therefore fails before Kimi +can receive the issue prompt, despite Codex continuing to work. + +### Outcome + +Agent launch commands use the supported interface of each selected agent and +always execute from the intended issue worktree. + +| Metric ID | Metric | Baseline | Target | Measurement method | +| --- | --- | --- | --- | --- | +| `MET-01` | Kimi launch success | Fails with unknown option | Deterministic launch command is accepted by the current Kimi CLI | Go tests and local `kimi --help` contract check | + +## Scope + +- `REQ-01` Keep the Codex, Claude, Pi, and `none` launch contracts unchanged. +- `REQ-02` Launch Kimi from the resolved worktree cwd without `--work-dir`. +- `REQ-03` Remove incompatible Kimi `--yolo` usage from prompt mode and keep model forwarding. +- `REQ-04` Keep helper operations (branch naming and prompt improvement) in the repository cwd for Kimi. +- `REQ-05` Update parity fixtures, documentation, and manual next steps together with the adapter behavior. + +## Non-Scope + +- `NS-01` Do not add version probing or support multiple incompatible Kimi command syntaxes in one launch. +- `NS-02` Do not change the public agent names, config precedence, worktree lifecycle, or Codex human-gate behavior. + +## Constraints / Assumptions + +- `ASM-01` Current Kimi Code CLI help exposes `-p/--prompt`, `--model`, `--yolo`, and `--add-dir`, but not `--work-dir`. +- `CON-01` The worktree path must be supplied through process cwd for agents whose CLI has no path option. + +## Design Requirement Decision + +| Decision | Reason | Downstream owner | +| --- | --- | --- | +| `Design required: yes` | This changes an external CLI integration contract and requires explicit command/cwd mapping. | `design.md` | + +## Verify + +### Exit Criteria + +- `EC-01` Kimi dry-run output contains `cd && kimi` and no `--work-dir` or incompatible `--yolo -p` combination. +- `EC-02` Kimi helper and launch tests preserve model/prompt forwarding and execute in the requested cwd. +- `EC-03` Existing non-Kimi adapter contracts and parity checks remain green. + +### Traceability matrix + +| Requirement ID | Problem refs | Acceptance refs | Checks | Evidence IDs | +| --- | --- | --- | --- | --- | +| `REQ-01` | `CON-01` | `EC-03`, `SC-02` | `CHK-01` | `EVID-01` | +| `REQ-02` | `ASM-01`, `CON-01` | `EC-01`, `SC-01` | `CHK-01`, `CHK-02` | `EVID-01`, `EVID-02` | +| `REQ-03` | `ASM-01` | `EC-01`, `SC-01` | `CHK-01` | `EVID-01` | +| `REQ-04` | `CON-01` | `EC-02`, `SC-01` | `CHK-01` | `EVID-01` | +| `REQ-05` | `CON-01` | `EC-03` | `CHK-02` | `EVID-02` | + +### Acceptance Scenarios + +- `SC-01` Given `--agent kimi --dry-run`, when start-issue renders its launch, then Kimi runs from the worktree and receives `-p` plus the optional model without unsupported flags. +- `SC-02` Given another supported agent, when its launch is rendered, then its existing adapter-specific command remains unchanged. + +### Checks + +| Check ID | Covers | How to check | Expected result | Evidence path | +| --- | --- | --- | --- | --- | +| `CHK-01` | `EC-01`–`EC-03`, `SC-01`, `SC-02` | `go test ./cmd/start-issue` | All adapter and cwd tests pass | `artifacts/ft-018/verify/go-test/` | +| `CHK-02` | `EC-03` | `make test` | Repository checks and parity suite pass | `artifacts/ft-018/verify/make-test/` | + +### Evidence + +- `EVID-01` Go adapter and cwd test output. +- `EVID-02` Full `make test` output. diff --git a/memory-bank/features/FT-018/design.md b/memory-bank/features/FT-018/design.md new file mode 100644 index 0000000..3edc3c9 --- /dev/null +++ b/memory-bank/features/FT-018/design.md @@ -0,0 +1,74 @@ +--- +title: "FT-018: Design" +doc_kind: feature +doc_function: canonical +purpose: "Solution-space contract for agent-specific launch arguments and working-directory handling." +derived_from: + - brief.md + - ../../../doc/spec.md +status: active +audience: humans_and_agents +must_not_define: + - ft_018_scope + - ft_018_acceptance_criteria + - implementation_sequence +--- + +# FT-018: Design + +## Design Pack + +| Artifact | Role | Owns | +| --- | --- | --- | +| `design.md` | Feature-local solution owner | `SOL-*`, `C4-*`, `SD-*`, `CTR-*`, `INV-*`, `FM-*` | + +## C4 Applicability + +| C4 ID | Decision | Trigger / reason | Artifact | +| --- | --- | --- | --- | +| `C4-00` | `not required` | Local adapter command mapping inside the existing CLI container; no runtime boundary changes. | none | + +## Selected Solution + +- `SOL-01` Keep each agent adapter responsible for its own arguments and cwd policy. +- `SOL-02` For Kimi, run `kimi [--model MODEL] -p PROMPT` with `cmd.Dir = WORKTREE_PATH`; prompt mode provides automatic permission handling, so `--yolo` is omitted. +- `SOL-03` For Kimi helper calls, run the same command with `cmd.Dir = REPOSITORY_ROOT`; do not encode cwd as a removed CLI option. +- `SOL-04` Keep Codex's `--cd`, Claude/Pi cwd behavior, and `none` manual output unchanged except for documentation alignment. + +## Alternatives Considered + +| Alternative ID | Option | Why not selected | +| --- | --- | --- | +| `ALT-01` | Keep `--work-dir` and require an older Kimi CLI | Fails with the installed/current CLI and makes start-issue unusable for Kimi users. | +| `ALT-02` | Add runtime version probing and two Kimi syntaxes | Adds fragile branching and is outside the requested compatibility fix. | +| `ALT-03` | Use `--add-dir` while keeping the caller cwd | Does not make the worktree the primary Kimi workspace. | + +## Accepted Local Decisions + +- `SD-01` Process cwd is the canonical worktree transport for Kimi. +- `SD-02` Kimi prompt mode omits `--yolo` because the current CLI rejects that combination and auto-approves prompt-mode tool calls. + +## Contracts + +| Contract ID | Input / Output | Producer / Consumer | Semantics / Constraints | +| --- | --- | --- | --- | +| `CTR-01` | agent, model, worktree, prompt → process command + cwd | adapter / selected agent CLI | Kimi has no path flag in the supported CLI; cwd must equal the requested directory. | + +## Invariants + +- `INV-01` No Kimi command emitted by the product contains `--work-dir`. +- `INV-02` No Kimi prompt command emits the incompatible `--yolo` and `-p` combination. +- `INV-03` The selected worktree remains the process cwd for Kimi launch and repository-root cwd for helper calls. + +## Failure Modes + +- `FM-01` A future Kimi CLI changes its flags; the adapter tests and explicit docs must be updated together rather than silently probing alternatives. +- `FM-02` Kimi is launched with the caller cwd due to a missed `cmd.Dir`; the cwd test must fail. + +## Traceability + +| Requirement ID | Solution refs | Contracts / invariants | Failure refs | +| --- | --- | --- | --- | +| `REQ-01` | `SOL-01`, `SOL-04` | `CTR-01` | `FM-01` | +| `REQ-02`–`REQ-04` | `SOL-02`, `SOL-03` | `CTR-01`, `INV-01`–`INV-03` | `FM-01`, `FM-02` | +| `REQ-05` | `SOL-01` | `INV-01` | `FM-01` | diff --git a/memory-bank/features/FT-018/implementation-plan.md b/memory-bank/features/FT-018/implementation-plan.md new file mode 100644 index 0000000..e6b0f63 --- /dev/null +++ b/memory-bank/features/FT-018/implementation-plan.md @@ -0,0 +1,70 @@ +--- +title: "FT-018: Implementation Plan" +doc_kind: feature +doc_function: derived +purpose: "Execution plan for the agent CLI launch compatibility fix." +derived_from: + - brief.md + - design.md + - ../../engineering/testing-policy.md +status: active +audience: humans_and_agents +must_not_define: + - ft_018_scope + - ft_018_selected_design + - ft_018_acceptance_criteria +--- + +# FT-018: Implementation Plan + +## Grounding / Support References + +| Path / module | Current role | Why relevant | Reuse / mirror | +| --- | --- | --- | --- | +| `cmd/start-issue/main.go` | Go adapter and process execution | Emits launch/helper commands | Update Kimi args and cwd only | +| `cmd/start-issue/main_test.go` | Go adapter/cwd regression tests | Verifies command shape and process cwd | Extend Kimi expectations | +| `cmd/start-issue/parity_integration_test.go` | Bash-v1 vs Go observable parity | Detects output drift | Keep parity contract aligned | +| `cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/{agent,output}.sh` | Baseline fixture | Must represent the corrected public contract | Mirror Kimi cwd/args | +| `README.md`, `README.ru.md`, `docs/agent-examples*`, `doc/spec.md` | User contract | Prevents docs from reintroducing the bad flag | Update examples and mapping | + +## Test Strategy + +| Test surface | Canonical refs | Planned automated coverage | Required command | +| --- | --- | --- | --- | +| Kimi command and helper args | `REQ-02`–`REQ-04`, `SC-01` | Exact args omit `--work-dir`/`--yolo`; helper runs with root cwd | `go test ./cmd/start-issue` | +| Agent cwd behavior | `REQ-02`, `FM-02` | Fake Kimi records cwd; launch must use worktree | `go test ./cmd/start-issue` | +| Baseline parity and docs/index | `REQ-01`, `REQ-05`, `CHK-02` | Updated fixture matches Go and memory-bank links remain valid | `make test` | + +## Open Questions / Ambiguities + +None after checking the installed `kimi --help` and current Kimi Code CLI reference. + +## Environment Contract + +| Area | Contract | Failure symptom | +| --- | --- | --- | +| Kimi CLI | Current prompt interface supports `-p/--prompt` and `--model`; no `--work-dir` | Old launch fails with unknown option | +| Tests | Agent binaries are fakes or dry-run only | Network or real agent invocation appears | +| Repository gate | `make test` is required before handoff | Unverified adapter or documentation drift | + +## Preconditions + +- `PRE-01` `brief.md` and `design.md` are active and define the Kimi cwd contract. +- `PRE-02` Existing non-Kimi parity cases remain unchanged. + +## Workstreams + +- `WS-01` Update Go Kimi adapter, helper cwd execution, and focused tests (`REQ-02`–`REQ-04`). +- `WS-02` Update Bash parity fixture and all command examples (`REQ-05`). +- `WS-03` Run focused and repository verification (`REQ-01`, `CHK-01`, `CHK-02`). + +## Execution Order + +1. `STEP-01` Update adapter command/cwd mapping and tests; verify with `go test ./cmd/start-issue`. +2. `STEP-02` Update the parity fixture and docs/spec; verify no product or docs reference emits `--work-dir`. +3. `STEP-03` Run `make test`, inspect the diff for unrelated adapter changes, and record evidence for `CHK-01`/`CHK-02`. + +## Stop Conditions / Fallback + +- `STOP-01` If parity exposes a non-Kimi regression, stop and restore only the unrelated drift; do not broaden the Kimi adapter. +- `STOP-02` If the installed Kimi CLI contradicts the documented interface, stop before adding version probing and escalate as a new design decision. diff --git a/memory-bank/features/FT-019/README.md b/memory-bank/features/FT-019/README.md new file mode 100644 index 0000000..5b76c5e --- /dev/null +++ b/memory-bank/features/FT-019/README.md @@ -0,0 +1,16 @@ +--- +title: "FT-019: CI sandbox E2E coverage" +doc_kind: feature +doc_function: index +purpose: "Navigation for deterministic end-to-end coverage of the built Go CLI in CI." +derived_from: + - brief.md +status: active +audience: humans_and_agents +--- + +# FT-019: CI sandbox E2E coverage + +- [brief.md](brief.md) — scope and acceptance contract. +- [design.md](design.md) — sandbox boundary and fixture strategy. +- [implementation-plan.md](implementation-plan.md) — implementation and CI sequence. diff --git a/memory-bank/features/FT-019/brief.md b/memory-bank/features/FT-019/brief.md new file mode 100644 index 0000000..23b8137 --- /dev/null +++ b/memory-bank/features/FT-019/brief.md @@ -0,0 +1,96 @@ +--- +title: "FT-019: CI sandbox E2E coverage" +doc_kind: feature +doc_function: canonical +purpose: "Canonical brief for running deterministic end-to-end smoke scenarios against the built Go CLI in CI/CD." +derived_from: + - ../../flows/feature-flow.md + - ../../engineering/testing-policy.md + - ../../ops/development.md +status: active +delivery_status: in_progress +audience: humans_and_agents +must_not_define: + - implementation_sequence +--- + +# FT-019: CI sandbox E2E coverage + +## What + +### Problem + +The existing real-Codex E2E requires authenticated GitHub, a private fixture +repository, and an interactive external agent, so it cannot be a reliable CI +gate. Unit and parity tests do not exercise the built executable against a real +git worktree process boundary. + +### Outcome + +CI runs a network-free sandbox E2E against the built Go binary and verifies the +complete local issue-start path with controlled external command fakes. + +| Metric ID | Metric | Baseline | Target | Measurement method | +| --- | --- | --- | --- | --- | +| `MET-01` | Built-binary workflow coverage | Unit/parity tests plus manual real-agent E2E | At least one deterministic CI E2E job covers worktree/init/agent and dry-run paths | CI job output | + +## Scope + +- `REQ-01` Run the actual built `start-issue` binary in an isolated temporary sandbox. +- `REQ-02` Use a real local git repository/worktree and fake `gh` and agent CLIs; do not require network, credentials, or real agents. +- `REQ-03` Cover an executing Kimi launch with rendered issue prompt, model, cwd, and `init.sh`. +- `REQ-04` Cover the dry-run/no-agent path and prove it creates no worktree. +- `REQ-05` Expose the sandbox E2E through Make and run it in CI/CD. + +## Non-Scope + +- `NS-01` Do not replace the existing manual real-Codex human-gate E2E. +- `NS-02` Do not claim that fake agents prove vendor CLI compatibility or model behavior. +- `NS-03` Do not use GitHub API, authenticated secrets, Docker, or persistent shared state. + +## Constraints / Assumptions + +- `ASM-01` CI provides Go, Bash, git, and standard POSIX utilities. +- `CON-01` The test must clean its own uniquely-created temporary directory on success and failure. + +## Design Requirement Decision + +| Decision | Reason | Downstream owner | +| --- | --- | --- | +| `Design required: yes` | The feature introduces a CI test boundary, fake external processes, cleanup guarantees, and a new workflow gate. | `design.md` | + +## Verify + +### Exit Criteria + +- `EC-01` Sandbox E2E runs locally against a built binary without network or secrets. +- `EC-02` The executing scenario proves git worktree creation, `init.sh`, rendered prompt, model, and Kimi cwd. +- `EC-03` The dry-run scenario proves no worktree directory is created. +- `EC-04` CI invokes the same Make target and reports a failing exit code on assertion failure. + +### Traceability matrix + +| Requirement ID | Acceptance refs | Checks | Evidence IDs | +| --- | --- | --- | --- | +| `REQ-01` | `EC-01`, `SC-01` | `CHK-01` | `EVID-01` | +| `REQ-02` | `EC-01`, `SC-01` | `CHK-01` | `EVID-01` | +| `REQ-03` | `EC-02`, `SC-01` | `CHK-01` | `EVID-01` | +| `REQ-04` | `EC-03`, `SC-02` | `CHK-01` | `EVID-01` | +| `REQ-05` | `EC-04` | `CHK-02` | `EVID-02` | + +### Acceptance Scenarios + +- `SC-01` Given an isolated git fixture and fake external CLIs, when the built binary starts issue #42 with Kimi, then the worktree, init marker, rendered prompt, model, and cwd are correct. +- `SC-02` Given the same fixture, when the built binary runs `--dry-run --agent none`, then it prints the plan and does not create the requested worktree directory. + +### Checks + +| Check ID | Covers | How to check | Expected result | Evidence path | +| --- | --- | --- | --- | --- | +| `CHK-01` | `EC-01`–`EC-03`, `SC-01`, `SC-02` | `make e2e-sandbox` | Both sandbox scenarios pass | `artifacts/ft-019/verify/sandbox-e2e/` | +| `CHK-02` | `EC-04` | Inspect CI `sandbox-e2e` job | The job runs the Make target and fails on non-zero status | `artifacts/ft-019/verify/ci/` | + +### Evidence + +- `EVID-01` Local sandbox E2E output. +- `EVID-02` CI job output and workflow definition. diff --git a/memory-bank/features/FT-019/design.md b/memory-bank/features/FT-019/design.md new file mode 100644 index 0000000..5119ea3 --- /dev/null +++ b/memory-bank/features/FT-019/design.md @@ -0,0 +1,71 @@ +--- +title: "FT-019: Design" +doc_kind: feature +doc_function: canonical +purpose: "Solution-space contract for deterministic built-binary E2E execution in a temporary local sandbox." +derived_from: + - brief.md +status: active +audience: humans_and_agents +must_not_define: + - ft_019_scope + - ft_019_acceptance_criteria + - implementation_sequence +--- + +# FT-019: Design + +## C4 Applicability + +| C4 ID | Decision | Trigger / reason | Artifact | +| --- | --- | --- | --- | +| `C4-00` | `not required` | This is a test harness and CI job within the existing CLI container; it creates no deployed runtime boundary. | none | + +## Selected Solution + +- `SOL-01` Build the Go binary first and invoke it as a subprocess from `test/e2e/sandbox.sh`. +- `SOL-02` Create a temporary local git repository with a commit and origin remote, allowing the product's real git/worktree code to run. +- `SOL-03` Put fake `gh` and Kimi executables first on PATH. The fake `gh` serves issue JSON and auth status; the fake Kimi records cwd and arguments. +- `SOL-04` Run two scenarios: a real worktree/init/Kimi launch and a no-agent dry-run. +- `SOL-05` Add `make e2e-sandbox` and a dedicated CI job. Keep real-Codex E2E opt-in and separate. + +## Alternatives Considered + +| Alternative ID | Option | Why not selected | +| --- | --- | --- | +| `ALT-01` | Use the real GitHub fixture and agent in CI | Requires credentials/network and is nondeterministic. | +| `ALT-02` | Test only Go functions | Does not verify the built executable and process/filesystem boundaries. | +| `ALT-03` | Fake git as well | Would not exercise actual worktree creation and reuse behavior. | + +## Accepted Local Decisions + +- `SD-01` The sandbox has no external network dependency; every command that could access a service is controlled by PATH fakes. +- `SD-02` The temporary root is created by `mktemp` and removed by an EXIT trap, including failure paths. +- `SD-03` Assertions are made against explicit artifacts and command logs, not broad output snapshots. + +## Contracts + +| Contract ID | Input / Output | Producer / Consumer | Semantics / Constraints | +| --- | --- | --- | --- | +| `CTR-01` | Built binary + isolated env → exit status/artifacts | E2E script / start-issue | Non-zero means CI failure; no credentials are needed. | +| `CTR-02` | Fake Kimi log → cwd and rendered args | fake CLI / E2E assertions | cwd equals the created worktree and model/prompt are present. | + +## Invariants + +- `INV-01` The sandbox never invokes a real `gh`, Kimi, Codex, Claude, or Pi binary. +- `INV-02` The sandbox never writes outside its unique temporary root except the tested binary's normal process execution. +- `INV-03` Dry-run does not create its requested worktree directory. + +## Failure Modes + +- `FM-01` PATH fake is not executable or is shadowed; the script fails before claiming PASS. +- `FM-02` Cleanup is skipped after an assertion failure; the EXIT trap still removes the unique root. +- `FM-03` A product change bypasses the worktree cwd or prompt rendering; explicit log assertions fail. + +## Traceability + +| Requirement ID | Solution refs | Contracts / invariants | Failure refs | +| --- | --- | --- | --- | +| `REQ-01`–`REQ-02` | `SOL-01`–`SOL-03` | `CTR-01`, `INV-01`, `INV-02` | `FM-01`, `FM-02` | +| `REQ-03` | `SOL-02`–`SOL-04` | `CTR-02`, `INV-03` | `FM-03` | +| `REQ-04`–`REQ-05` | `SOL-04`, `SOL-05` | `CTR-01`, `INV-03` | `FM-02` | diff --git a/memory-bank/features/FT-019/implementation-plan.md b/memory-bank/features/FT-019/implementation-plan.md new file mode 100644 index 0000000..615e16d --- /dev/null +++ b/memory-bank/features/FT-019/implementation-plan.md @@ -0,0 +1,65 @@ +--- +title: "FT-019: Implementation Plan" +doc_kind: feature +doc_function: derived +purpose: "Execution plan for sandbox E2E coverage and CI integration." +derived_from: + - brief.md + - design.md + - ../../engineering/testing-policy.md +status: active +audience: humans_and_agents +must_not_define: + - ft_019_scope + - ft_019_selected_design + - ft_019_acceptance_criteria +--- + +# FT-019: Implementation Plan + +## Grounding / Support References + +| Path / module | Current role | Why relevant | Reuse / mirror | +| --- | --- | --- | --- | +| `test/e2e/human-gate.sh` | Existing real-agent E2E | Establishes E2E script conventions and opt-in boundary | Keep separate because it needs secrets/interactive Codex | +| `Makefile` | Build/test entrypoint | Owns the local and CI target | Add sandbox target beside human-gate target | +| `.github/workflows/ci.yml` | CI checks | Runs Go build and tests | Add network-free sandbox job | +| `cmd/start-issue/main.go` | Product executable | Must be exercised as a built subprocess | Do not add test-only product hooks | + +## Test Strategy + +| Test surface | Canonical refs | Planned automated coverage | Required command | +| --- | --- | --- | --- | +| Built binary issue start | `REQ-01`–`REQ-03`, `SC-01` | Real local git/worktree, fake gh/Kimi, init marker and cwd/args assertions | `make e2e-sandbox` | +| Built binary dry-run | `REQ-04`, `SC-02` | Output and no-directory assertion | `make e2e-sandbox` | +| CI wiring | `REQ-05` | Dedicated workflow job invokes Make target | CI run | + +## Environment Contract + +| Area | Contract | Failure symptom | +| --- | --- | --- | +| Toolchain | Go binary is built by Make; Bash, git, and POSIX utilities are available | Missing executable or command failure | +| Network/secrets | No network, GitHub auth, or agent credentials are allowed | Any fake command miss or unexpected external call fails | +| Cleanup | Temporary fixture root is unique and EXIT-trapped | No retained sandbox state after run | + +## Preconditions + +- `PRE-01` Go build succeeds. +- `PRE-02` `git` is available in the CI runner. + +## Workstreams + +- `WS-01` Implement the sandbox fixture and assertions (`REQ-01`–`REQ-04`). +- `WS-02` Add Make and CI wiring (`REQ-05`). +- `WS-03` Document usage and verify all repository gates. + +## Execution Order + +1. `STEP-01` Add `test/e2e/sandbox.sh`; run it against a local build and verify both scenarios. +2. `STEP-02` Add `make e2e-sandbox` and CI job; verify the job has no secrets or network setup. +3. `STEP-03` Update README/ops docs and run `make e2e-sandbox` plus `make test`. + +## Stop Conditions / Fallback + +- `STOP-01` If the script needs a real external service, stop and keep the test out of CI; do not weaken the no-network contract. +- `STOP-02` If cleanup or isolation fails, stop before merging the CI job. diff --git a/memory-bank/features/README.md b/memory-bank/features/README.md index 16cb5cb..340f0d0 100644 --- a/memory-bank/features/README.md +++ b/memory-bank/features/README.md @@ -42,3 +42,9 @@ audience: humans_and_agents - [FT-017: Codex human-gate delivery permissions](FT-017/README.md) Explicit restricted/default and opt-in full-delivery capability contract for Codex human-gate runs. + +- [FT-018: Agent CLI launch compatibility](FT-018/README.md) + Issue #34 follow-up package for current Kimi Code CLI command and cwd compatibility. + +- [FT-019: CI sandbox E2E coverage](FT-019/README.md) + Deterministic built-binary E2E scenarios for local worktree/agent and dry-run paths in CI/CD. diff --git a/memory-bank/ops/config.md b/memory-bank/ops/config.md index 936f76a..b1968f1 100644 --- a/memory-bank/ops/config.md +++ b/memory-bank/ops/config.md @@ -25,7 +25,8 @@ variables. The canonical precedence is: 4. environment variables 5. built-in defaults -Config resolution is owned by `scripts/lib/start_issue/config.sh`. User-facing +Config resolution is owned by the Go configuration helpers in +`cmd/start-issue`. User-facing documentation must stay aligned in README and `doc/spec.md`. ## File Layout @@ -54,12 +55,12 @@ User config: | Variable | Description | Default | Owner | | --- | --- | --- | --- | -| `START_ISSUE_AGENT` | Default agent when CLI and config files do not set one | built-in `claude` | `config.sh` | -| `START_ISSUE_MODEL` | Default model when CLI and config files do not set one | unset | `config.sh` | -| `START_ISSUE_PROMPT` | Inline prompt template | none | `config.sh` | -| `START_ISSUE_PROMPT_FILE` | Prompt template file | none | `config.sh` | -| `START_ISSUE_WORKTREE_DIR` | Parent directory for created worktrees | `~/worktrees` | `config.sh` / `worktree.sh` | -| `START_ISSUE_DUMP_PROMPT` | Print full rendered prompt in dry-run when set to `1` | unset | `output.sh` | +| `START_ISSUE_AGENT` | Default agent when CLI and config files do not set one | built-in `claude` | Go configuration helpers | +| `START_ISSUE_MODEL` | Default model when CLI and config files do not set one | unset | Go configuration helpers | +| `START_ISSUE_PROMPT` | Inline prompt template | none | Go configuration helpers | +| `START_ISSUE_PROMPT_FILE` | Prompt template file | none | Go configuration helpers | +| `START_ISSUE_WORKTREE_DIR` | Parent directory for created worktrees | `~/worktrees` | Go configuration/worktree helpers | +| `START_ISSUE_DUMP_PROMPT` | Print full rendered prompt in dry-run when set to `1` | unset | Go output helpers | `START_ISSUE_PROMPT` and `START_ISSUE_PROMPT_FILE` are mutually exclusive when no CLI prompt override is provided. diff --git a/memory-bank/ops/development.md b/memory-bank/ops/development.md index 02139a1..650c75e 100644 --- a/memory-bank/ops/development.md +++ b/memory-bank/ops/development.md @@ -18,16 +18,16 @@ audience: humans_and_agents Required tools for normal development: -- `bash` +- Go 1.24+ - `git` - `gh` with an authenticated GitHub session for issue/update flows -- `jq` -- `shellcheck` -- `bats` -- `curl` or `wget` for installer/update paths -- one checksum tool: `sha256sum`, `shasum`, or `openssl` -Build the bundled script: +The Go CLI parses release metadata, downloads update assets, and verifies +checksums itself. `jq`, download tools, and checksum tools are not required for +the CLI update workflow. The optional Bash installer still requires `bash`, +`curl` or `wget`, and a SHA-256 tool. + +Build the binary: ```bash make build @@ -46,6 +46,14 @@ current repository: START_ISSUE_E2E=1 make e2e-human-gate ``` +Run the deterministic, network-free built-binary E2E used in CI: + +```bash +make e2e-sandbox +``` + +It uses fake `gh` and agent commands but real local git/worktree operations. + When a live E2E must be visible in cmux, follow the canonical cmux-tab procedure in [`../../AGENTS.md`](../../AGENTS.md): find the `start-issue` workspace using `cmux tree --all`, create a terminal surface in its active pane, @@ -62,10 +70,10 @@ make install Useful direct commands: ```bash -bash -n scripts/start-issue -shellcheck install.sh scripts/start-issue scripts/build-start-issue scripts/bump-version scripts/prepare-release scripts/lib/start_issue/*.sh +gofmt -w cmd/start-issue/*.go +go vet ./... +go test ./... python3 scripts/check_memory_bank_index.py --max-depth 4 -bats test ``` ## Dry-Run Development @@ -74,7 +82,7 @@ Use `--dry-run` to inspect config, prompt source, worktree path, and launch command without creating a worktree or launching an agent: ```bash -scripts/start-issue 123 --repo dapi/start-issue --agent codex --dry-run +.build/start-issue 123 --repo dapi/start-issue --agent codex --dry-run ``` Set `START_ISSUE_DUMP_PROMPT=1` when the full rendered prompt must be visible in @@ -83,7 +91,7 @@ dry-run output. ## Browser Testing Not applicable. This project has no browser UI. CLI output is verified through -Bats and direct command output review. +Go tests and direct command output review. ## Local Services @@ -93,7 +101,7 @@ mode-specific as documented in README/spec. ## Development Safety -- Prefer `scripts/start-issue --dry-run` when exploring behavior. +- Prefer `.build/start-issue --dry-run` when exploring behavior. - Do not run release commands unless preparing an actual release. - Do not delete worktrees manually while tests depend on fake worktree state; use isolated temp directories in tests. diff --git a/memory-bank/ops/release.md b/memory-bank/ops/release.md index e7a3fd6..1f25ed1 100644 --- a/memory-bank/ops/release.md +++ b/memory-bank/ops/release.md @@ -14,55 +14,63 @@ audience: humans_and_agents # Release And Deployment -`start-issue` is distributed as a single-file Bash executable through GitHub +`start-issue` is distributed as platform-specific Go binaries through GitHub Releases. There is no server deployment. ## Release Flow 1. Add user-facing changes under `## [Unreleased]` in `CHANGELOG.md`. -2. Run one release prep command: +2. From a clean worktree, run the required checks and create the SemVer tag. + The tag is the source of the release version: ```bash -make release-patch -make release-minor -make release-major +make test +make build +git tag -a vX.Y.Z -m "Release vX.Y.Z" ``` -3. The release script bumps `VERSION`, updates changelog entries, runs - `make test`, runs `make build`, creates `Release vX.Y.Z`, and creates an - annotated tag. -4. Publish with: +3. Publish with: ```bash git push origin master --follow-tags ``` -5. GitHub Actions builds the release asset and checksum. +If a lightweight tag was created instead, push that exact tag explicitly: + +```bash +git push origin vX.Y.Z +``` + +4. GitHub Actions reruns the test suite and uses GoReleaser to publish the + binaries and checksum manifest. ## Release Assets -Each release uploads: +Each release uploads one binary for every supported target: -- `start-issue` -- `start-issue.sha256` +- `start-issue-linux-amd64` +- `start-issue-linux-arm64` +- `start-issue-darwin-amd64` +- `start-issue-darwin-arm64` +- `start-issue-windows-amd64.exe` +- `checksums.txt` +- `start-issue` and `start-issue.sha256` during the v1-to-v2 transition only The installer and self-update workflow download the release asset and verify the -checksum before install. +checksum before install. The two legacy-named assets are a verified POSIX +migration bridge: v1.13.2 and older updaters install it, then it resolves, +verifies, and replaces itself with the matching v2 platform binary. -## Release Commands +## Version Source ```bash make print-version -make bump-patch -make bump-minor -make bump-major -make release-patch -make release-minor -make release-major ``` -Do not run release commands with a dirty worktree unless the release script -explicitly supports the current state. +`make build` and `make install` derive the embedded version from `git describe` +using the nearest SemVer tag. A tagged release therefore reports the tag +version, while a checkout between tags reports its describe suffix. GoReleaser +embeds the pushed release tag in published binaries. ## Release Verification @@ -75,9 +83,10 @@ make build Release-specific checks: -- `VERSION` in `scripts/start-issue` matches the tag without the `v` prefix. +- `make print-version` matches the intended tag version when run at that tag. - `CHANGELOG.md` has the new version/date section. -- Release asset and checksum are present in GitHub Releases after CI. +- All five platform assets, `checksums.txt`, and the v1 migration bridge assets + `start-issue`/`start-issue.sha256` are present in GitHub Releases after CI. - `start-issue update` can resolve the latest release and no-op/install correctly. diff --git a/memory-bank/ops/stages.md b/memory-bank/ops/stages.md index d70abba..2cd1add 100644 --- a/memory-bank/ops/stages.md +++ b/memory-bank/ops/stages.md @@ -20,7 +20,7 @@ limited to GitHub and external CLIs. | Environment | Purpose | Access path | Notes | | --- | --- | --- | --- | | GitHub repository | Issues, CI, releases, tags | `gh`, git remote, GitHub UI | Auth required for issue/update/release operations | -| GitHub Releases | Distribution source for installer and update | `gh api`, release URLs | Publishes `start-issue` and checksum | +| GitHub Releases | Distribution source for installer and update | `gh api`, release URLs | Publishes platform binaries and `checksums.txt` | | User machine | Installed executable and user config | local filesystem | Update installs into running executable path | ## Common Operations @@ -53,7 +53,7 @@ gh api repos/dapi/start-issue/releases/latest --jq .tag_name There is no centralized runtime observability. Diagnostics come from: - command output; -- Bats test logs; +- Go test logs; - GitHub Actions logs; - human-gate state files under `/.start-issue/runs//`. diff --git a/memory-bank/product/context.md b/memory-bank/product/context.md index 382abc1..22a8055 100644 --- a/memory-bank/product/context.md +++ b/memory-bank/product/context.md @@ -67,7 +67,7 @@ tracker, or the developer's review and merge process. | Metric ID | Metric | Baseline | Target | Measurement method | | --- | --- | --- | --- | --- | | `MET-01` | Time from issue reference to prepared worktree | Manual setup | One command for common cases | Manual workflow review and regression tests | -| `MET-02` | Predictability of config and launch behavior | Historically implicit defaults | Effective agent, model, prompt source, and launch command are visible | CLI output, `--dry-run`, Bats coverage | +| `MET-02` | Predictability of config and launch behavior | Historically implicit defaults | Effective agent, model, prompt source, and launch command are visible | CLI output, `--dry-run`, Go test coverage | | `MET-03` | Release/install reliability | Manual install/update risk | Checksummed release asset and self-update path | Release workflow and installer/update tests | ## Product Constraints @@ -79,9 +79,8 @@ tracker, or the developer's review and merge process. - `PCON-03` Prompt and config changes must be visible and reviewable; prompt improvement writes proposals instead of overwriting active templates. - `PCON-04` Agent-specific behavior belongs behind the adapter boundary. -- `PCON-05` Bash remains acceptable while workflow complexity stays readable; a - richer lifecycle, nested config, or structured machine output is the threshold - for reevaluating a Python core. +- `PCON-05` The Go CLI is the sole runtime. New lifecycle behavior belongs in + focused Go helpers and must not reintroduce a second shell or Python runtime. ## Source Documents diff --git a/memory-bank/product/customers.md b/memory-bank/product/customers.md index 1155ad2..6ddc4cf 100644 --- a/memory-bank/product/customers.md +++ b/memory-bank/product/customers.md @@ -22,7 +22,7 @@ canonical_for: | --- | --- | --- | --- | --- | --- | | `SEG-01` | Maintainer/developer of this repository | Start work on a GitHub issue in an isolated worktree with the right agent | Repeated manual setup and inconsistent launch commands | One command prepares the expected workspace | README workflow and feature history | | `SEG-02` | Coding agent session | Receive enough context and repository instructions to work safely | Missing repo context, wrong prompt, unclear checks | Prompt contains issue/worktree/base branch and repo instructions are discoverable | Prompt template and AGENTS.md | -| `SEG-03` | Release maintainer | Publish and update a single-file CLI safely | Manual version/changelog/tag/release steps can drift | `make release-*`, checksums, and update tests pass | Release docs and scripts | +| `SEG-03` | Release maintainer | Publish and update platform-specific CLI binaries safely | Manual version/changelog/tag/release steps can drift | Tagged GoReleaser release, checksum manifest, and update tests pass | Release docs and workflows | ## Users And Actors @@ -37,7 +37,7 @@ canonical_for: - Existing feature packages under [features/](../features/README.md). - Current public docs in [README.md](../../README.md) and [doc/spec.md](../../doc/spec.md). -- Test suite behavior in `test/start_issue.bats`. +- Test suite behavior in `cmd/start-issue/*_test.go`. ## Assumptions diff --git a/memory-bank/product/metrics.md b/memory-bank/product/metrics.md index 27c9caf..ab5b07f 100644 --- a/memory-bank/product/metrics.md +++ b/memory-bank/product/metrics.md @@ -19,15 +19,15 @@ canonical_for: | Metric ID | Metric | Why it matters | Current baseline | Target | Review cadence | | --- | --- | --- | --- | --- | --- | -| `NSM-01` | Successful issue-start completion for supported workflows | It captures the core product value | Covered by Bats regression scenarios | No known regression in supported flows | Each release | +| `NSM-01` | Successful issue-start completion for supported workflows | It captures the core product value | Covered by Go regression and parity scenarios | No known regression in supported flows | Each release | ## Product Metrics | Metric ID | Metric | Owner | Baseline | Target | Measurement method | Source | | --- | --- | --- | --- | --- | --- | --- | | `MET-01` | Local verification pass rate | Maintainer | `make test` is canonical | Pass before release and handoff | Local command and CI | Makefile / GitHub Actions | -| `MET-02` | Config visibility | Maintainer | Missing issue and dry-run paths print effective config | Every config source change updates output tests | Bats assertions | `test/start_issue.bats` | -| `MET-03` | Release artifact integrity | Maintainer | Release asset plus `.sha256` | Installer/update verify checksum | Release workflow and tests | GitHub Releases | +| `MET-02` | Config visibility | Maintainer | Missing issue and dry-run paths print effective config | Every config source change updates output tests | Go output assertions | `cmd/start-issue/*_test.go` | +| `MET-03` | Release artifact integrity | Maintainer | Platform binary plus `checksums.txt` | Installer/update verify checksum | Release workflow and Go tests | GitHub Releases | | `MET-04` | Memory-bank navigation health | Maintainer/agent | New audit introduced | `scripts/check_memory_bank_index.py --max-depth 4` passes | Local audit in `make test` | memory-bank audit | ## Guardrails diff --git a/memory-bank/product/roadmap.md b/memory-bank/product/roadmap.md index 9f20324..04ce786 100644 --- a/memory-bank/product/roadmap.md +++ b/memory-bank/product/roadmap.md @@ -29,7 +29,7 @@ into feature packages. | `now` | Memory-bank adoption | Agents have project-specific process, product, domain, engineering, and ops context | Current memory-bank work | Template source from `dapi/memory-bank` | active | | `next` | New feature-flow adoption | New medium/large features use `brief.md -> optional design.md -> implementation-plan.md` | Future `FT-*` packages | AGENTS.md and flows docs | planned | | `next` | Release confidence | Release prep, changelog, version, build, and update path stay coherent | Existing release scripts | CI and `make test` | active | -| `later` | Richer lifecycle commands | Possible `resume`, `list`, `cleanup`, or structured output | Unknown | Requires design and Bash/Python reevaluation | idea | +| `later` | Richer lifecycle commands | Possible `resume`, `list`, `cleanup`, or structured output | Unknown | Requires Go design and module-boundary review | idea | ## Roadmap Rules @@ -44,5 +44,5 @@ into feature packages. - `BET-01` Whether Codex human-gate patterns should remain Codex-only or become a generic agent capability after other CLIs expose equivalent contracts. -- `BET-02` Whether future lifecycle complexity justifies moving orchestration - from Bash modules to a Python core. +- `BET-02` Whether future lifecycle complexity warrants extracting Go helper + packages from the current command package. diff --git a/mise.toml b/mise.toml index 59eb3f1..fc07e58 100644 --- a/mise.toml +++ b/mise.toml @@ -1,4 +1,4 @@ [tools] -bats = "1.13.0" +go = "1.24" gh = "2.90.0" jq = "1.8.1" diff --git a/scripts/v1-upgrade-shim b/scripts/v1-upgrade-shim new file mode 100644 index 0000000..a3f560a --- /dev/null +++ b/scripts/v1-upgrade-shim @@ -0,0 +1,78 @@ +#!/usr/bin/env bash + +# This compatibility asset is published only to let v1.13.2 and older +# installations complete their documented `start-issue update` migration. The +# legacy updater requires assets named start-issue and start-issue.sha256. +set -euo pipefail + +repository="${START_ISSUE_REPOSITORY:-dapi/start-issue}" + +fetch() { + local url="$1" + local destination="$2" + + if command -v curl >/dev/null 2>&1; then + curl -fsSL "$url" -o "$destination" + return + fi + if command -v wget >/dev/null 2>&1; then + wget -qO "$destination" "$url" + return + fi + + echo "start-issue v1 upgrade bridge requires curl or wget." >&2 + exit 1 +} + +sha256() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{ print $1 }' + return + fi + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | awk '{ print $1 }' + return + fi + if command -v openssl >/dev/null 2>&1; then + openssl dgst -sha256 "$1" | awk '{ print $NF }' + return + fi + + echo "start-issue v1 upgrade bridge requires a SHA-256 tool." >&2 + exit 1 +} + +os="$(uname -s | tr '[:upper:]' '[:lower:]')" +case "$os" in + linux|darwin) ;; + *) echo "Unsupported OS for start-issue upgrade: $os" >&2; exit 1 ;; +esac + +case "$(uname -m)" in + x86_64|amd64) arch=amd64 ;; + arm64|aarch64) arch=arm64 ;; + *) echo "Unsupported architecture for start-issue upgrade: $(uname -m)" >&2; exit 1 ;; +esac + +asset="start-issue-${os}-${arch}" +asset_url="${START_ISSUE_UPGRADE_ASSET_URL:-https://github.com/${repository}/releases/latest/download/${asset}}" +checksum_url="${START_ISSUE_UPGRADE_CHECKSUM_URL:-https://github.com/${repository}/releases/latest/download/checksums.txt}" +temporary_dir="$(mktemp -d)" +trap 'rm -rf "$temporary_dir"' EXIT + +binary="$temporary_dir/$asset" +checksums="$temporary_dir/checksums.txt" +fetch "$asset_url" "$binary" +fetch "$checksum_url" "$checksums" + +expected="$(awk -v asset="$asset" '$2 == asset || $2 == "*" asset { print $1; exit }' "$checksums")" +actual="$(sha256 "$binary")" +if [[ -z "$expected" || "$expected" != "$actual" ]]; then + echo "Checksum verification failed for $asset." >&2 + exit 1 +fi + +script_dir="$(cd -P -- "$(dirname -- "$0")" && pwd)" +script_path="$script_dir/$(basename -- "$0")" +install -m 0755 "$binary" "$script_path" +exec "$script_path" "$@" diff --git a/test/e2e/human-gate.sh b/test/e2e/human-gate.sh index 7ebccbb..9f1efa2 100755 --- a/test/e2e/human-gate.sh +++ b/test/e2e/human-gate.sh @@ -42,7 +42,7 @@ done [[ "$scenario" == "done" || "$scenario" == "human-gate" ]] || fail "scenario must be done or human-gate" [[ "${START_ISSUE_E2E:-}" == "1" ]] || fail "set START_ISSUE_E2E=1 to authorize a real Codex session" -start_issue_bin="${START_ISSUE_E2E_BINARY:-$repo_root/scripts/start-issue}" +start_issue_bin="${START_ISSUE_E2E_BINARY:-$repo_root/.build/start-issue}" [[ -x "$start_issue_bin" ]] || fail "start-issue executable not found: $start_issue_bin" diff --git a/test/e2e/sandbox.sh b/test/e2e/sandbox.sh new file mode 100755 index 0000000..41fa469 --- /dev/null +++ b/test/e2e/sandbox.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash + +# Deterministic, network-free E2E smoke test for the built Go CLI. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +start_issue_bin="${START_ISSUE_SANDBOX_BINARY:-$repo_root/.build/start-issue}" +fixture_root="$(mktemp -d "${TMPDIR:-/tmp}/start-issue-sandbox-e2e.XXXXXX")" +fixture_root="$(cd "$fixture_root" && pwd -P)" +trap 'rm -rf -- "$fixture_root"' EXIT + +fail() { + printf 'E2E sandbox: %s\n' "$*" >&2 + exit 1 +} + +[[ -x "$start_issue_bin" ]] || fail "start-issue executable not found: $start_issue_bin" + +repo="$fixture_root/repo" +remote="$fixture_root/remote.git" +worktrees="$fixture_root/worktrees" +home="$fixture_root/home" +bin="$fixture_root/bin" +kimi_log="$fixture_root/kimi.log" +init_marker="$fixture_root/init.marker" +mkdir -p "$repo" "$worktrees" "$home/.config/start-issue" "$bin" + +git -C "$repo" init -q +git -C "$repo" config user.email sandbox@example.invalid +git -C "$repo" config user.name 'start-issue sandbox' +printf '# sandbox fixture\n' > "$repo/README.md" +cat > "$repo/init.sh" < "$init_marker" +EOF +chmod +x "$repo/init.sh" +git -C "$repo" add README.md init.sh +git -C "$repo" commit -q -m 'sandbox fixture' +git init --bare -q "$remote" +git -C "$repo" remote add origin "$remote" +git -C "$repo" push -q -u origin HEAD + +cat > "$bin/gh" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +case "${1:-}" in + auth) + [[ "${2:-}" == "status" ]] || exit 1 + ;; + api) + printf '%s\n' '{"number":42,"title":"Sandbox E2E issue","body":"controlled fixture","labels":[{"name":"feature"}]}' + ;; + *) + printf 'unexpected gh invocation: %s\n' "$*" >&2 + exit 1 + ;; +esac +EOF + +cat > "$bin/kimi" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +printf 'cwd=%s\nargs=%s\n' "$PWD" "$*" > "$KIMI_LOG" +EOF + +chmod +x "$bin/gh" "$bin/kimi" + +export HOME="$home" +export KIMI_LOG="$kimi_log" +export START_ISSUE_REPOSITORY=acme/sandbox +export PATH="$bin:$PATH" + +printf 'Sandbox E2E: issue start with real git worktree and fake Kimi\n' +( + cd "$repo" + "$start_issue_bin" 42 \ + --repo acme/sandbox \ + --agent kimi \ + --model sandbox-model \ + --worktree-dir "$worktrees" \ + --prompt 'Implement {ISSUE_URL} in {WORKTREE_PATH}' +) + +worktree="$worktrees/feature/issue-42-sandbox-e2e-issue" +[[ -d "$worktree" ]] || fail "worktree was not created: $worktree" +[[ -f "$init_marker" ]] || fail "init.sh was not executed" +[[ "$(cat "$init_marker")" == "$worktree" ]] || fail "init.sh ran outside worktree: got $(cat "$init_marker"), want $worktree" +[[ "$(sed -n '1p' "$kimi_log")" == "cwd=$worktree" ]] || fail "Kimi cwd is wrong: $(sed -n '1p' "$kimi_log")" +grep -F -- '--model sandbox-model -p Implement https://github.com/acme/sandbox/issues/42 in' "$kimi_log" >/dev/null || \ + fail "Kimi arguments do not contain rendered prompt" +git -C "$worktree" status --porcelain | grep -v '^?? \.start-issue/' >/dev/null && \ + fail "worktree contains unexpected changes" + +printf 'Sandbox E2E: dry-run no-agent path\n' +dry_run_log="$fixture_root/dry-run.log" +( + cd "$repo" + "$start_issue_bin" 43 \ + --repo acme/sandbox \ + --agent none \ + --no-init \ + --dry-run \ + --flat \ + --worktree-dir "$fixture_root/dry-run-worktrees" +) > "$dry_run_log" +grep -F '[DRY-RUN] Would run: git worktree add' "$dry_run_log" >/dev/null || fail "dry-run did not plan worktree creation" +grep -F 'Agent: none' "$dry_run_log" >/dev/null || fail "dry-run did not resolve no-agent" +[[ ! -d "$fixture_root/dry-run-worktrees" ]] || fail "dry-run created worktree directory" + +printf 'PASS: sandbox E2E scenarios\n' diff --git a/test/fixtures/issue-1.json b/test/fixtures/issue-1.json deleted file mode 100644 index 544f051..0000000 --- a/test/fixtures/issue-1.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "number": 1, - "title": "Add login button", - "body": "Add a login button to the header and keep the implementation scoped.", - "html_url": "https://github.com/owner/repo/issues/1", - "labels": [ - { - "name": "enhancement" - }, - { - "name": "ui" - } - ] -} diff --git a/test/helpers/fake-bin/claude b/test/helpers/fake-bin/claude deleted file mode 100755 index 57d66e1..0000000 --- a/test/helpers/fake-bin/claude +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -if [[ -n "${START_ISSUE_FAKE_EXPECT_MODEL:-}" && "$*" != *"--model ${START_ISSUE_FAKE_EXPECT_MODEL}"* ]]; then - printf "%s\n" "expected model flag --model ${START_ISSUE_FAKE_EXPECT_MODEL}" >&2 - exit 1 -fi - -# Assert the branch-name prompt contains EXPECT_PROMPT. Only the --print path is -# checked; the prompt-improvement path ("Improve the following ...") also uses -# --print but carries a different prompt, so it is excluded. -if [[ -n "${START_ISSUE_FAKE_EXPECT_PROMPT:-}" \ - && "$*" == *"--print"* \ - && "$*" != *"Improve the following"* \ - && "$*" != *"${START_ISSUE_FAKE_EXPECT_PROMPT}"* ]]; then - printf "%s\n" "expected prompt to contain: ${START_ISSUE_FAKE_EXPECT_PROMPT}" >&2 - exit 1 -fi - -if [[ "${START_ISSUE_FAKE_FORBID_MODEL:-}" == "1" && "$*" == *"--model "* ]]; then - printf "%s\n" "unexpected model flag" >&2 - exit 1 -fi - -if [[ "${START_ISSUE_FAKE_AGENT_FAIL:-}" == "1" ]]; then - exit 1 -fi - -for arg in "$@"; do - if [[ "$arg" == "--print" ]]; then - if [[ "$*" == *"Improve the following start-issue prompt template"* ]]; then - improved_prompt="${START_ISSUE_FAKE_IMPROVED_PROMPT:-Improved prompt}" - printf "%s\n" "$improved_prompt" - exit 0 - fi - - printf "%s\n" "${START_ISSUE_FAKE_BRANCH_NAME:-feature/issue-1-ai-branch}" - exit 0 - fi -done - -printf "fake claude invoked\n" diff --git a/test/helpers/fake-bin/codex b/test/helpers/fake-bin/codex deleted file mode 100755 index d44485f..0000000 --- a/test/helpers/fake-bin/codex +++ /dev/null @@ -1,128 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -if [[ -n "${START_ISSUE_FAKE_EXPECT_MODEL:-}" && "$*" != *"--model ${START_ISSUE_FAKE_EXPECT_MODEL}"* ]]; then - printf "%s\n" "expected model flag --model ${START_ISSUE_FAKE_EXPECT_MODEL}" >&2 - exit 1 -fi - -if [[ "${START_ISSUE_FAKE_FORBID_MODEL:-}" == "1" && "$*" == *"--model "* ]]; then - printf "%s\n" "unexpected model flag" >&2 - exit 1 -fi - -if [[ "${START_ISSUE_FAKE_AGENT_FAIL:-}" == "1" ]]; then - exit 1 -fi - -if [[ "${START_ISSUE_FAKE_CODEX_REJECT_ASK_FOR_APPROVAL:-}" == "1" && "$*" == *"--ask-for-approval"* ]]; then - printf '%s\n' "unexpected obsolete --ask-for-approval flag" >&2 - exit 1 -fi - -if [[ "${1:-}" == "resume" ]]; then - shift - - if [[ "${START_ISSUE_FAKE_CODEX_RESUME_FAIL:-}" == "1" ]]; then - exit 1 - fi - - printf "fake codex resume %s\n" "$*" - exit 0 -fi - -if [[ "${1:-}" == "exec" ]]; then - shift - - json_mode=0 - output_last_message="" - extra_args=() - - while [[ $# -gt 0 ]]; do - case "$1" in - --json) - json_mode=1 - shift - ;; - --output-last-message) - output_last_message="${2:-}" - shift 2 - ;; - --model|--cd|--sandbox) - shift 2 - ;; - --skip-git-repo-check|-) - shift - ;; - *) - extra_args+=("$1") - shift - ;; - esac - done - - request="" - if [[ ! -t 0 ]]; then - request="$(cat)" - fi - - if [[ -z "$request" && "${#extra_args[@]}" -gt 0 ]]; then - request="${extra_args[*]}" - fi - - if [[ "$json_mode" == "1" || -n "$output_last_message" ]]; then - if [[ "${START_ISSUE_FAKE_CODEX_OMIT_THREAD_ID:-}" != "1" ]]; then - printf '{"type":"thread.started","thread_id":"%s"}\n' "${START_ISSUE_FAKE_CODEX_THREAD_ID:-019e5b45-f9ac-76c1-8177-4317c42d04f9}" - fi - - if [[ -n "$output_last_message" ]]; then - mkdir -p "$(dirname "$output_last_message")" - if [[ -n "${START_ISSUE_FAKE_CODEX_LAST_MESSAGE:-}" ]]; then - printf "%s\n" "$START_ISSUE_FAKE_CODEX_LAST_MESSAGE" > "$output_last_message" - elif [[ "${START_ISSUE_FAKE_CODEX_STATUS:-DONE}" == "HUMAN_GATE" ]]; then - cat > "$output_last_message" <<'EOF' -STATUS: HUMAN_GATE - -Blocker: -Need a human decision. - -Question: -Which option should be used? - -Options: -- Option A: Keep the current behavior. -- Option B: Change the behavior. - -Recommendation: -Option A keeps the workflow stable. -EOF - else - cat > "$output_last_message" <<'EOF' -STATUS: DONE - -Summary: -- Completed. - -Validation: -- Fake validation. - -Changed files: -- fake-file -EOF - fi - fi - - exit "${START_ISSUE_FAKE_CODEX_BATCH_EXIT:-0}" - fi - - if [[ "$request" == *"Improve the following start-issue prompt template"* ]]; then - improved_prompt="${START_ISSUE_FAKE_IMPROVED_PROMPT:-Improved prompt}" - printf "%s\n" "$improved_prompt" - exit 0 - fi - - printf "%s\n" "${START_ISSUE_FAKE_BRANCH_NAME:-feature/issue-1-ai-branch}" - exit 0 -fi - -printf "fake codex invoked\n" diff --git a/test/helpers/fake-bin/gh b/test/helpers/fake-bin/gh deleted file mode 100755 index 6ffc7bc..0000000 --- a/test/helpers/fake-bin/gh +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -case "${1:-}" in - auth) - if [[ "${2:-}" == "status" ]]; then - exit 0 - fi - ;; - api) - endpoint="${2:-}" - if [[ "$endpoint" =~ ^repos/[^/]+/[^/]+/issues/[0-9]+$ ]]; then - cat "${START_ISSUE_FAKE_ISSUE_JSON:?START_ISSUE_FAKE_ISSUE_JSON is required}" - exit 0 - fi - if [[ "$endpoint" =~ ^repos/[^/]+/[^/]+/releases/latest$ ]]; then - cat "${START_ISSUE_FAKE_LATEST_RELEASE_JSON:?START_ISSUE_FAKE_LATEST_RELEASE_JSON is required}" - exit 0 - fi - ;; -esac - -echo "fake gh: unsupported arguments: $*" >&2 -exit 1 diff --git a/test/helpers/fake-bin/kimi b/test/helpers/fake-bin/kimi deleted file mode 100755 index e7dfc5e..0000000 --- a/test/helpers/fake-bin/kimi +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -if [[ -n "${START_ISSUE_FAKE_EXPECT_MODEL:-}" && "$*" != *"--model ${START_ISSUE_FAKE_EXPECT_MODEL}"* ]]; then - printf "%s\n" "expected model flag --model ${START_ISSUE_FAKE_EXPECT_MODEL}" >&2 - exit 1 -fi - -if [[ "${START_ISSUE_FAKE_FORBID_MODEL:-}" == "1" && "$*" == *"--model "* ]]; then - printf "%s\n" "unexpected model flag" >&2 - exit 1 -fi - -if [[ "${START_ISSUE_FAKE_AGENT_FAIL:-}" == "1" ]]; then - exit 1 -fi - -if [[ "$*" == *"Improve the following start-issue prompt template"* ]]; then - improved_prompt="${START_ISSUE_FAKE_IMPROVED_PROMPT:-Improved prompt}" - printf "%s\n" "$improved_prompt" - exit 0 -fi - -printf "%s\n" "${START_ISSUE_FAKE_BRANCH_NAME:-feature/issue-1-ai-branch}" diff --git a/test/helpers/fake-bin/pi b/test/helpers/fake-bin/pi deleted file mode 100755 index 17b0b24..0000000 --- a/test/helpers/fake-bin/pi +++ /dev/null @@ -1,31 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -if [[ -n "${START_ISSUE_FAKE_EXPECT_MODEL:-}" && "$*" != *"--model ${START_ISSUE_FAKE_EXPECT_MODEL}"* ]]; then - printf "%s\n" "expected model flag --model ${START_ISSUE_FAKE_EXPECT_MODEL}" >&2 - exit 1 -fi - -if [[ "${START_ISSUE_FAKE_FORBID_MODEL:-}" == "1" && "$*" == *"--model "* ]]; then - printf "%s\n" "unexpected model flag" >&2 - exit 1 -fi - -if [[ "${START_ISSUE_FAKE_AGENT_FAIL:-}" == "1" ]]; then - exit 1 -fi - -for arg in "$@"; do - if [[ "$arg" == "--print" ]]; then - if [[ "$*" == *"Improve the following start-issue prompt template"* ]]; then - improved_prompt="${START_ISSUE_FAKE_IMPROVED_PROMPT:-Improved prompt}" - printf "%s\n" "$improved_prompt" - exit 0 - fi - - printf "%s\n" "${START_ISSUE_FAKE_BRANCH_NAME:-feature/issue-1-ai-branch}" - exit 0 - fi -done - -printf "fake pi invoked\n" diff --git a/test/start_issue.bats b/test/start_issue.bats deleted file mode 100644 index 18657c1..0000000 --- a/test/start_issue.bats +++ /dev/null @@ -1,1341 +0,0 @@ -#!/usr/bin/env bats - -setup() { - REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/.." && pwd)" - TEST_TMPDIR="${BATS_TEST_TMPDIR:-${BATS_TMPDIR:?}}" - export REPO_ROOT - export PATH="$REPO_ROOT/test/helpers/fake-bin:$PATH" - export START_ISSUE_FAKE_ISSUE_JSON="$REPO_ROOT/test/fixtures/issue-1.json" - - export HOME="$TEST_TMPDIR/home" - mkdir -p "$HOME" - mkdir -p "$HOME/.config/start-issue" - - TEST_REPO="$TEST_TMPDIR/repo" - mkdir -p "$TEST_REPO" - git -C "$TEST_REPO" init -q -b master - git -C "$TEST_REPO" config user.email "ci@example.invalid" - git -C "$TEST_REPO" config user.name "CI" - printf "fixture\n" > "$TEST_REPO/README.md" - git -C "$TEST_REPO" add README.md - git -C "$TEST_REPO" commit -q -m "Initial commit" - git -C "$TEST_REPO" remote add origin git@github.com:owner/repo.git - cd "$TEST_REPO" - - unset START_ISSUE_AGENT - unset START_ISSUE_MODEL - unset START_ISSUE_PROMPT - unset START_ISSUE_PROMPT_FILE - unset START_ISSUE_WORKTREE_DIR - unset START_ISSUE_FAKE_BRANCH_NAME - unset START_ISSUE_FAKE_AGENT_FAIL - unset START_ISSUE_FAKE_EXPECT_MODEL - unset START_ISSUE_FAKE_EXPECT_PROMPT - unset START_ISSUE_FAKE_FORBID_MODEL - unset START_ISSUE_FAKE_LATEST_RELEASE_JSON - unset START_ISSUE_REPOSITORY - unset START_ISSUE_RUN_ID - unset START_ISSUE_FAKE_CODEX_STATUS - unset START_ISSUE_FAKE_CODEX_LAST_MESSAGE - unset START_ISSUE_FAKE_CODEX_THREAD_ID - unset START_ISSUE_FAKE_CODEX_OMIT_THREAD_ID - unset START_ISSUE_FAKE_CODEX_RESUME_FAIL - unset START_ISSUE_FAKE_CODEX_BATCH_EXIT -} - -run_start_issue() { - run "$REPO_ROOT/scripts/start-issue" "$@" -} - -run_install_script() { - run env \ - HOME="$HOME" \ - PREFIX="$TEST_TMPDIR/install-prefix" \ - BINDIR="$TEST_TMPDIR/install-prefix/bin" \ - TARGET="$TEST_TMPDIR/install-prefix/bin/start-issue" \ - START_ISSUE_REPOSITORY="test/local" \ - START_ISSUE_ASSET_URL="file://$TEST_TMPDIR/install-fixture/start-issue" \ - START_ISSUE_CHECKSUM_URL="file://$TEST_TMPDIR/install-fixture/start-issue.sha256" \ - bash "$REPO_ROOT/install.sh" "$@" -} - -run_piped_install_script() { - run env \ - HOME="$HOME" \ - PREFIX="$TEST_TMPDIR/install-prefix" \ - BINDIR="$TEST_TMPDIR/install-prefix/bin" \ - TARGET="$TEST_TMPDIR/install-prefix/bin/start-issue" \ - START_ISSUE_REPOSITORY="test/local" \ - START_ISSUE_ASSET_URL="file://$TEST_TMPDIR/install-fixture/start-issue" \ - START_ISSUE_CHECKSUM_URL="file://$TEST_TMPDIR/install-fixture/start-issue.sha256" \ - bash -c 'bash < "$1"' -- "$REPO_ROOT/install.sh" -} - -build_installed_start_issue() { - local output_path="$1" - local version="$2" - local tmpfile - - bash "$REPO_ROOT/scripts/build-start-issue" "$output_path" >/dev/null - tmpfile="$TEST_TMPDIR/versioned-start-issue" - awk -v version="$version" ' - BEGIN { replaced = 0 } - /^VERSION="/ && replaced == 0 { - print "VERSION=\"" version "\"" - replaced = 1 - next - } - { print } - ' "$output_path" > "$tmpfile" - mv "$tmpfile" "$output_path" - chmod +x "$output_path" -} - -create_fake_release_assets() { - local dir="$1" - local version="$2" - local asset_path="$dir/start-issue" - local checksum_path="$dir/start-issue.sha256" - local json_path="$dir/release.json" - local asset_url - local checksum_url - local checksum - - mkdir -p "$dir" - cat > "$asset_path" < "$checksum_path" - asset_url="file://$asset_path" - checksum_url="file://$checksum_path" - cat > "$json_path" < \"\${START_ISSUE_ZELLIJ_LOG:?}\"" - } > "$ZELLIJ_FAKE_BIN/zellij-tab-status" - chmod +x "$ZELLIJ_FAKE_BIN/zellij-tab-status" - export PATH="$ZELLIJ_FAKE_BIN:$PATH" - export START_ISSUE_ZELLIJ_LOG="$TEST_TMPDIR/zellij-tab-status.log" -} - -@test "default agent is claude and SSH origin remote is parsed" { - run_start_issue 1 --dry-run --no-init - - assert_success - assert_output_contains "Agent: claude" - assert_output_contains "Agent source: built-in default" - assert_output_contains "Model: " - assert_output_contains "Model source: built-in default" - assert_output_contains "Fetching issue #1 from owner/repo" - assert_output_contains "Prompt source: built-in Claude command" - assert_output_contains "Default prompt files:" - assert_output_contains "Project:" - assert_output_contains ".start-issue/prompt.md" - assert_output_contains "User: $HOME/.config/start-issue/prompt.md" - assert_output_contains "claude --dangerously-skip-permissions" - assert_output_contains "/task-router:route-task" -} - -@test "missing issue prints selected default agent and prompt details" { - run_start_issue - - assert_failure - assert_output_contains "Error: missing issue URL or issue number" - assert_output_contains 'Run `start-issue --help` for full usage and prompt variables.' - assert_output_contains "Usage: start-issue [options]" - assert_output_contains "Examples:" - assert_output_contains "Prompt variables:" - assert_output_contains "Current configuration:" - assert_output_contains "Agent: claude" - assert_output_contains "Agent source: built-in default" - assert_output_contains "Model: " - assert_output_contains "Model source: built-in default" - assert_output_contains "Prompt source: built-in Claude command" - assert_output_contains "Prompt location: $REPO_ROOT/scripts/start-issue" - assert_output_contains "Project model:" - assert_output_contains ".start-issue/model" - assert_output_contains "User model:" - assert_output_contains "Worktree dir: $HOME/worktrees (built-in default)" - assert_output_contains "Default prompt files:" - assert_output_contains "Project:" - assert_output_contains ".start-issue/prompt.md" - assert_output_contains "User: $HOME/.config/start-issue/prompt.md" - [[ "$output" != *"Prompt preview:"* ]] - [[ "$output" != *"Options:"* ]] - [[ "$output" != *"Agent selection precedence:"* ]] - [[ "$output" != *"Fetching issue"* ]] -} - -@test "help lists environment variables for prompt and config sources" { - run_start_issue --help - - assert_success - assert_output_contains "Environment variables:" - assert_output_contains "START_ISSUE_AGENT" - assert_output_contains "START_ISSUE_PROMPT" - assert_output_contains "START_ISSUE_PROMPT_FILE" - assert_output_contains "START_ISSUE_WORKTREE_DIR" - assert_output_contains "START_ISSUE_DUMP_PROMPT" -} - -@test "help documents update entry points" { - run_start_issue --help - - assert_success - assert_output_contains "start-issue update [options]" - assert_output_contains "--update" - assert_output_contains "start-issue update" - assert_output_contains "start-issue --update" -} - -@test "help documents setup entry points" { - run_start_issue --help - - assert_success - assert_output_contains "start-issue setup [options]" - assert_output_contains "--setup" - assert_output_contains "start-issue setup" - assert_output_contains "start-issue --setup" -} - -@test "missing issue prints project agent and prompt file location" { - mkdir -p .start-issue - printf "codex\n" > .start-issue/agent - printf "Project prompt for {ISSUE_URL}\n" > .start-issue/prompt.md - - run_start_issue - - assert_failure - assert_output_contains "Error: missing issue URL or issue number" - assert_output_contains "Agent: codex" - assert_output_contains "Agent source: " - assert_output_contains ".start-issue/agent" - assert_output_contains "Prompt source:" - assert_output_contains ".start-issue/prompt.md" - assert_output_contains "Prompt location:" - [[ "$output" != *"Prompt preview:"* ]] - [[ "$output" != *"Fetching issue"* ]] -} - -@test "help documents model option and config surfaces" { - run_start_issue --help - - assert_success - assert_output_contains "--model " - assert_output_contains "START_ISSUE_MODEL" - assert_output_contains ".start-issue/model" - assert_output_contains "~/.config/start-issue/model" -} - -@test "help documents human-gate entry points" { - run_start_issue --help - - assert_success - assert_output_contains "--human-gate" - assert_output_contains "--human-gate-help" - assert_output_contains "start-issue 123 --agent codex --human-gate" -} - -@test "dedicated human-gate help documents the contract" { - run_start_issue --human-gate-help - - assert_success - assert_output_contains "Codex human-gate mode" - assert_output_contains "STATUS: DONE" - assert_output_contains "STATUS: HUMAN_GATE" - assert_output_contains "codex resume --include-non-interactive " - assert_output_contains ".start-issue/runs//events.jsonl" -} - -@test "real Codex human-gate E2E runner requires explicit authorization" { - run bash "$REPO_ROOT/test/e2e/human-gate.sh" - - assert_failure - assert_output_contains "set START_ISSUE_E2E=1" -} - -@test "real Codex human-gate E2E runner documents its scenarios" { - run bash "$REPO_ROOT/test/e2e/human-gate.sh" --help - - assert_success - assert_output_contains "--scenario done|human-gate" -} - -@test "prompt improvement without issue prints explicit error" { - run_start_issue --improve-prompt - - assert_failure - assert_output_contains "--improve-prompt requires " - assert_output_contains "Example: start-issue 123 --improve-prompt" - [[ "$output" != *"Current configuration:"* ]] - [[ "$output" != *"Fetching issue"* ]] -} - -@test "setup subcommand writes user config with selected agent and prompt" { - run bash -c 'printf "2\ny\n" | "$REPO_ROOT/scripts/start-issue" setup' - - assert_success - assert_output_contains "Select default agent:" - assert_output_contains "Save this prompt to $HOME/.config/start-issue/prompt.md? [Y/n]" - [[ "$(cat "$HOME/.config/start-issue/agent")" == "codex" ]] - [[ "$(cat "$HOME/.config/start-issue/prompt.md")" == *"Implement GitHub issue {ISSUE_URL} in this worktree."* ]] -} - -@test "--setup supports skip agent and declined prompt" { - printf "old-agent\n" > "$HOME/.config/start-issue/agent" - printf "old-prompt\n" > "$HOME/.config/start-issue/prompt.md" - - run bash -c 'printf "5\nn\n" | "$REPO_ROOT/scripts/start-issue" --setup' - - assert_success - [[ ! -e "$HOME/.config/start-issue/agent" ]] - [[ ! -e "$HOME/.config/start-issue/prompt.md" ]] -} - -@test "setup works outside a git repository" { - outside_dir="$TEST_TMPDIR/outside-setup" - rm -rf "$HOME/.config/start-issue" - mkdir -p "$outside_dir" - - run bash -c "cd '$outside_dir' && printf '1\ny\n' | '$REPO_ROOT/scripts/start-issue' setup" - - assert_success - assert_output_contains "Directory: $HOME/.config/start-issue" - [[ "$(cat "$HOME/.config/start-issue/agent")" == "claude" ]] - [[ "$(cat "$HOME/.config/start-issue/prompt.md")" == "/task-router:route-task {ISSUE_URL}" ]] -} - -@test "first run without config prompts onboarding and then shows missing issue guidance" { - rm -rf "$HOME/.config/start-issue" - - run bash -c 'printf "n\n" | "$REPO_ROOT/scripts/start-issue"' - - assert_failure - assert_output_contains "Configuration is not initialized yet." - assert_output_contains "Run setup now? [Y/n]" - assert_output_contains "Error: missing issue URL or issue number" - [[ -d "$HOME/.config/start-issue" ]] - [[ ! -e "$HOME/.config/start-issue/agent" ]] - [[ ! -e "$HOME/.config/start-issue/prompt.md" ]] -} - -@test "first run decline continues the requested issue workflow" { - rm -rf "$HOME/.config/start-issue" - - run bash -c 'printf "n\n" | "$REPO_ROOT/scripts/start-issue" 1 --no-init --no-agent' - - assert_success - assert_output_contains "Configuration is not initialized yet." - assert_output_contains "Run setup now? [Y/n]" - assert_output_contains "Fetching issue #1 from owner/repo" - assert_output_contains "Selected agent: none (CLI)" - [[ -d "$HOME/.config/start-issue" ]] - [[ ! -e "$HOME/.config/start-issue/agent" ]] - [[ ! -e "$HOME/.config/start-issue/prompt.md" ]] -} - -@test "first run accept continues the requested issue workflow with saved config" { - rm -rf "$HOME/.config/start-issue" - - run bash -c 'printf "y\n2\ny\n" | "$REPO_ROOT/scripts/start-issue" 1 --no-init' - - assert_success - assert_output_contains "Configuration is not initialized yet." - assert_output_contains "Run setup now? [Y/n]" - assert_output_contains "Fetching issue #1 from owner/repo" - assert_output_contains "Agent: codex" - assert_output_contains "Agent source: $HOME/.config/start-issue/agent" - assert_output_contains "Handing off to codex in $HOME/worktrees/feature/issue-1-add-login-button" - assert_output_contains "fake codex invoked" - [[ "$(cat "$HOME/.config/start-issue/agent")" == "codex" ]] - [[ "$(cat "$HOME/.config/start-issue/prompt.md")" == *"Implement GitHub issue {ISSUE_URL} in this worktree."* ]] -} - -@test "full issue URL overrides detected repository" { - run_start_issue https://github.com/other/project/issues/1 --dry-run --no-init --no-agent - - assert_success - assert_output_contains "Fetching issue #1 from other/project" - assert_output_contains "Selected agent: none (CLI)" -} - -@test "update subcommand installs the latest release into the running executable path" { - installed_script="$TEST_TMPDIR/bin/start-issue" - mkdir -p "$(dirname "$installed_script")" - build_installed_start_issue "$installed_script" "1.11.0" - create_fake_release_assets "$TEST_TMPDIR/release-assets" "1.11.1" - expected_path="$(cd "$(dirname "$installed_script")" && pwd -P)/$(basename "$installed_script")" - - run "$installed_script" update - - assert_success - assert_output_contains "Installed version: v1.11.0" - assert_output_contains "Latest release: v1.11.1" - assert_output_contains "Updated start-issue at: $expected_path" - assert_output_contains "Version: start-issue v1.11.1" - run "$installed_script" --version - assert_success - assert_output_contains "start-issue v1.11.1" -} - -@test "--update is equivalent to the update subcommand" { - installed_script="$TEST_TMPDIR/bin/start-issue" - mkdir -p "$(dirname "$installed_script")" - build_installed_start_issue "$installed_script" "1.11.0" - create_fake_release_assets "$TEST_TMPDIR/release-assets-flag" "1.11.1" - expected_path="$(cd "$(dirname "$installed_script")" && pwd -P)/$(basename "$installed_script")" - - run "$installed_script" --update - - assert_success - assert_output_contains "Latest release: v1.11.1" - assert_output_contains "Updated start-issue at: $expected_path" -} - -@test "update exits successfully when already on the latest release tag" { - installed_script="$TEST_TMPDIR/bin/start-issue" - mkdir -p "$(dirname "$installed_script")" - build_installed_start_issue "$installed_script" "1.11.1" - create_fake_release_assets "$TEST_TMPDIR/release-assets-current" "1.11.1" - - run "$installed_script" update - - assert_success - assert_output_contains "Installed version: v1.11.1" - assert_output_contains "Latest release: v1.11.1" - assert_output_contains "already up to date" -} - -@test "update treats bare and v-prefixed versions as equivalent" { - installed_script="$TEST_TMPDIR/bin/start-issue" - mkdir -p "$(dirname "$installed_script")" - build_installed_start_issue "$installed_script" "1.11.1" - create_fake_release_assets "$TEST_TMPDIR/release-assets-normalized" "1.11.1" - - run "$installed_script" --update - - assert_success - assert_output_contains "Installed version: v1.11.1" - assert_output_contains "Latest release: v1.11.1" - assert_output_contains "already up to date" -} - -@test "update does not downgrade when installed version is newer than the latest release" { - installed_script="$TEST_TMPDIR/bin/start-issue" - mkdir -p "$(dirname "$installed_script")" - build_installed_start_issue "$installed_script" "1.12.0" - create_fake_release_assets "$TEST_TMPDIR/release-assets-older" "1.11.1" - - run "$installed_script" update - - assert_success - assert_output_contains "Installed version: v1.12.0" - assert_output_contains "Latest release: v1.11.1" - assert_output_contains "newer than the latest published release" - run "$installed_script" --version - assert_success - assert_output_contains "start-issue v1.12.0" -} - -@test "update works outside a git repository" { - installed_script="$TEST_TMPDIR/bin/start-issue" - outside_dir="$TEST_TMPDIR/outside" - mkdir -p "$(dirname "$installed_script")" "$outside_dir" - build_installed_start_issue "$installed_script" "1.11.1" - create_fake_release_assets "$TEST_TMPDIR/release-assets-outside" "1.11.1" - expected_path="$(cd "$(dirname "$installed_script")" && pwd -P)/$(basename "$installed_script")" - - run bash -c "cd '$outside_dir' && '$installed_script' update" - - assert_success - assert_output_contains "Executable: $expected_path" - assert_output_contains "already up to date" -} - -@test "update fails clearly when latest release lookup fails" { - installed_script="$TEST_TMPDIR/bin/start-issue" - mkdir -p "$(dirname "$installed_script")" - build_installed_start_issue "$installed_script" "1.11.0" - - run "$installed_script" update - - assert_failure - assert_output_contains "Failed to resolve the latest GitHub release" -} - -@test "update rejects mixing update mode with issue input" { - run_start_issue 1 --update - - assert_failure - assert_output_contains "Use either update or , not both." - [[ "$output" != *"Fetching issue"* ]] -} - -@test "HTTPS origin remote is parsed" { - git remote set-url origin https://github.com/https-owner/https-repo.git - - run_start_issue 1 --dry-run --no-init --no-agent - - assert_success - assert_output_contains "Fetching issue #1 from https-owner/https-repo" -} - -@test "CLI agent wins over project config and environment" { - mkdir -p .start-issue - printf "kimi\n" > .start-issue/agent - export START_ISSUE_AGENT=pi - - run_start_issue 1 --agent codex --dry-run --no-init - - assert_success - assert_output_contains "Agent: codex" - assert_output_contains "Agent source: CLI" - assert_output_contains "codex --cd" -} - -@test "CLI model wins over project, user, and environment" { - mkdir -p .start-issue - mkdir -p "$HOME/.config/start-issue" - printf "project-model\n" > .start-issue/model - printf "user-model\n" > "$HOME/.config/start-issue/model" - export START_ISSUE_MODEL=env-model - - run_start_issue 1 --agent codex --model cli-model --dry-run --no-init - - assert_success - assert_output_contains "Model: cli-model" - assert_output_contains "Model source: CLI" - assert_output_contains "codex --model cli-model --cd" -} - -@test "project model config wins over environment" { - mkdir -p .start-issue - printf "project-model\n" > .start-issue/model - export START_ISSUE_MODEL=env-model - - run_start_issue 1 --agent codex --dry-run --no-init - - assert_success - assert_output_contains "Model: project-model" - assert_output_contains "Model source: " - assert_output_contains ".start-issue/model" - assert_output_contains "codex --model project-model --cd" -} - -@test "user model config wins over environment" { - mkdir -p "$HOME/.config/start-issue" - printf "user-model\n" > "$HOME/.config/start-issue/model" - export START_ISSUE_MODEL=env-model - - run_start_issue 1 --agent pi --dry-run --no-init - - assert_success - assert_output_contains "Model: user-model" - assert_output_contains "Model source: $HOME/.config/start-issue/model" - assert_output_contains "pi --model user-model" -} - -@test "project agent config wins over environment" { - mkdir -p .start-issue - printf "codex\n" > .start-issue/agent - export START_ISSUE_AGENT=kimi - - run_start_issue 1 --dry-run --no-init - - assert_success - assert_output_contains "Agent: codex" - assert_output_contains "Agent source: " - assert_output_contains ".start-issue/agent" -} - -@test "user agent config wins over environment" { - mkdir -p "$HOME/.config/start-issue" - printf "pi\n" > "$HOME/.config/start-issue/agent" - export START_ISSUE_AGENT=kimi - - run_start_issue 1 --dry-run --no-init - - assert_success - assert_output_contains "Agent: pi" - assert_output_contains "Agent source: $HOME/.config/start-issue/agent" - assert_output_contains "cd $HOME/worktrees/feature/issue-1-add-login-button && pi" -} - -@test "init writes project config with selected agent and portable prompt" { - run_start_issue init --project --agent codex - - assert_success - assert_output_contains "Scope: project config" - assert_output_contains "Agent: codex" - assert_output_contains "Agent source: CLI" - [[ "$(cat .start-issue/agent)" == "codex" ]] - [[ "$(cat .start-issue/prompt.md)" == *"Implement GitHub issue {ISSUE_URL} in this worktree."* ]] - [[ "$(cat .start-issue/prompt.md)" == *"target the base branch {BASE_BRANCH}."* ]] - [[ "$output" != *"Fetching issue"* ]] -} - -@test "init writes project model config when selected" { - run_start_issue init --project --agent codex --model gpt-5.2 - - assert_success - assert_output_contains "Model: gpt-5.2" - assert_output_contains "Model source: CLI" - [[ "$(cat .start-issue/model)" == "gpt-5.2" ]] -} - -@test "init prompts for user config when scope is omitted" { - run bash -c 'printf "2\n" | "$REPO_ROOT/scripts/start-issue" init' - - assert_success - assert_output_contains "User config" - [[ "$(cat "$HOME/.config/start-issue/agent")" == "claude" ]] - [[ "$(cat "$HOME/.config/start-issue/prompt.md")" == "/task-router:route-task {ISSUE_URL}" ]] -} - -@test "init keeps existing config unless forced" { - mkdir -p .start-issue - printf "kimi\n" > .start-issue/agent - printf "custom\n" > .start-issue/prompt.md - - run_start_issue init --project --agent codex --prompt inline - assert_success - [[ "$(cat .start-issue/agent)" == "kimi" ]] - [[ "$(cat .start-issue/prompt.md)" == "custom" ]] - - run_start_issue init --project --agent codex --prompt inline --force - assert_success - [[ "$(cat .start-issue/agent)" == "codex" ]] - [[ "$(cat .start-issue/prompt.md)" == "inline" ]] -} - -@test "init derives missing prompt from kept existing agent" { - mkdir -p .start-issue - printf "codex\n" > .start-issue/agent - - run_start_issue init --project - - assert_success - assert_output_contains "Agent: codex" - assert_output_contains "Agent source: " - assert_output_contains ".start-issue/agent (existing)" - [[ "$(cat .start-issue/agent)" == "codex" ]] - [[ "$(cat .start-issue/prompt.md)" == *"Implement GitHub issue {ISSUE_URL} in this worktree."* ]] - [[ "$(cat .start-issue/prompt.md)" == *"target the base branch {BASE_BRANCH}."* ]] - [[ "$(cat .start-issue/prompt.md)" != *"/task-router:route-task"* ]] -} - -@test "init dry-run does not write config files" { - run_start_issue init --project --agent codex --dry-run - - assert_success - assert_output_contains "[DRY-RUN] Would write agent config" - assert_output_contains "No model config to write" - [[ ! -e .start-issue/agent ]] - [[ ! -e .start-issue/model ]] - [[ ! -e .start-issue/prompt.md ]] -} - -@test "init --force removes existing model config when no model is selected" { - mkdir -p .start-issue - printf "old-model\n" > .start-issue/model - - run_start_issue init --project --agent codex --force - - assert_success - [[ ! -e .start-issue/model ]] -} - -@test "--no-agent prints manual next steps" { - run_start_issue 1 --no-agent --dry-run --no-init - - assert_success - assert_output_contains "Selected agent: none (CLI)" - assert_output_contains "To start working:" - assert_output_contains "codex --cd $HOME/worktrees/feature/issue-1-add-login-button" -} - -@test "--no-agent displays resolved model without passing launch args" { - run_start_issue 1 --agent none --model sonnet --dry-run --no-init - - assert_success - assert_output_contains "Selected agent: none (CLI)" - assert_output_contains "Resolved model: sonnet (CLI)" - [[ "$output" != *"--model sonnet"* ]] -} - -@test "zellij-tab-status dry-run rename is shown when installed" { - install_fake_zellij_tab_status - - run_start_issue 1 --agent none --dry-run --no-init - - assert_success - assert_output_contains "Would run: zellij-tab-status --set-name \\#1" -} - -@test "worktree directory priority uses environment and CLI override" { - export START_ISSUE_WORKTREE_DIR="$TEST_TMPDIR/env-worktrees" - - run_start_issue 1 --agent none --dry-run --no-init - assert_success - assert_output_contains "Worktree directory: $TEST_TMPDIR/env-worktrees (START_ISSUE_WORKTREE_DIR)" - - run_start_issue 1 --agent none --dry-run --no-init --worktree-dir "$TEST_TMPDIR/cli-worktrees" - assert_success - assert_output_contains "Worktree directory: $TEST_TMPDIR/cli-worktrees (CLI)" -} - -@test "legacy Claude worktree environment name is ignored" { - legacy_name="CLAUDE""_WORKTREE_DIR" - export "$legacy_name=$TEST_TMPDIR/legacy-worktrees" - - run_start_issue 1 --agent none --dry-run --no-init - - assert_success - assert_output_contains "Worktree directory: $HOME/worktrees (built-in default)" -} - -@test "codex, kimi, and pi launch commands are rendered in dry-run" { - run_start_issue 1 --agent codex --dry-run --no-init - assert_success - assert_output_contains "codex --cd $HOME/worktrees/feature/issue-1-add-login-button" - assert_output_contains "--dangerously-bypass-approvals-and-sandbox" - - run_start_issue 1 --agent kimi --dry-run --no-init - assert_success - assert_output_contains "kimi --work-dir $HOME/worktrees/feature/issue-1-add-login-button --yolo -p" - - run_start_issue 1 --agent pi --dry-run --no-init - assert_success - assert_output_contains "cd $HOME/worktrees/feature/issue-1-add-login-button && pi" -} - -@test "human-gate rejects non-codex agents before fetching the issue" { - run_start_issue 1 --agent kimi --human-gate --dry-run --no-init - - assert_failure - assert_output_contains "--human-gate requires agent 'codex'" - [[ "$output" != *"Fetching issue"* ]] -} - -@test "human-gate dry-run renders the Codex batch command and state artifacts" { - export START_ISSUE_RUN_ID="20260524-010203" - - run_start_issue 1 --agent codex --human-gate --dry-run --no-init - - assert_success - assert_output_contains "Starting codex human-gate batch session" - [[ "$output" != *"--ask-for-approval"* ]] - assert_output_contains "--sandbox workspace-write" - assert_output_contains "--json" - assert_output_contains "--output-last-message" - assert_output_contains ".start-issue/runs/20260524-010203/events.jsonl" - assert_output_contains ".start-issue/runs/20260524-010203/last-message.txt" - assert_output_contains ".start-issue/runs/20260524-010203/thread-id" -} - -@test "human-gate exits successfully on DONE without resuming Codex" { - export START_ISSUE_RUN_ID="20260524-020304" - export START_ISSUE_FAKE_CODEX_REJECT_ASK_FOR_APPROVAL=1 - - run_start_issue 1 --agent codex --human-gate --no-init - - assert_success - assert_output_contains "Codex finished with STATUS: DONE" - [[ "$output" != *"fake codex resume"* ]] - run_dir="$HOME/worktrees/feature/issue-1-add-login-button/.start-issue/runs/20260524-020304" - [[ -f "$run_dir/events.jsonl" ]] - [[ -f "$run_dir/last-message.txt" ]] - [[ -f "$run_dir/thread-id" ]] - [[ "$(cat "$run_dir/thread-id")" == "019e5b45-f9ac-76c1-8177-4317c42d04f9" ]] -} - -@test "human-gate resumes the same Codex session on HUMAN_GATE" { - export START_ISSUE_RUN_ID="20260524-030405" - export START_ISSUE_FAKE_CODEX_STATUS="HUMAN_GATE" - export START_ISSUE_FAKE_CODEX_THREAD_ID="thread-human-gate" - - run_start_issue 1 --agent codex --human-gate --no-init - - assert_success - assert_output_contains "Thread ID: thread-human-gate" - assert_output_contains "Resume command: codex resume --include-non-interactive thread-human-gate" - assert_output_contains "fake codex resume --include-non-interactive thread-human-gate" -} - -@test "human-gate exits 2 when HUMAN_GATE resume cannot be opened" { - export START_ISSUE_RUN_ID="20260524-040506" - export START_ISSUE_FAKE_CODEX_STATUS="HUMAN_GATE" - export START_ISSUE_FAKE_CODEX_THREAD_ID="thread-resume-fail" - export START_ISSUE_FAKE_CODEX_RESUME_FAIL=1 - - run_start_issue 1 --agent codex --human-gate --no-init - - [ "$status" -eq 2 ] - assert_output_contains "Could not open Codex resume session." - assert_output_contains "Resume command: codex resume --include-non-interactive thread-resume-fail" - assert_output_contains "Thread ID: thread-resume-fail" -} - -@test "human-gate fails clearly when no recognized final status is found" { - export START_ISSUE_RUN_ID="20260524-050607" - export START_ISSUE_FAKE_CODEX_LAST_MESSAGE="Summary only" - - run_start_issue 1 --agent codex --human-gate --no-init - - assert_failure - assert_output_contains "No recognized final status found." - assert_output_contains ".start-issue/runs/20260524-050607/last-message.txt" -} - -@test "human-gate fails clearly when no thread id is captured" { - export START_ISSUE_RUN_ID="20260524-060708" - export START_ISSUE_FAKE_CODEX_OMIT_THREAD_ID=1 - - run_start_issue 1 --agent codex --human-gate --no-init - - assert_failure - assert_output_contains "did not capture thread_id" - assert_output_contains ".start-issue/runs/20260524-060708/events.jsonl" -} - -@test "dry-run renders explicit model for supported launch adapters" { - run_start_issue 1 --agent codex --model gpt-5.2 --dry-run --no-init - assert_success - assert_output_contains "Model: gpt-5.2" - assert_output_contains "Model source: CLI" - assert_output_contains "codex --model gpt-5.2 --cd $HOME/worktrees/feature/issue-1-add-login-button" - - run_start_issue 1 --agent claude --model sonnet --dry-run --no-init - assert_success - assert_output_contains "Model: sonnet" - assert_output_contains "Model source: CLI" - assert_output_contains "claude --model sonnet --dangerously-skip-permissions" -} - -@test "prompt template from project file is rendered" { - mkdir -p .start-issue - printf "Prompt-{ISSUE_NUMBER}-{REPO}-{BASE_BRANCH}-{UNKNOWN}\n" > .start-issue/prompt.md - - run_start_issue 1 --agent codex --dry-run --no-init - - assert_success - assert_output_contains "Prompt source:" - assert_output_contains ".start-issue/prompt.md" - assert_output_contains "Prompt-1-owner/repo-master" - assert_output_contains "UNKNOWN" -} - -@test "environment prompt file overrides user and project prompt files" { - mkdir -p .start-issue "$HOME/.config/start-issue" - printf "Project prompt {ISSUE_NUMBER}\n" > .start-issue/prompt.md - printf "User prompt {ISSUE_NUMBER}\n" > "$HOME/.config/start-issue/prompt.md" - printf "Env prompt {ISSUE_NUMBER}\n" > "$TEST_TMPDIR/env-prompt.md" - export START_ISSUE_PROMPT_FILE="$TEST_TMPDIR/env-prompt.md" - - run_start_issue 1 --agent codex --dry-run --no-init - - assert_success - assert_output_contains "Prompt source: START_ISSUE_PROMPT_FILE: $TEST_TMPDIR/env-prompt.md" - assert_output_contains "Env\\ prompt\\ 1" - [[ "$output" != *"Project prompt 1"* ]] - [[ "$output" != *"User prompt 1"* ]] -} - -@test "prompt improvement writes proposal next to project prompt and exits before worktree" { - mkdir -p .start-issue - printf "Prompt {ISSUE_NUMBER}\n" > .start-issue/prompt.md - export START_ISSUE_FAKE_IMPROVED_PROMPT="Improved prompt {ISSUE_URL} {ISSUE_NUMBER}" - - run_start_issue 1 --agent codex --improve-prompt --no-init - - assert_success - assert_output_contains "Improving prompt template" - assert_output_contains "Prompt source:" - assert_output_contains ".start-issue/prompt.md" - assert_output_contains "Proposal path:" - assert_output_contains ".start-issue/prompt.improved.md" - assert_output_contains "Prompt improvement written" - [[ "$(cat .start-issue/prompt.improved.md)" == "Improved prompt {ISSUE_URL} {ISSUE_NUMBER}" ]] - [[ "$output" != *"Creating worktree"* ]] -} - -@test "prompt improvement passes explicit model to the selected agent" { - export START_ISSUE_FAKE_IMPROVED_PROMPT="Improved prompt" - export START_ISSUE_FAKE_EXPECT_MODEL=sonnet - - run_start_issue 1 --agent claude --model sonnet --improve-prompt --no-init - - assert_success - [[ "$(cat .start-issue/prompt.improved.md)" == "Improved prompt" ]] -} - -@test "built-in prompt improvement writes project proposal by default" { - export START_ISSUE_FAKE_IMPROVED_PROMPT="Improved built-in prompt {ISSUE_URL}" - - run_start_issue 1 --agent codex --improve-prompt --no-init - - assert_success - assert_output_contains "Prompt source: built-in portable prompt" - assert_output_contains "Proposal path:" - assert_output_contains ".start-issue/prompt.improved.md" - [[ "$(cat .start-issue/prompt.improved.md)" == "Improved built-in prompt {ISSUE_URL}" ]] -} - -@test "prompt improvement dry-run does not write proposal or call agent" { - mkdir -p .start-issue - printf "Prompt {ISSUE_NUMBER}\n" > .start-issue/prompt.md - export START_ISSUE_FAKE_AGENT_FAIL=1 - - run_start_issue 1 --agent codex --improve-prompt --dry-run --no-init - - assert_success - assert_output_contains "[DRY-RUN] Would ask codex to generate an improved prompt proposal." - [[ ! -e .start-issue/prompt.improved.md ]] -} - -@test "prompt improvement uses custom output path and refuses overwrite" { - mkdir -p .start-issue - printf "Prompt {ISSUE_NUMBER}\n" > .start-issue/prompt.md - export START_ISSUE_FAKE_IMPROVED_PROMPT="Improved custom prompt" - - run_start_issue 1 --agent codex --improve-prompt --prompt-output-file .start-issue/prompt.next.md --no-init - - assert_success - assert_output_contains "Proposal path: .start-issue/prompt.next.md" - [[ "$(cat .start-issue/prompt.next.md)" == "Improved custom prompt" ]] - - run_start_issue 1 --agent codex --improve-prompt --prompt-output-file .start-issue/prompt.next.md --no-init - - assert_failure - assert_output_contains "Prompt improvement output already exists: .start-issue/prompt.next.md" - [[ "$(cat .start-issue/prompt.next.md)" == "Improved custom prompt" ]] -} - -@test "prompt improvement rejects agent none before fetching issue" { - run_start_issue 1 --agent none --improve-prompt --dry-run --no-init - - assert_failure - assert_output_contains "--improve-prompt requires an agent" - [[ "$output" != *"Fetching issue"* ]] -} - -@test "prompt conflict fails fast" { - run_start_issue 1 --agent none --dry-run --prompt inline --prompt-file prompt.md - - assert_failure - assert_output_contains "Use either --prompt-file or --prompt, not both." - [[ "$output" != *"Fetching issue"* ]] -} - -@test "unknown agent fails fast" { - run_start_issue 1 --agent unknown --dry-run - - assert_failure - assert_output_contains "Unknown agent: unknown" - [[ "$output" != *"Fetching issue"* ]] -} - -@test "AI branch naming accepts selected agent output" { - export START_ISSUE_FAKE_BRANCH_NAME=fix/issue-1-ai-generated-name - - run_start_issue 1 --agent codex --ai --dry-run --no-init - - assert_success - assert_output_contains "Branch: fix/issue-1-ai-generated-name" - assert_output_contains "ai:codex" -} - -@test "AI branch naming passes explicit model to the selected agent" { - export START_ISSUE_FAKE_BRANCH_NAME=fix/issue-1-ai-generated-name - export START_ISSUE_FAKE_EXPECT_MODEL=gpt-5.2 - - run_start_issue 1 --agent codex --model gpt-5.2 --ai --dry-run --no-init - - assert_success - assert_output_contains "Branch: fix/issue-1-ai-generated-name" - assert_output_contains "Model: gpt-5.2" - assert_output_contains "Model source: CLI" -} - -@test "no-model branch naming keeps existing adapter behavior" { - export START_ISSUE_FAKE_BRANCH_NAME=fix/issue-1-ai-generated-name - export START_ISSUE_FAKE_FORBID_MODEL=1 - - run_start_issue 1 --agent codex --ai --dry-run --no-init - - assert_success -} - -@test "unsupported model selection helper returns a clear error" { - run bash -lc 'set -euo pipefail; RED=""; NC=""; source "'"$REPO_ROOT"'/scripts/lib/start_issue/utils.sh"; source "'"$REPO_ROOT"'/scripts/lib/start_issue/agent.sh"; AGENT="unsupported"; MODEL="x"; validate_model_selection_support launch' - - assert_failure - assert_output_contains "does not support explicit model selection" -} - -@test "AI branch naming falls back when selected agent returns invalid output" { - export START_ISSUE_FAKE_BRANCH_NAME="not a branch" - - run_start_issue 1 --agent codex --ai --dry-run --no-init - - assert_success - assert_output_contains "Generated branch name doesn't match expected format" - assert_output_contains "Using fallback: feature/issue-1-add-login-button" -} - -@test "AI branch naming prompt instructs model to transliterate non-English titles" { - export START_ISSUE_FAKE_BRANCH_NAME=feature/issue-1-ai-generated-name - # Substring must match a word in the generate_ai_branch_name prompt; keep in sync if reworded. - export START_ISSUE_FAKE_EXPECT_PROMPT="transliterate" - - run_start_issue 1 --agent claude --ai --dry-run --no-init - - assert_success - assert_output_contains "Branch: feature/issue-1-ai-generated-name" -} - -@test "AI branch naming prompt instructs model to ignore leading bracketed tags" { - export START_ISSUE_FAKE_BRANCH_NAME=feature/issue-1-ai-generated-name - # Substring must match a word in the generate_ai_branch_name prompt; keep in sync if reworded. - export START_ISSUE_FAKE_EXPECT_PROMPT="bracketed" - - run_start_issue 1 --agent claude --ai --dry-run --no-init - - assert_success - assert_output_contains "Branch: feature/issue-1-ai-generated-name" -} - -@test "AI branch naming falls back when branch ends with a trailing dash" { - export START_ISSUE_FAKE_BRANCH_NAME="feature/issue-1-ai-generated-" - - run_start_issue 1 --agent codex --ai --dry-run --no-init - - assert_success - assert_output_contains "Generated branch name doesn't match expected format" - assert_output_contains "Using fallback: feature/issue-1-add-login-button" -} - -@test "fast branch naming trims a trailing dash after truncation" { - local long_prefix - long_prefix="$(printf 'a%.0s' {1..39})" - cat > "$TEST_TMPDIR/issue-trailing-dash.json" < "$TEST_TMPDIR/install-fixture/start-issue" <<'EOF' -#!/usr/bin/env bash -printf 'start-issue test-build\n' -EOF - chmod +x "$TEST_TMPDIR/install-fixture/start-issue" - shasum -a 256 "$TEST_TMPDIR/install-fixture/start-issue" | awk '{ print $1 " start-issue" }' > "$TEST_TMPDIR/install-fixture/start-issue.sha256" - - run_install_script - - assert_success - assert_output_contains "Downloading latest release from test/local" - assert_output_contains "Installed: $TEST_TMPDIR/install-prefix/bin/start-issue" - assert_output_contains "Version: start-issue test-build" - [[ -x "$TEST_TMPDIR/install-prefix/bin/start-issue" ]] -} - -@test "install.sh installs when piped to Bash" { - mkdir -p "$TEST_TMPDIR/install-fixture" - cat > "$TEST_TMPDIR/install-fixture/start-issue" <<'EOF' -#!/usr/bin/env bash -printf 'start-issue piped-build\n' -EOF - chmod +x "$TEST_TMPDIR/install-fixture/start-issue" - shasum -a 256 "$TEST_TMPDIR/install-fixture/start-issue" | awk '{ print $1 " start-issue" }' > "$TEST_TMPDIR/install-fixture/start-issue.sha256" - - run_piped_install_script - - assert_success - assert_output_contains "Installed: $TEST_TMPDIR/install-prefix/bin/start-issue" - assert_output_contains "Version: start-issue piped-build" - [[ -x "$TEST_TMPDIR/install-prefix/bin/start-issue" ]] -} - -@test "install.sh debug mode prints diagnostic details" { - mkdir -p "$TEST_TMPDIR/install-fixture" - cat > "$TEST_TMPDIR/install-fixture/start-issue" <<'EOF' -#!/usr/bin/env bash -printf 'start-issue debug-build\n' -EOF - chmod +x "$TEST_TMPDIR/install-fixture/start-issue" - shasum -a 256 "$TEST_TMPDIR/install-fixture/start-issue" | awk '{ print $1 " start-issue" }' > "$TEST_TMPDIR/install-fixture/start-issue.sha256" - - run_install_script --debug - - assert_success - assert_output_contains "DEBUG: Repository: test/local" - assert_output_contains "DEBUG: Asset URL: file://$TEST_TMPDIR/install-fixture/start-issue" - assert_output_contains "DEBUG: Verifying checksum" - assert_output_contains "+ install.sh:" - assert_output_contains "Version: start-issue debug-build" -} - -@test "branch reuse matches the exact worktree path" { - git worktree add "$TEST_TMPDIR/worktree-v2" -b feature/issue-1-add-login-button-v2 master - git worktree add "$TEST_TMPDIR/worktree-exact" -b feature/issue-1-add-login-button master - expected_worktree="$(cd "$TEST_TMPDIR/worktree-exact" && pwd -P)" - - run bash -c 'printf "1\n" | "$REPO_ROOT/scripts/start-issue" 1 --agent none --dry-run --no-init' - - assert_success - assert_output_contains "Existing worktree: $expected_worktree" - assert_output_contains "Waiting for input: branch already exists" - assert_output_contains "Using existing worktree: $expected_worktree" - assert_output_contains "✅ Worktree ready at: $expected_worktree" - [[ "$output" != *"worktree-v2"* ]] -} - -@test "reusing a plain directory is rejected before init or agent launch" { - mkdir -p "$HOME/worktrees/feature/issue-1-add-login-button" - - run bash -c 'printf "1\n" | "$REPO_ROOT/scripts/start-issue" 1 --agent none --no-init' - - assert_failure - assert_output_contains "Waiting for input: worktree path already exists" - assert_output_contains "path exists but is not a git worktree for this repository" - [[ "$output" != *"Worktree ready"* ]] -} - -@test "reusing a path registered to a different branch is rejected" { - git worktree add "$HOME/worktrees/feature/issue-1-add-login-button" -b chore/other-branch master - - run bash -c 'printf "1\n" | "$REPO_ROOT/scripts/start-issue" 1 --agent none --no-init' - - assert_failure - assert_output_contains "Registered branch: chore/other-branch" - assert_output_contains "Waiting for input: worktree path already exists" - assert_output_contains "Cannot reuse worktree path '$HOME/worktrees/feature/issue-1-add-login-button': it belongs to branch 'chore/other-branch', not 'feature/issue-1-add-login-button'." - [[ "$output" != *"Worktree ready"* ]] -} - -@test "delete and recreate flow replaces the conflicting branch worktree" { - git worktree add "$TEST_TMPDIR/existing-worktree" -b feature/issue-1-add-login-button master - - run bash -c 'printf "3\n" | "$REPO_ROOT/scripts/start-issue" 1 --agent none --no-init' - - assert_success - assert_output_contains "Removing existing branch/worktree" - assert_output_contains "✅ Cleaned up" - assert_output_contains "✅ Worktree created" - [[ -d "$HOME/worktrees/feature/issue-1-add-login-button" ]] - [[ ! -d "$TEST_TMPDIR/existing-worktree" ]] -} - -@test "flat worktree mode uses flattened path" { - run_start_issue 1 --agent none --dry-run --no-init --flat - - assert_success - assert_output_contains "Path: $HOME/worktrees/feature-issue-1-add-login-button" - assert_output_contains "Would run: git worktree add -b feature/issue-1-add-login-button $HOME/worktrees/feature-issue-1-add-login-button master" -} - -@test "base branch falls back to the current branch when origin HEAD is missing" { - git checkout -q -b develop - - run_start_issue 1 --agent none --dry-run --no-init - - assert_success - assert_output_contains "Could not detect default branch, using current: develop" - assert_output_contains "Base: develop" - assert_output_contains "Would run: git worktree add -b feature/issue-1-add-login-button $HOME/worktrees/feature/issue-1-add-login-button develop" -} - -@test "sanitize_branch_slug strips leading bracketed tag before slug" { - run bash -lc 'set -euo pipefail; source "'"$REPO_ROOT"'/scripts/lib/start_issue/worktree.sh"; sanitize_branch_slug "[brief] add login button"' - - assert_success - [[ "$output" == add-login-button* ]] - [[ "$output" != brief* ]] -} - -@test "sanitize_branch_slug strips multiple leading bracketed tags" { - run bash -lc 'set -euo pipefail; source "'"$REPO_ROOT"'/scripts/lib/start_issue/worktree.sh"; sanitize_branch_slug "[brief][investigation] add login button"' - - assert_success - [[ "$output" == add-login-button* ]] - [[ "$output" != brief* ]] -} - -@test "sanitize_branch_slug transliterates Cyrillic to meaningful Latin slug" { - run bash -lc 'set -euo pipefail; source "'"$REPO_ROOT"'/scripts/lib/start_issue/worktree.sh"; sanitize_branch_slug "Кнопка Отменить выгрузки"' - - assert_success - # Exact match pins the transliteration table; soft/hard signs (ь/ъ) must drop, not transliterate. - [[ "$output" == "knopka-otmenit-vygruzki" ]] -} - -@test "sanitize_branch_slug falls back to 'work' when only a bracketed tag remains" { - run bash -lc 'set -euo pipefail; source "'"$REPO_ROOT"'/scripts/lib/start_issue/worktree.sh"; sanitize_branch_slug "[brief]"' - - assert_success - [[ "$output" == "work" ]] -} - -@test "sanitize_branch_slug falls back to 'work' when title is only soft/hard signs" { - # ь/ъ are deleted (not transliterated), so the slug collapses to empty -> work. - run bash -lc 'set -euo pipefail; source "'"$REPO_ROOT"'/scripts/lib/start_issue/worktree.sh"; sanitize_branch_slug "ьъ"' - - assert_success - [[ "$output" == "work" ]] -} - -@test "sanitize_branch_slug transliterates a mixed Latin/Cyrillic title" { - run bash -lc 'set -euo pipefail; source "'"$REPO_ROOT"'/scripts/lib/start_issue/worktree.sh"; sanitize_branch_slug "Fix Кнопка login"' - - assert_success - [[ "$output" == "fix-knopka-login" ]] -} - -@test "sanitize_branch_slug orders multi-letter digraphs before single-letter rules" { - # Защита exercises щ->shch and several digraphs; a wrong rule order would corrupt it. - run bash -lc 'set -euo pipefail; source "'"$REPO_ROOT"'/scripts/lib/start_issue/worktree.sh"; sanitize_branch_slug "Защита поиска"' - - assert_success - [[ "$output" == "zashchita-poiska" ]] -} - -@test "sanitize_branch_slug lowercases transliterated uppercase Cyrillic" { - run bash -lc 'set -euo pipefail; source "'"$REPO_ROOT"'/scripts/lib/start_issue/worktree.sh"; sanitize_branch_slug "КНОПКА ОТМЕНИТЬ"' - - assert_success - [[ "$output" == "knopka-otmenit" ]] -} - -@test "fast branch naming with cyrillic title and leading bracketed tag yields meaningful slug" { - cat > "$TEST_TMPDIR/issue-cyrillic.json" <<'EOF' -{ - "number": 2980, - "title": "[brief] - Кнопка «Отменить» на странице выгрузки в операторской", - "body": "Some body text.", - "html_url": "https://github.com/owner/repo/issues/2980", - "labels": [ - { - "name": "enhancement" - } - ] -} -EOF - export START_ISSUE_FAKE_ISSUE_JSON="$TEST_TMPDIR/issue-cyrillic.json" - - run_start_issue 2980 --agent none --dry-run --no-init - - assert_success - [[ "$output" != *"/issue-2980-brief"* ]] - [[ "$output" != *"/issue-2980-work"* ]] - assert_output_contains "knopka" -} - -@test "fast branch naming with purely Cyrillic title yields transliterated slug not 'work'" { - cat > "$TEST_TMPDIR/issue-pure-cyrillic.json" <<'EOF' -{ - "number": 42, - "title": "Добавить кнопку входа", - "body": "Добавить кнопку входа в заголовок.", - "html_url": "https://github.com/owner/repo/issues/42", - "labels": [ - { - "name": "enhancement" - } - ] -} -EOF - export START_ISSUE_FAKE_ISSUE_JSON="$TEST_TMPDIR/issue-pure-cyrillic.json" - - run_start_issue 42 --agent none --dry-run --no-init - - assert_success - [[ "$output" != *"/issue-42-work"* ]] - assert_output_contains "dobavit" -} - -@test "fast branch naming reproduces the real issue #3109 title with a meaningful slug" { - # Regression for the real alfagen/mercury#3109 title that the old code turned into - # 'feature/issue-3109-brief-0-20-usd' (leaked [brief] tag, dropped the Cyrillic head word). - cat > "$TEST_TMPDIR/issue-3109.json" <<'EOF' -{ - "number": 3109, - "title": "[brief] - Вознаграждение 0,20 USD в рублёвом эквиваленте при нулевой/отрицательной комиссии", - "body": "Some body text.", - "html_url": "https://github.com/owner/repo/issues/3109", - "labels": [ - { - "name": "enhancement" - } - ] -} -EOF - export START_ISSUE_FAKE_ISSUE_JSON="$TEST_TMPDIR/issue-3109.json" - - run_start_issue 3109 --agent none --dry-run --no-init - - assert_success - [[ "$output" != *"/issue-3109-brief"* ]] - [[ "$output" != *"/issue-3109-work"* ]] - assert_output_contains "voznagrazhdenie" -}