diff --git a/.claude/skills/gh-issues/SKILL.md b/.claude/skills/gh-issues/SKILL.md new file mode 100644 index 00000000..db667a57 --- /dev/null +++ b/.claude/skills/gh-issues/SKILL.md @@ -0,0 +1,167 @@ +--- +name: gh-issues +description: Walk over all open GitHub issues that are unassigned or assigned to the current user, and process each one via the /gh-issue skill, sequentially. +user-invocable: true +argument-hint: "[--limit N] [--label foo] [--dry-run]" +disable-model-invocation: true +allowed-tools: Read, Bash, Skill +--- + +# GitHub Issues Watcher + +## Purpose + +Process every open GitHub issue that is **unassigned** or **assigned to the current user (`@me`)**, one after another, by delegating each to the `/gh-issue` skill. Stop on first hard failure so it can be inspected. + +Driven from inside the Claude session rather than a polling shell script. + +## Args + +- `--limit N` — process at most N issues this run (default: all). +- `--label foo` — only issues carrying label `foo`. +- `--dry-run` — list issues that would be processed; do not invoke `/gh-issue`. + +Strip leading `#` if user passes `#123` style. + +## Phase 1: Discover + +Fetch open issues that are unassigned **or** assigned to `@me`, oldest first. GitHub search does not OR these cleanly, so run two queries and merge: + +```bash +# Unassigned +gh issue list \ + --state open \ + --search "no:assignee" \ + --json number,title,labels,assignees,createdAt \ + --limit 200 + +# Assigned to me +gh issue list \ + --state open \ + --assignee "@me" \ + --json number,title,labels,assignees,createdAt \ + --limit 200 +``` + +Merge: +- Deduplicate by `number`. +- Keep only issues whose `assignees` array is empty **or** contains the current user (`gh api user -q .login`). +- Drop issues assigned to anyone else (defensive). +- **Skip tracker/epic issues** — those whose body is a checklist of other issues (e.g. `- [ ] #123 …`). Process the child issues directly, not the parent. +- Apply `--label` filter if given. +- Apply `--limit` if given. +- Sort ascending by `createdAt` (FIFO). + +Print the queue: `# [assignee]` per line, where `[assignee]` is `unassigned` or `@me`. If empty, exit cleanly. + +## Phase 2: Worktree Sanity + +Before touching any issue: + +```bash +git status --porcelain +git fetch origin main +git checkout main && git reset --hard origin/main +``` + +Abort if worktree dirty. Never auto-stash. + +## Phase 3: Process Loop + +For each issue in the queue: + +1. Re-check assignment state (someone else may have grabbed it): + ```bash + gh issue view <num> --json assignees -q '.assignees[].login' + me=$(gh api user -q .login) + ``` + - Empty output → unassigned, proceed. + - Only `$me` listed → already mine, proceed (skip self-assign step). + - Any other login present → skip this issue. + +2. Invoke the `/gh-issue` skill with the issue number. That skill owns: + - self-assign via `gh issue edit <num> --add-assignee @me` (no-op if already assigned) + - branch from fresh `main` (prefix from labels: `fix/`, `feat/`, `docs/`) + - strict TDD: RED → GREEN → REFACTOR (no production code without a failing test first) + - full suite green locally (`./bashunit tests/` **and** `./bashunit --parallel tests/`) + - `CHANGELOG.md` entry under `## Unreleased` for user-facing changes + - commit with conventional message + `Closes #<num>` in the body + - PR opened via `/pr #<num>` + +3. **Before pushing**, run the exact command CI's strict job uses — plain + sequential runs miss simple-mode and strict-mode bugs that CI rejects: + ```bash + ./bashunit --parallel --simple --strict tests/ + ``` + In `--simple` mode `print_line` emits only a one-char marker, so assert on + pure helper functions, not on `print_line` stdout. Under `--strict` + (`set -euo pipefail`): no `[ cond ] && assignment` (use `if`), initialise + arrays with `=()` and read elements as `${arr[i]:-}`, and never enable + `shopt -s extdebug` in the parent shell (isolate it inside a `$()` subshell). + +4. After `/gh-issue` returns, wait for CI green on the PR: + ```bash + gh pr checks --watch + ``` + Fix red checks on the branch before moving on. Bash 3.0 CI fails often — + check that job specifically. A red `docker`/registry step is usually a + transient Docker Hub timeout, not your code: `gh run rerun <id> --failed`. + +5. Merge when allowed: + ```bash + gh pr merge --auto --squash --admin + ``` + +6. **Close the issue if the squash-merge did not.** GitHub builds the squash commit from the **PR body**, and `/pr` writes `Related #<num>` there (never `Closes`), so the `Closes #<num>` in the branch commit body is lost. After merge: + ```bash + gh issue view <num> --json state -q .state # still OPEN? + gh issue close <num> --reason completed -c "Done in #<pr> (merged)." + ``` + If the issue belongs to a tracker/epic, tick its checkbox in the parent + issue body. Use `sed` for the tick — bash `${body/- [ ] #N/...}` treats + `[ ]` as a glob (it matches a space, not literal brackets) and silently + does nothing: + ```bash + gh issue view <tracker> --json body -q .body \ + | sed 's/- \[ \] #<num>/- [x] #<num>/' | gh issue edit <tracker> --body-file - + ``` + +7. Sync `main` for next iteration: + ```bash + git checkout main && git fetch origin main && git reset --hard origin/main + ``` + +8. Continue with next issue. + +## Stop Conditions + +Halt the loop and surface the failure when: + +- `/gh-issue` errors out or leaves the worktree dirty. +- `./bashunit tests/` or `./bashunit --parallel tests/` fails after implementation (for the right reason — distinguish pre-existing failures; see Notes). +- `make sa` (ShellCheck) or `make lint` (EditorConfig) fails. +- CI stays red after one fix attempt. +- Merge is blocked by branch protection beyond `--admin` bypass. +- `--limit` reached. +- Queue empty. + +Do **not** retry blindly. Report which issue failed and why. + +## Dry Run + +With `--dry-run`, only execute Phase 1 and print the queue. No assignment, no branching, no commits. + +## Preconditions + +- `gh` authenticated, can read issues, open and merge PRs. +- Worktree clean. +- `main` exists and tracks `origin/main`. +- `/gh-issue` and `/pr` skills available in this session. + +## Notes + +- **Bash 3.0+ compatibility is mandatory.** No `declare -A`, `[[ ]]`, `${var,,}`, negative array indexing, or `&>>` in `src/`. See `.claude/rules/bash-style.md`. +- **Quality gate is `make sa` + `make lint`, not bare `shfmt`.** `shfmt -w .` without project flags rewrites the whole tree (collapses line-continuations, flips binary-op style, tabs the `indent_size = unset` files). Match the surrounding 2-space style by hand and rely on `make lint` (EditorConfig) to verify. There is no `shfmt` make target. +- **Know your baseline.** `tests/unit/coverage_subshell_test.sh` fails on some macOS setups regardless of the change — confirm a failure is new (`git stash` + re-run) before treating it as a regression. +- Treat GitHub CI as the full quality gate; locally run focused tests during implementation, the full sequential + parallel suite once before commit. +- Never split bundled changes into multiple PRs unless the issue explicitly demands it. diff --git a/.editorconfig b/.editorconfig index f2f85148..5bae05d6 100644 --- a/.editorconfig +++ b/.editorconfig @@ -29,5 +29,8 @@ indent_style = tab [{tests/acceptance/**.sh,src/console_header.sh,docs/command-line.md}] indent_size = unset +[.claude/**.md] +indent_size = unset + [src/coverage.sh] max_line_length = unset