From 11f923fe8b935399b671d696a95d89bf6e39b066 Mon Sep 17 00:00:00 2001 From: Filipe Utzig Date: Tue, 16 Jun 2026 00:10:38 -0300 Subject: [PATCH 1/4] perf: extract item selection into bin/pick-item MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Steps 1–2.6 of execute-item (milestone resolution, in-progress check, candidate selection, block-skipping, sub-issue check) are now deterministic bash rather than inline AI prose. execute-item delegates to bin/pick-item and consumes the JSON output; AI reasoning resumes at Step 3 (INVEST validation). Includes bats test suite (13 tests) covering all 9 acceptance criteria, wired into CI as a test stage that gates the validate-plugin job. Refs #141 Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Filipe Utzig --- .github/workflows/ci.yaml | 13 + bin/pick-item | 300 +++++++++++++++++++++++ skills/execute-item/SKILL.md | 172 +++++-------- tests/pick-item.bats | 456 +++++++++++++++++++++++++++++++++++ 4 files changed, 824 insertions(+), 117 deletions(-) create mode 100755 bin/pick-item create mode 100644 tests/pick-item.bats diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 5eec63a..11f824e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -12,6 +12,19 @@ permissions: contents: read jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Install bats + run: | + sudo apt-get update -qq + sudo apt-get install -y bats + + - name: Run bats tests + run: bats tests/ + validate-plugin: runs-on: ubuntu-latest steps: diff --git a/bin/pick-item b/bin/pick-item new file mode 100755 index 0000000..81a2650 --- /dev/null +++ b/bin/pick-item @@ -0,0 +1,300 @@ +#!/usr/bin/env bash +set -uo pipefail + +WORK_DIR="$(mktemp -d)" +trap 'rm -rf "$WORK_DIR"' EXIT + +DEPS_UNAVAIL_FLAG="$WORK_DIR/deps_unavail" +WARNINGS_FILE="$WORK_DIR/warnings" +touch "$WARNINGS_FILE" + +# ─── 0. Metadata ────────────────────────────────────────────────────────────── +metadata_file=".claude/backlog-project.json" +if [[ ! -f "$metadata_file" ]]; then + echo "No .claude/backlog-project.json found. Run /initialize first." >&2 + exit 1 +fi +owner=$(jq -r '.owner' "$metadata_file") +repo=$(jq -r '.repo' "$metadata_file") +proj_num=$(jq -r '.project_number' "$metadata_file") + +# ─── 1. Active milestone ────────────────────────────────────────────────────── +milestone_json=$(resolve-milestone 2>&1) || { echo "$milestone_json" >&2; exit 1; } +ms_num=$(echo "$milestone_json" | jq -r '.number') +ms_title=$(echo "$milestone_json" | jq -r '.title') +ms_due=$(echo "$milestone_json" | jq -r '.due_on') +active_milestone_out=$(jq -n --argjson n "$ms_num" --arg t "$ms_title" --arg d "$ms_due" \ + '{number: $n, title: $t, due_on: $d}') + +# ─── 2. Current user ────────────────────────────────────────────────────────── +current_user=$(gh api user --jq '.login' 2>/dev/null) || { + echo "Failed to get current user. Check gh authentication." >&2; exit 1 +} + +# ─── 3. Fetch project items (three queries) ─────────────────────────────────── +inprogress_raw=$(gh project item-list "$proj_num" --owner "$owner" \ + --format json --limit 200 --query "is:issue status:In Progress" 2>&1) || { + echo "Failed to fetch in-progress items: $inprogress_raw" >&2; exit 1 +} +tier1_raw=$(gh project item-list "$proj_num" --owner "$owner" \ + --format json --limit 200 --query "is:issue status:Todo milestone:${ms_title}" 2>&1) || { + echo "Failed to fetch Tier 1 items: $tier1_raw" >&2; exit 1 +} +tier2_raw=$(gh project item-list "$proj_num" --owner "$owner" \ + --format json --limit 200 --query "is:issue status:Todo no:milestone" 2>&1) || { + echo "Failed to fetch Tier 2 items: $tier2_raw" >&2; exit 1 +} + +# ─── 4. in_progress array (current user only) ───────────────────────────────── +in_progress=$(echo "$inprogress_raw" | jq --arg user "$current_user" ' + [.items // [] + | .[] + | select( + .type == "ISSUE" + and (.content.assignees // [] | map(.login) | index($user)) != null + ) + | { + number: .content.number, + title: .content.title, + milestone: (.content.milestone.title // null), + labels: (.content.labels // [] | map(.name)), + linked_pr: ( + (.linkedPullRequests // []) | if length > 0 + then .[0] | {number: .number, url: .url} + else null + end + ) + } + ] +') + +# ─── 5. Candidate list: tier1 then tier2, external-blocker items discarded ──── +candidates=$(jq -n \ + --argjson t1 "$tier1_raw" \ + --argjson t2 "$tier2_raw" \ + ' + (($t1.items // [] | map(select(.type == "ISSUE") | . + {_tier: 1})) + + ($t2.items // [] | map(select(.type == "ISSUE") | . + {_tier: 2}))) + | map(select( + (.content.labels // [] | map(.name) | index("type:external-blocker")) == null + )) + ') + +# ─── 6. Block-skipping helper ───────────────────────────────────────────────── +# Writes open_blockers JSON to $1 (file path). Respects $DEPS_UNAVAIL_FLAG. +# Returns 0 always; caller checks file contents. +fetch_open_blockers() { + local num="$1" + local out_file="$2" + + if [[ -f "$DEPS_UNAVAIL_FLAG" ]]; then + echo "[]" > "$out_file" + return 0 + fi + + local summary_out="$WORK_DIR/iss_${num}" + local err_file="$WORK_DIR/err_${num}" + + if ! gh api "repos/${owner}/${repo}/issues/${num}" > "$summary_out" 2>"$err_file"; then + if grep -qi "404" "$err_file" 2>/dev/null; then + touch "$DEPS_UNAVAIL_FLAG" + echo "Issue Dependencies API unavailable on this repo — block-skipping disabled." \ + >> "$WARNINGS_FILE" + echo "[]" > "$out_file" + return 0 + fi + cat "$err_file" >&2 + exit 1 + fi + + local blocked_count + blocked_count=$(jq -r '.issue_dependencies_summary.blocked_by // 0' "$summary_out") + + if [[ "$blocked_count" -eq 0 ]]; then + echo "[]" > "$out_file" + return 0 + fi + + local bl_out="$WORK_DIR/bl_${num}" + local bl_err="$WORK_DIR/bl_err_${num}" + + if ! gh api "repos/${owner}/${repo}/issues/${num}/dependencies/blocked_by" \ + > "$bl_out" 2>"$bl_err"; then + if grep -qi "404" "$bl_err" 2>/dev/null; then + touch "$DEPS_UNAVAIL_FLAG" + echo "Issue Dependencies API unavailable on this repo — block-skipping disabled." \ + >> "$WARNINGS_FILE" + echo "[]" > "$out_file" + return 0 + fi + cat "$bl_err" >&2 + exit 1 + fi + + jq ' + map(select(.state == "open")) + | map({ + number: .number, + title: .title, + url: .html_url, + state: .state, + labels: (.labels // [] | map(.name)), + assignees: (.assignees // [] | map(.login)), + cross_repo: false + }) + ' "$bl_out" > "$out_file" +} + +# ─── 7. Candidate walk with block-skipping and sub-issue check ──────────────── +skipped_blocked="[]" +skipped_for_sub_issues="[]" + +winner_num="" +winner_tier="" +winner_sub_issues_summary="" + +n_candidates=$(echo "$candidates" | jq 'length') + +for (( i=0; i/dev/null || echo "[]") + sub_summary=$(echo "$sub_raw" | jq '{ + total: length, + completed: [.[] | select(.state == "closed")] | length + }') + open_sub_nums=$(echo "$sub_raw" | jq '[.[] | select(.state == "open") | .number]') + + # Sub-issues that appear in the candidates list (already in project Todo) + sub_candidates=$(echo "$candidates" | jq --argjson nums "$open_sub_nums" \ + '[.[] | select(.content.number as $n | $nums | any(. == $n))]') + sub_count=$(echo "$sub_candidates" | jq 'length') + + if [[ "$sub_count" -gt 0 ]]; then + # Skip parent; pick among sub-candidates + skip_entry=$(jq -n --argjson n "$num" --arg t "$title" '{number: $n, title: $t}') + skipped_for_sub_issues=$(echo "$skipped_for_sub_issues" | jq ". + [$skip_entry]") + + found_sub=false + sub_n=$(echo "$sub_candidates" | jq 'length') + + for (( j=0; j/dev/null || echo "[]") + sub_summary=$(echo "$sub2_raw" | jq '{ + total: length, + completed: [.[] | select(.state == "closed")] | length + }') + winner_num="$sub_num" + winner_tier="$sub_tier" + winner_sub_issues_summary="$sub_summary" + found_sub=true + break + done + + if [[ "$found_sub" == "true" ]]; then + break + fi + # All sub-issues blocked; continue to next parent candidate + continue + fi + + # No open sub-issues in Todo — this candidate wins + winner_num="$num" + winner_tier="$tier" + winner_sub_issues_summary="$sub_summary" + break +done + +# ─── 8. Fetch winner details ────────────────────────────────────────────────── +candidate_out="null" +message_out="null" + +if [[ -n "$winner_num" ]]; then + winner_view=$(gh issue view "$winner_num" \ + --json number,title,body,labels,milestone,url 2>&1) || { + echo "Failed to fetch details for issue #${winner_num}: $winner_view" >&2; exit 1 + } + + # Parent check (404 = no parent) + parent_raw=$(gh api "repos/${owner}/${repo}/issues/${winner_num}/parent" 2>/dev/null) && { + parent_out=$(echo "$parent_raw" | jq '{ + number: .number, + title: .title, + body: .body, + url: .html_url + }') + } || parent_out="null" + + candidate_out=$(echo "$winner_view" | jq \ + --argjson tier "$winner_tier" \ + --argjson sub_issues_summary "$winner_sub_issues_summary" \ + --argjson parent "$parent_out" \ + '{ + number: .number, + title: .title, + tier: $tier, + body: .body, + labels: .labels, + milestone: .milestone, + url: .url, + sub_issues_summary: $sub_issues_summary, + parent: $parent + }') + +else + if [[ $(echo "$skipped_blocked" | jq 'length') -gt 0 ]]; then + message_out='"All actionable items are blocked. Resolve a blocker or re-rank."' + else + message_out='"No actionable backlog items."' + fi +fi + +# ─── 9. Emit JSON ───────────────────────────────────────────────────────────── +warnings_out=$(jq -nR '[inputs | select(length > 0)]' < "$WARNINGS_FILE" 2>/dev/null || echo "[]") + +jq -n \ + --argjson active_milestone "$active_milestone_out" \ + --argjson in_progress "$in_progress" \ + --argjson candidate "$candidate_out" \ + --argjson skipped_blocked "$skipped_blocked" \ + --argjson skipped_for_sub_issues "$skipped_for_sub_issues" \ + --argjson warnings "$warnings_out" \ + --argjson message "$message_out" \ + '$ARGS.named' diff --git a/skills/execute-item/SKILL.md b/skills/execute-item/SKILL.md index 9b7ccc0..fc090ad 100644 --- a/skills/execute-item/SKILL.md +++ b/skills/execute-item/SKILL.md @@ -25,141 +25,83 @@ Run `backlog-preflight` via the Bash tool. If it exits non-zero, STOP and surfac --- -### 1. Active Milestone Detection +### 1. Item Selection (MANDATORY) -Run `resolve-milestone` via the Bash tool. If it exits non-zero, STOP and surface its output verbatim. On success, capture the JSON — `{"number": N, "title": "...", "due_on": "..."}`. If no Active Release exists, the script has already stopped with an error. +Run `bin/pick-item` via the Bash tool. If it exits non-zero, STOP and surface its stderr verbatim. ---- - -### 1.5. In-Progress Resume Check (STRICT) - -Before picking a new item, check whether the authenticated user already has work in flight. - -1. Get the current username: `gh api user --jq '.login'` -2. Query the full project item list and filter for `status == "In Progress"` - AND `assignees` contains the current user. -3. **For each matching item**, classify using the `linked pull requests` field from the - project item payload: - - **Linked PR found** → "near complete — PR open, likely waiting for review" - - **No linked PR** → "in progress — work started, no PR yet" -4. **If one or more matching items exist**, present each as: - - | # | Title | Milestone | Labels | PR Status | - |---|-------|-----------|--------|-----------| - | #N | ... | v0.x.x | type:... | ✅ PR #M open (waiting review) | - | #N | ... | — | type:... | 🔧 No PR yet (work in progress) | - - Then use AskUserQuestion with options built dynamically: one option per in-progress item (title + PR status, e.g. "fix/auth-bug — PR #42 open" or "feat/dashboard — no PR yet") plus a final "Pick a new item" option. - - **In-progress item chosen:** that item becomes winner. Skip Steps 2, 2.5, 2.6, 3, 5, and 6. - Advise the user to check out the existing branch (`/`). - If a linked PR exists, skip Step 9 as well. Proceed to Step 4. - - **"Pick a new item" chosen:** proceed to Step 2 normally. -5. **If no matching In-Progress items:** proceed to Step 2 normally. - ---- - -### 2. Candidate Selection (PRIORITIZED) +Capture the JSON output: -Find the next item to execute by walking these tiers in order. Stop at the first tier that yields candidates. +```json +{ + "active_milestone": {"number": N, "title": "...", "due_on": "..."}, + "in_progress": [...], + "candidate": {...} | null, + "skipped_blocked": [...], + "skipped_for_sub_issues": [...], + "warnings": [...], + "message": "..." | null +} +``` -Execution order is determined by the Project's rank — the topmost item in the `Todo` column wins. The `priority:*` label is severity classification ONLY and does NOT influence ordering. +If any `warnings` are present, surface them before proceeding. -**Pre-filter (MANDATORY):** Before building the candidate lists for either tier, discard any issue whose labels include `type:external-blocker`. External-blocker stubs are infrastructure placeholders — they are never executable work items. Their titles surface in the "skipped because blocked" output when they are open blockers gating a real candidate (see step 2.5). +**If `candidate` is null:** Surface `message`. If `skipped_blocked` is non-empty, render the per-blocker analysis table (see Step 2.5) using the facts already in the JSON — no extra API calls needed. Then STOP. -#### Tier 1 — Active milestone, in Project, status Todo +**If `in_progress` is non-empty:** Display each item: -- Open issues assigned to the active milestone -- Present in the linked Project -- Project Status = `Todo` -- Sorted by Project rank (top of column = next). The order is the position field returned by `gh project item-list` (items appear in rank order in the response). +| # | Title | Milestone | Labels | PR Status | +|---|-------|-----------|--------|-----------| +| #N | ... | v0.x.x | type:... | ✅ PR #M open (waiting review) | +| #N | ... | — | type:... | 🔧 No PR yet (work in progress) | -#### Tier 2 — In Project, no milestone, status Todo +Then use AskUserQuestion with options built dynamically: one option per in-progress item (title + PR status, e.g. "fix/auth-bug — PR #42 open" or "feat/dashboard — no PR yet") plus a final "Pick a new item" option. +- **In-progress item chosen:** that item becomes the winner. Skip Steps 2.6, 3, 5, and 6. Advise the user to check out the existing branch (`/`). If a linked PR exists, skip Step 9 as well. Proceed to Step 4. +- **"Pick a new item" chosen:** proceed with `candidate`. -- Open issues with NO milestone assigned -- Present in the linked Project -- Project Status = `Todo` -- Sorted by Project rank, same as Tier 1 +If `skipped_blocked` is non-empty but `candidate` is not null, surface the blocked items table in the eventual plan output so the user knows why the queue was deeper than expected. -#### Tier 3 — None +If `skipped_for_sub_issues` is non-empty, log each as: `Skipping parent #N — open sub-issues found.` -- If both tiers are empty: - - Report `No actionable backlog items.` - - STOP (do NOT pick items outside the Project, do NOT pick items in milestones other than the active one, do NOT pick by `priority:*` label as a fallback) - -If the picked item's `priority:*` label appears mismatched against its Project rank (e.g. a `priority:P3` is at the top while a `priority:P0` is below it), proceed anyway with the topmost item but surface the discrepancy in the proposed plan so the user can confirm or reorder. - -Use these `gh` calls to gather data: - -- Project candidates — use targeted queries: - - Tier 1: `gh project item-list --owner --format json --limit 200 --query "is:issue status:Todo milestone:"` - - Tier 2: `gh project item-list --owner --format json --limit 200 --query "is:issue status:Todo no:milestone"` +If the picked item's `priority:*` label appears mismatched against its Project rank, surface the discrepancy so the user can confirm or reorder. --- -### 2.5. Block-skipping (STRICT) - -Before declaring a winner, walk the rank-ordered candidate list and skip any item that is currently blocked by an open issue. The first unblocked item wins. - -For each candidate in rank order (Tier 1 first, then Tier 2): +### 2.5. Per-Blocker Analysis Table -- **Active-blocker pre-check**: fetch `gh api "repos///issues/" --jq '.issue_dependencies_summary.blocked_by'`. - - If the count is `0` → the item has no active blockers. Treat it as unblocked and skip the `blocked_by` list fetch entirely. - - If the count is `> 0` → fetch the blocker list: `gh api "repos///issues//dependencies/blocked_by"` and apply the checks below. -- For each blocker in the list: - - If `state == "open"`, the candidate is BLOCKED. Skip it. Record the candidate + its open blockers in a "skipped because blocked" list. - - If `state == "closed"`, the blocker is satisfied (regardless of how it was closed — merged PR, manual close, transferred, deleted) - - **Cross-Project / cross-repo blockers are permitted.** If the blocker lives in a different repo, query it via `gh api "repos///issues/" --jq '.state'`. The blocker URL on the dependency response includes the full repo reference. -- If a candidate has no active blockers (pre-check count is 0) OR every fetched blocker is closed, it is the winner. Stop walking. +Rendered when all candidates are blocked (`candidate` null, `skipped_blocked` non-empty). All facts come from the script output — no extra API calls needed. -Outcomes: +- Report: `All actionable items are blocked. Resolve a blocker or re-rank.` +- Render: -1. **A candidate wins** — proceed to step 2.6 (Sub-issue Check). In the eventual plan output, list every item that was skipped above this one with their open blockers, so the user knows why the queue was deeper than expected. When a blocker carries `type:external-blocker`, show it as `External: ` rather than a plain issue reference. -2. **Every candidate is blocked** — STOP. Report: - - `All actionable items are blocked. Resolve a blocker or re-rank.` - - Followed by a **per-blocker analysis table** with these columns: + | Blocked item | Blocker | Blocker state | Suggested action | + |---|---|---|---| + | #N title | #M title | open / closed | see rules below | - | Blocked item | Blocker | Blocker state | Suggested action | - |--------------|-----------|----------------|------------------| - | #N title | #M title | open / closed | see rules below | - - - **Suggested action rules** (apply the first matching rule): - - Blocker `closed` + dependency still active → `Stale — clear with: gh api -X DELETE repos///issues//dependencies/blocked_by/` - - Blocker `open`, cross-repo → `External — coordinate with owning team ()` - - Blocker `open`, has assignee → `In Progress — monitor` - - Blocker `open`, no assignee → `Unassigned — assign or re-plan` - - Close with a summary line: `N of M blockers may be resolvable without new work` (count stale + in-progress blockers as resolvable) - - DO NOT pick a blocked item even with user confirmation — re-running `execute-item` after the user resolves a blocker is the correct loop. - -The Issue Dependencies API is GA on public repos and on paid plans for private repos. If the API returns `404` (feature unavailable), treat all items as unblocked and emit a one-time warning: `Issue Dependencies API unavailable on this repo — block-skipping disabled.` +- **Suggested action rules** (apply first match; use `cross_repo`, `assignees`, `labels` from `skipped_blocked[].open_blockers`): + - Blocker `closed` + dependency still active → `Stale — clear with: gh api -X DELETE repos///issues//dependencies/blocked_by/` + - Blocker `open`, `cross_repo: true` → `External — coordinate with owning team` + - Blocker `open`, `assignees` non-empty → `In Progress — monitor` + - Blocker `open`, `assignees` empty → `Unassigned — assign or re-plan` + - Blockers with `"type:external-blocker"` in labels: show as `External: `. +- Close with: `N of M blockers may be resolvable without new work` (stale + in-progress count as resolvable). +- DO NOT pick a blocked item even with user confirmation — re-run `/execute-item` after resolving a blocker. --- -### 2.6. Sub-issue Check (STRICT) +### 2.6. Sub-issue Scope Check -After block-skipping yields a winning candidate, check whether it has open sub-issues that should be executed first. +Use `candidate.sub_issues_summary` from the script output — no API call needed. -1. Fetch sub-issues: `gh api "repos///issues//sub_issues"` to get the full sub-issue list with state. -2. Derive `sub_issues_summary`: `total` = count of all sub-issues, `completed` = count with `state == "closed"`. -3. Cross-reference against the already-fetched Project item list (from Step 2) to find which open sub-issues are present in the Project with Status = `Todo`. -4. **If one or more open sub-issues are in the Project's Todo column:** - - Log: `Skipping parent #N — open sub-issues found. Picking #M.` - - Apply the same block-skipping logic (Step 2.5) to the sub-issues ranked in the Project's Todo column; pick the first unblocked one. - - If all sub-issues are blocked, report them using the same per-blocker analysis table from Step 2.5 and STOP. - - The selected sub-issue becomes the new winner and proceeds to Step 3. -5. **If `sub_issues_summary.completed == total AND total > 0` (all sub-issues are closed):** - Enter Scope Completeness Review — see below. -6. **If `sub_issues_summary.total == 0`** (no sub-issues exist): proceed with the parent normally — it becomes the winner and proceeds to Step 3. +- **If `completed == total AND total > 0`:** All sub-issues are closed. Enter **Scope Completeness Review** below. +- **Otherwise:** proceed to Step 3. -The "none added to Project but open sub-issues exist" scenario (`completed < total`, none in Project Todo) is treated as a pass-through — treat the parent as a leaf and proceed to Step 3. - -The sub_issues API endpoint returns `[]` when the issue has no sub-issues; treat this the same as case 6. +Note: Open sub-issue routing is already handled by `bin/pick-item` — if the script selected a sub-issue as `candidate`, no further parent/sub-issue traversal is needed here. #### Scope Completeness Review -Entered when `sub_issues_summary.completed == total AND total > 0`. +Entered when `candidate.sub_issues_summary.completed == total AND total > 0`. -1. Fetch the parent's body: `gh issue view --json number,title,body` - - Extract the `### In Scope` and `### Acceptance Criteria` sections. +1. Extract `### In Scope` and `### Acceptance Criteria` from `candidate.body` — no `gh issue view` call needed. 2. Fetch each closed sub-issue body: - Use the sub-issue list from the API call above for their numbers. @@ -195,9 +137,7 @@ Entered when `sub_issues_summary.completed == total AND total > 0`. ### 3. Item Validation (MANDATORY) -Once a candidate is selected, fetch its full body and labels: - -- `gh issue view --json number,title,body,labels,milestone,url` +Use `candidate.body` and `candidate.labels` from the `bin/pick-item` output — no `gh issue view` call needed. Parse the body sections (`### What`, `### Why`, `### In Scope`, `### Out of Scope`, `### Acceptance Criteria`, `### INVEST Notes`). Validate against INVEST principles: @@ -222,13 +162,11 @@ If priority or effort labels are missing or duplicated, STOP and direct the user #### 4.0. Parent Context (RELATIVE) -Before drafting the plan, check whether the winning item is a sub-issue: +Use `candidate.parent` from the `bin/pick-item` output — no additional API call needed. -1. `gh api "repos///issues//parent"` -2. **If 404** → proceed silently. No warning, no block. -3. **If a parent is returned:** - - Fetch: `gh issue view --json number,title,body` - - Extract `### What` and `### Why` sections from the body (matched by exact heading text). +1. **If `candidate.parent` is `null`** → proceed silently. No warning, no block. +2. **If `candidate.parent` is present:** + - Extract `### What` and `### Why` sections from `candidate.parent.body` (matched by exact heading text). - Emit the following block **before** the implementation plan: ``` @@ -239,7 +177,7 @@ Before drafting the plan, check whether the winning item is a sub-issue: - If one section is absent, show the available one and omit the missing line. - If neither section exists, emit `(Parent body does not follow standard template — no What/Why sections found)` and proceed. -4. Continue to the implementation plan below. +3. Continue to the implementation plan below. - Propose a concise implementation plan that: - Covers ALL Acceptance Criteria (parsed from `### Acceptance Criteria`) diff --git a/tests/pick-item.bats b/tests/pick-item.bats new file mode 100644 index 0000000..34f91a3 --- /dev/null +++ b/tests/pick-item.bats @@ -0,0 +1,456 @@ +#!/usr/bin/env bats + +setup() { + REPO_ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)" + PICK_ITEM="$REPO_ROOT/bin/pick-item" + + TEST_DIR="$(mktemp -d)" + MOCK_BIN="$(mktemp -d)" + export GH_MOCK_DIR="$(mktemp -d)" + export PATH="$MOCK_BIN:$PATH" + + cd "$TEST_DIR" + + mkdir -p .claude + cat > .claude/backlog-project.json << 'JSON' +{ + "owner": "testowner", + "repo": "testrepo", + "project_number": 1, + "project_id": "PVT_test", + "status_field_id": "PVTF_test", + "status_options": { + "Todo": "opt_todo", + "In Progress": "opt_inprogress", + "Done": "opt_done" + } +} +JSON + + # resolve-milestone mock + cat > "$MOCK_BIN/resolve-milestone" << 'SCRIPT' +#!/usr/bin/env bash +echo '{"number": 8, "title": "v0.6.0", "due_on": "2026-07-14T00:00:00Z"}' +SCRIPT + chmod +x "$MOCK_BIN/resolve-milestone" + + # gh mock — reads fixtures from $GH_MOCK_DIR at runtime (not expanded here) + cat > "$MOCK_BIN/gh" << 'SCRIPT' +#!/usr/bin/env bash +subcmd="${1:-}" +shift || true + +if [[ "$subcmd" == "api" ]]; then + path="${1:-}" + if [[ "$path" == "user" ]]; then + cat "$GH_MOCK_DIR/api_user.txt" + elif [[ "$path" =~ ^repos/[^/]+/[^/]+/issues/[0-9]+/dependencies/blocked_by$ ]]; then + num=$(echo "$path" | grep -oE '/issues/[0-9]+/' | grep -oE '[0-9]+') + file="$GH_MOCK_DIR/blockers_${num}.json" + if [[ -f "${file}.404" ]]; then + echo "HTTP 404: Not Found" >&2; exit 1 + fi + cat "$file" + elif [[ "$path" =~ ^repos/[^/]+/[^/]+/issues/[0-9]+/sub_issues$ ]]; then + num=$(echo "$path" | grep -oE '/issues/[0-9]+/sub_issues' | grep -oE '[0-9]+') + cat "$GH_MOCK_DIR/sub_issues_${num}.json" + elif [[ "$path" =~ ^repos/[^/]+/[^/]+/issues/[0-9]+/parent$ ]]; then + num=$(echo "$path" | grep -oE '/issues/[0-9]+/parent' | grep -oE '[0-9]+') + file="$GH_MOCK_DIR/parent_${num}.json" + if [[ ! -f "$file" ]]; then + echo "HTTP 404: Not Found" >&2; exit 1 + fi + cat "$file" + elif [[ "$path" =~ ^repos/[^/]+/[^/]+/issues/[0-9]+$ ]]; then + num=$(echo "$path" | grep -oE '/issues/[0-9]+$' | grep -oE '[0-9]+') + file="$GH_MOCK_DIR/issue_${num}.json" + if [[ -f "${file}.404" ]]; then + echo "HTTP 404: Not Found" >&2; exit 1 + fi + cat "$file" + else + echo "Unhandled api path: $path" >&2; exit 1 + fi + +elif [[ "$subcmd" == "project" ]]; then + subcmd2="${1:-}" + shift || true + if [[ "$subcmd2" == "item-list" ]]; then + args="$*" + if echo "$args" | grep -qF "In Progress"; then + cat "$GH_MOCK_DIR/items_inprogress.json" + elif echo "$args" | grep -qF "no:milestone"; then + cat "$GH_MOCK_DIR/items_tier2.json" + else + cat "$GH_MOCK_DIR/items_tier1.json" + fi + else + echo "Unhandled project subcmd: $subcmd2" >&2; exit 1 + fi + +elif [[ "$subcmd" == "issue" ]]; then + subcmd2="${1:-}" + shift || true + if [[ "$subcmd2" == "view" ]]; then + num="$1" + cat "$GH_MOCK_DIR/issue_view_${num}.json" + else + echo "Unhandled issue subcmd: $subcmd2" >&2; exit 1 + fi + +else + echo "Unhandled gh subcmd: $subcmd ($*)" >&2; exit 1 +fi +SCRIPT + chmod +x "$MOCK_BIN/gh" + + # Default fixtures shared across tests + # api user --jq '.login' returns just the username string (no JSON quotes) + echo 'testuser' > "$GH_MOCK_DIR/api_user.txt" + echo '{"items": []}' > "$GH_MOCK_DIR/items_inprogress.json" + echo '{"items": []}' > "$GH_MOCK_DIR/items_tier1.json" + echo '{"items": []}' > "$GH_MOCK_DIR/items_tier2.json" +} + +teardown() { + rm -rf "$TEST_DIR" "$MOCK_BIN" "$GH_MOCK_DIR" +} + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +issue_item() { + local num="$1" title="$2" milestone="${3:-v0.6.0}" + printf '{ + "id": "PVTI_%s", "type": "ISSUE", + "content": { + "number": %s, "title": "%s", + "url": "https://github.com/testowner/testrepo/issues/%s", + "assignees": [], "labels": [{"name": "type:feature"}, {"name": "priority:P1"}, {"name": "effort:S"}], + "milestone": {"number": 8, "title": "%s"} + }, + "status": "Todo", "linkedPullRequests": [] + }' "$num" "$num" "$title" "$num" "$milestone" +} + +no_blockers_summary() { + local num="$1" title="$2" + printf '{"number": %s, "title": "%s", "state": "open", "issue_dependencies_summary": {"blocked_by": 0}}' \ + "$num" "$title" +} + +issue_view() { + local num="$1" title="$2" + # \\n → printf outputs literal \n (JSON escape), not actual newline + printf '{"number": %s, "title": "%s", "body": "### What\\nDo X\\n\\n### Why\\nBecause\\n\\n### In Scope\\n- step\\n\\n### Out of Scope\\n- nothing\\n\\n### Acceptance Criteria\\n- [ ] AC1\\n\\n### INVEST Notes\\nOK", "labels": [{"name": "type:feature"}, {"name": "priority:P1"}, {"name": "effort:S"}], "milestone": {"number": 8, "title": "v0.6.0"}, "url": "https://github.com/testowner/testrepo/issues/%s"}' \ + "$num" "$title" "$num" +} + +# --------------------------------------------------------------------------- +# AC1 — script exists and is executable +# --------------------------------------------------------------------------- + +@test "AC1: bin/pick-item exists and is executable" { + [[ -x "$PICK_ITEM" ]] +} + +# --------------------------------------------------------------------------- +# AC7 — in_progress is empty array when no items in flight +# --------------------------------------------------------------------------- + +@test "AC7a: in_progress is empty array when current user has no In Progress items" { + # Default fixtures: empty items_inprogress.json + run "$PICK_ITEM" + [[ "$status" -eq 0 ]] + result=$(echo "$output" | jq -c '.in_progress') + [[ "$result" == "[]" ]] +} + +# --------------------------------------------------------------------------- +# AC2 — returns winner JSON for unblocked Todo item +# --------------------------------------------------------------------------- + +@test "AC2: returns winner candidate for unblocked Tier 1 Todo item" { + printf '{"items": [%s]}' "$(issue_item 42 'Do something')" \ + > "$GH_MOCK_DIR/items_tier1.json" + + no_blockers_summary 42 "Do something" > "$GH_MOCK_DIR/issue_42.json" + echo '[]' > "$GH_MOCK_DIR/sub_issues_42.json" + # No parent file → 404 + issue_view 42 "Do something" > "$GH_MOCK_DIR/issue_view_42.json" + + run "$PICK_ITEM" + [[ "$status" -eq 0 ]] + + candidate_num=$(echo "$output" | jq -r '.candidate.number') + [[ "$candidate_num" == "42" ]] + + milestone_title=$(echo "$output" | jq -r '.active_milestone.title') + [[ "$milestone_title" == "v0.6.0" ]] + + candidate_tier=$(echo "$output" | jq -r '.candidate.tier') + [[ "$candidate_tier" == "1" ]] + + message=$(echo "$output" | jq -r '.message') + [[ "$message" == "null" ]] +} + +@test "AC2b: falls back to Tier 2 (no-milestone) when Tier 1 is empty" { + printf '{"items": [%s]}' "$(issue_item 99 'No-milestone item' '')" \ + > "$GH_MOCK_DIR/items_tier2.json" + # Override: strip milestone from content + cat > "$GH_MOCK_DIR/items_tier2.json" << 'JSON' +{"items": [{"id": "PVTI_99", "type": "ISSUE", "content": {"number": 99, "title": "No-milestone item", "url": "https://github.com/testowner/testrepo/issues/99", "assignees": [], "labels": [{"name": "type:feature"}, {"name": "priority:P2"}, {"name": "effort:S"}], "milestone": null}, "status": "Todo", "linkedPullRequests": []}]} +JSON + + no_blockers_summary 99 "No-milestone item" > "$GH_MOCK_DIR/issue_99.json" + echo '[]' > "$GH_MOCK_DIR/sub_issues_99.json" + issue_view 99 "No-milestone item" > "$GH_MOCK_DIR/issue_view_99.json" + + run "$PICK_ITEM" + [[ "$status" -eq 0 ]] + + candidate_num=$(echo "$output" | jq -r '.candidate.number') + [[ "$candidate_num" == "99" ]] + + candidate_tier=$(echo "$output" | jq -r '.candidate.tier') + [[ "$candidate_tier" == "2" ]] +} + +# --------------------------------------------------------------------------- +# AC4 — candidate null when no actionable items +# --------------------------------------------------------------------------- + +@test "AC4a: candidate is null with message when no items exist" { + # Default fixtures: all empty + run "$PICK_ITEM" + [[ "$status" -eq 0 ]] + + candidate=$(echo "$output" | jq -r '.candidate') + [[ "$candidate" == "null" ]] + + message=$(echo "$output" | jq -r '.message') + [[ -n "$message" && "$message" != "null" ]] +} + +@test "AC4b: candidate is null with message when all items are blocked" { + printf '{"items": [%s]}' "$(issue_item 10 'Blocked item')" \ + > "$GH_MOCK_DIR/items_tier1.json" + + cat > "$GH_MOCK_DIR/issue_10.json" << 'JSON' +{"number": 10, "title": "Blocked item", "state": "open", "issue_dependencies_summary": {"blocked_by": 1}} +JSON + cat > "$GH_MOCK_DIR/blockers_10.json" << 'JSON' +[{"number": 77, "title": "Open blocker", "html_url": "https://github.com/testowner/testrepo/issues/77", "state": "open", "labels": [{"name": "type:feature"}], "assignees": []}] +JSON + + run "$PICK_ITEM" + [[ "$status" -eq 0 ]] + + candidate=$(echo "$output" | jq -r '.candidate') + [[ "$candidate" == "null" ]] + + message=$(echo "$output" | jq -r '.message') + [[ -n "$message" && "$message" != "null" ]] + + skipped_num=$(echo "$output" | jq -r '.skipped_blocked[0].number') + [[ "$skipped_num" == "10" ]] +} + +# --------------------------------------------------------------------------- +# AC3 — skips blocked items, populates skipped_blocked +# --------------------------------------------------------------------------- + +@test "AC3: skips blocked items and picks the first unblocked candidate" { + printf '{"items": [%s, %s]}' \ + "$(issue_item 10 'Blocked item')" \ + "$(issue_item 20 'Unblocked item')" \ + > "$GH_MOCK_DIR/items_tier1.json" + + cat > "$GH_MOCK_DIR/issue_10.json" << 'JSON' +{"number": 10, "title": "Blocked item", "state": "open", "issue_dependencies_summary": {"blocked_by": 1}} +JSON + cat > "$GH_MOCK_DIR/blockers_10.json" << 'JSON' +[{"number": 77, "title": "Open blocker", "html_url": "https://github.com/testowner/testrepo/issues/77", "state": "open", "labels": [{"name": "type:external-blocker"}], "assignees": []}] +JSON + + no_blockers_summary 20 "Unblocked item" > "$GH_MOCK_DIR/issue_20.json" + echo '[]' > "$GH_MOCK_DIR/sub_issues_20.json" + issue_view 20 "Unblocked item" > "$GH_MOCK_DIR/issue_view_20.json" + + run "$PICK_ITEM" + [[ "$status" -eq 0 ]] + + candidate_num=$(echo "$output" | jq -r '.candidate.number') + [[ "$candidate_num" == "20" ]] + + skipped_num=$(echo "$output" | jq -r '.skipped_blocked[0].number') + [[ "$skipped_num" == "10" ]] + + blocker_num=$(echo "$output" | jq -r '.skipped_blocked[0].open_blockers[0].number') + [[ "$blocker_num" == "77" ]] + + blocker_url=$(echo "$output" | jq -r '.skipped_blocked[0].open_blockers[0].url') + [[ "$blocker_url" == "https://github.com/testowner/testrepo/issues/77" ]] + + blocker_labels=$(echo "$output" | jq -c '.skipped_blocked[0].open_blockers[0].labels') + [[ "$blocker_labels" == '["type:external-blocker"]' ]] + + cross_repo=$(echo "$output" | jq -r '.skipped_blocked[0].open_blockers[0].cross_repo') + [[ "$cross_repo" == "false" ]] +} + +# --------------------------------------------------------------------------- +# AC5 — 404 on dependencies API: all unblocked + warning +# --------------------------------------------------------------------------- + +@test "AC5: 404 on dependencies API treats all items as unblocked and adds warning" { + printf '{"items": [%s]}' "$(issue_item 42 'Do something')" \ + > "$GH_MOCK_DIR/items_tier1.json" + + # Mark the issue summary as having 1 blocker, but the blockers endpoint returns 404 + cat > "$GH_MOCK_DIR/issue_42.json" << 'JSON' +{"number": 42, "title": "Do something", "state": "open", "issue_dependencies_summary": {"blocked_by": 1}} +JSON + touch "$GH_MOCK_DIR/blockers_42.json.404" + + echo '[]' > "$GH_MOCK_DIR/sub_issues_42.json" + issue_view 42 "Do something" > "$GH_MOCK_DIR/issue_view_42.json" + + run "$PICK_ITEM" + [[ "$status" -eq 0 ]] + + candidate_num=$(echo "$output" | jq -r '.candidate.number') + [[ "$candidate_num" == "42" ]] + + warning=$(echo "$output" | jq -r '.warnings[0]') + [[ "$warning" == *"unavailable"* ]] +} + +# --------------------------------------------------------------------------- +# AC6 — sub-issue case: parent skipped, sub-issue becomes candidate +# --------------------------------------------------------------------------- + +@test "AC6: parent with open Todo sub-issues is skipped; sub-issue becomes candidate" { + # Tier 1: parent #100 first, then sub-issue #101 + cat > "$GH_MOCK_DIR/items_tier1.json" << 'JSON' +{"items": [ + {"id": "PVTI_100", "type": "ISSUE", "content": {"number": 100, "title": "Parent item", "url": "https://github.com/testowner/testrepo/issues/100", "assignees": [], "labels": [{"name": "type:feature"}, {"name": "priority:P1"}, {"name": "effort:L"}], "milestone": {"number": 8, "title": "v0.6.0"}}, "status": "Todo", "linkedPullRequests": []}, + {"id": "PVTI_101", "type": "ISSUE", "content": {"number": 101, "title": "Sub-issue item", "url": "https://github.com/testowner/testrepo/issues/101", "assignees": [], "labels": [{"name": "type:feature"}, {"name": "priority:P1"}, {"name": "effort:S"}], "milestone": {"number": 8, "title": "v0.6.0"}}, "status": "Todo", "linkedPullRequests": []} +]} +JSON + + no_blockers_summary 100 "Parent item" > "$GH_MOCK_DIR/issue_100.json" + + # Parent #100 has open sub-issue #101 in the project + cat > "$GH_MOCK_DIR/sub_issues_100.json" << 'JSON' +[{"number": 101, "title": "Sub-issue item", "state": "open"}] +JSON + + no_blockers_summary 101 "Sub-issue item" > "$GH_MOCK_DIR/issue_101.json" + echo '[]' > "$GH_MOCK_DIR/sub_issues_101.json" + issue_view 101 "Sub-issue item" > "$GH_MOCK_DIR/issue_view_101.json" + + # Sub-issue #101's parent is #100 + cat > "$GH_MOCK_DIR/parent_101.json" << 'JSON' +{"number": 100, "title": "Parent item", "body": "### What\nBig thing\n\n### Why\nBecause", "html_url": "https://github.com/testowner/testrepo/issues/100"} +JSON + + run "$PICK_ITEM" + [[ "$status" -eq 0 ]] + + candidate_num=$(echo "$output" | jq -r '.candidate.number') + [[ "$candidate_num" == "101" ]] + + skipped_parent=$(echo "$output" | jq -r '.skipped_for_sub_issues[0].number') + [[ "$skipped_parent" == "100" ]] + + parent_num=$(echo "$output" | jq -r '.candidate.parent.number') + [[ "$parent_num" == "100" ]] +} + +# --------------------------------------------------------------------------- +# AC7b — in_progress populated when current user has items in flight +# --------------------------------------------------------------------------- + +@test "AC7b: in_progress populated with current user's In Progress items" { + cat > "$GH_MOCK_DIR/items_inprogress.json" << 'JSON' +{"items": [ + {"id": "PVTI_55", "type": "ISSUE", "content": {"number": 55, "title": "WIP item", "url": "https://github.com/testowner/testrepo/issues/55", "assignees": [{"login": "testuser"}], "labels": [{"name": "type:feature"}, {"name": "priority:P1"}, {"name": "effort:M"}], "milestone": {"number": 8, "title": "v0.6.0"}}, "status": "In Progress", "linkedPullRequests": [{"number": 88, "url": "https://github.com/testowner/testrepo/pull/88"}]}, + {"id": "PVTI_66", "type": "ISSUE", "content": {"number": 66, "title": "Another WIP", "url": "https://github.com/testowner/testrepo/issues/66", "assignees": [{"login": "otheruser"}], "labels": [{"name": "type:bug"}, {"name": "priority:P2"}, {"name": "effort:S"}], "milestone": {"number": 8, "title": "v0.6.0"}}, "status": "In Progress", "linkedPullRequests": []} +]} +JSON + + run "$PICK_ITEM" + [[ "$status" -eq 0 ]] + + # Only testuser's items appear in in_progress + count=$(echo "$output" | jq '.in_progress | length') + [[ "$count" == "1" ]] + + num=$(echo "$output" | jq -r '.in_progress[0].number') + [[ "$num" == "55" ]] + + linked_pr=$(echo "$output" | jq -r '.in_progress[0].linked_pr.number') + [[ "$linked_pr" == "88" ]] + + milestone=$(echo "$output" | jq -r '.in_progress[0].milestone') + [[ "$milestone" == "v0.6.0" ]] +} + +# --------------------------------------------------------------------------- +# AC1b — exit non-zero on infrastructure failure (missing metadata file) +# --------------------------------------------------------------------------- + +@test "AC1b: exits non-zero when .claude/backlog-project.json is missing" { + rm .claude/backlog-project.json + run "$PICK_ITEM" + [[ "$status" -ne 0 ]] +} + +# --------------------------------------------------------------------------- +# AC3b — type:external-blocker items are never picked as candidates +# --------------------------------------------------------------------------- + +@test "AC3b: items with type:external-blocker are never selected as candidates" { + cat > "$GH_MOCK_DIR/items_tier1.json" << 'JSON' +{"items": [ + {"id": "PVTI_77", "type": "ISSUE", "content": {"number": 77, "title": "External blocker stub", "url": "https://github.com/testowner/testrepo/issues/77", "assignees": [], "labels": [{"name": "type:external-blocker"}], "milestone": {"number": 8, "title": "v0.6.0"}}, "status": "Todo", "linkedPullRequests": []} +]} +JSON + + run "$PICK_ITEM" + [[ "$status" -eq 0 ]] + + candidate=$(echo "$output" | jq -r '.candidate') + [[ "$candidate" == "null" ]] +} + +# --------------------------------------------------------------------------- +# sub_issues_summary included in candidate +# --------------------------------------------------------------------------- + +@test "candidate includes sub_issues_summary with total and completed counts" { + printf '{"items": [%s]}' "$(issue_item 42 'Parent with sub-issues done')" \ + > "$GH_MOCK_DIR/items_tier1.json" + + no_blockers_summary 42 "Parent with sub-issues done" > "$GH_MOCK_DIR/issue_42.json" + + cat > "$GH_MOCK_DIR/sub_issues_42.json" << 'JSON' +[ + {"number": 43, "title": "Sub 1", "state": "closed"}, + {"number": 44, "title": "Sub 2", "state": "closed"} +] +JSON + + issue_view 42 "Parent with sub-issues done" > "$GH_MOCK_DIR/issue_view_42.json" + + run "$PICK_ITEM" + [[ "$status" -eq 0 ]] + + total=$(echo "$output" | jq -r '.candidate.sub_issues_summary.total') + completed=$(echo "$output" | jq -r '.candidate.sub_issues_summary.completed') + [[ "$total" == "2" ]] + [[ "$completed" == "2" ]] +} From cc65cd9fab668792f1c9ee87b1cb56ed7cad649e Mon Sep 17 00:00:00 2001 From: Filipe Utzig Date: Tue, 16 Jun 2026 11:27:16 -0300 Subject: [PATCH 2/4] perf(pick-item): address review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move assignee:@me and -label:type:external-blocker filtering server-side; remove redundant jq post-filters - Drop gh api user fetch — no longer needed after server-side assignee filtering - Fetch only body from gh issue view; reuse project item-list data for number/title/labels/milestone/url (saves 5 redundant fields) - Save winner_item during candidate walk so section 7 can build candidate_out without re-fetching already-available fields - Fix step numbering in execute-item SKILL.md: 2.5→2, 2.6→3, 3→4 … 11→12; update all cross-references - Simplify REPO_ROOT to nested dirname calls (no subshell cd) - Update test mock to simulate server-side external-blocker filtering; simplify AC7b fixture to reflect assignee:@me scope Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Filipe Utzig --- bin/pick-item | 62 ++++++++++++++++-------------------- skills/execute-item/SKILL.md | 34 ++++++++++---------- tests/pick-item.bats | 27 +++++++++------- 3 files changed, 60 insertions(+), 63 deletions(-) diff --git a/bin/pick-item b/bin/pick-item index 81a2650..37e5194 100755 --- a/bin/pick-item +++ b/bin/pick-item @@ -26,33 +26,25 @@ ms_due=$(echo "$milestone_json" | jq -r '.due_on') active_milestone_out=$(jq -n --argjson n "$ms_num" --arg t "$ms_title" --arg d "$ms_due" \ '{number: $n, title: $t, due_on: $d}') -# ─── 2. Current user ────────────────────────────────────────────────────────── -current_user=$(gh api user --jq '.login' 2>/dev/null) || { - echo "Failed to get current user. Check gh authentication." >&2; exit 1 -} - -# ─── 3. Fetch project items (three queries) ─────────────────────────────────── +# ─── 2. Fetch project items (three queries) ─────────────────────────────────── inprogress_raw=$(gh project item-list "$proj_num" --owner "$owner" \ - --format json --limit 200 --query "is:issue status:In Progress" 2>&1) || { + --format json --limit 200 --query 'is:issue status:"In Progress" assignee:@me' 2>&1) || { echo "Failed to fetch in-progress items: $inprogress_raw" >&2; exit 1 } tier1_raw=$(gh project item-list "$proj_num" --owner "$owner" \ - --format json --limit 200 --query "is:issue status:Todo milestone:${ms_title}" 2>&1) || { + --format json --limit 200 --query "is:issue -label:type:external-blocker status:Todo milestone:\"${ms_title}\"" 2>&1) || { echo "Failed to fetch Tier 1 items: $tier1_raw" >&2; exit 1 } tier2_raw=$(gh project item-list "$proj_num" --owner "$owner" \ - --format json --limit 200 --query "is:issue status:Todo no:milestone" 2>&1) || { + --format json --limit 200 --query "is:issue -label:type:external-blocker status:Todo no:milestone" 2>&1) || { echo "Failed to fetch Tier 2 items: $tier2_raw" >&2; exit 1 } -# ─── 4. in_progress array (current user only) ───────────────────────────────── -in_progress=$(echo "$inprogress_raw" | jq --arg user "$current_user" ' +# ─── 3. in_progress array ───────────────────────────────────────────────────── +in_progress=$(echo "$inprogress_raw" | jq ' [.items // [] | .[] - | select( - .type == "ISSUE" - and (.content.assignees // [] | map(.login) | index($user)) != null - ) + | select(.type == "ISSUE") | { number: .content.number, title: .content.title, @@ -68,19 +60,16 @@ in_progress=$(echo "$inprogress_raw" | jq --arg user "$current_user" ' ] ') -# ─── 5. Candidate list: tier1 then tier2, external-blocker items discarded ──── +# ─── 4. Candidate list: tier1 then tier2 ───────────────────────────────────── candidates=$(jq -n \ --argjson t1 "$tier1_raw" \ --argjson t2 "$tier2_raw" \ ' - (($t1.items // [] | map(select(.type == "ISSUE") | . + {_tier: 1})) - + ($t2.items // [] | map(select(.type == "ISSUE") | . + {_tier: 2}))) - | map(select( - (.content.labels // [] | map(.name) | index("type:external-blocker")) == null - )) + ($t1.items // [] | map(select(.type == "ISSUE") | . + {_tier: 1})) + + ($t2.items // [] | map(select(.type == "ISSUE") | . + {_tier: 2})) ') -# ─── 6. Block-skipping helper ───────────────────────────────────────────────── +# ─── 5. Block-skipping helper ───────────────────────────────────────────────── # Writes open_blockers JSON to $1 (file path). Respects $DEPS_UNAVAIL_FLAG. # Returns 0 always; caller checks file contents. fetch_open_blockers() { @@ -145,13 +134,14 @@ fetch_open_blockers() { ' "$bl_out" > "$out_file" } -# ─── 7. Candidate walk with block-skipping and sub-issue check ──────────────── +# ─── 6. Candidate walk with block-skipping and sub-issue check ──────────────── skipped_blocked="[]" skipped_for_sub_issues="[]" winner_num="" winner_tier="" winner_sub_issues_summary="" +winner_item="" n_candidates=$(echo "$candidates" | jq 'length') @@ -224,6 +214,7 @@ for (( i=0; i&1) || { - echo "Failed to fetch details for issue #${winner_num}: $winner_view" >&2; exit 1 + winner_body=$(gh issue view "$winner_num" --json body 2>&1) || { + echo "Failed to fetch body for issue #${winner_num}: $winner_body" >&2; exit 1 } # Parent check (404 = no parent) @@ -262,18 +253,19 @@ if [[ -n "$winner_num" ]]; then }') } || parent_out="null" - candidate_out=$(echo "$winner_view" | jq \ + candidate_out=$(echo "$winner_item" | jq \ --argjson tier "$winner_tier" \ --argjson sub_issues_summary "$winner_sub_issues_summary" \ --argjson parent "$parent_out" \ + --argjson body_obj "$winner_body" \ '{ - number: .number, - title: .title, + number: .content.number, + title: .content.title, tier: $tier, - body: .body, - labels: .labels, - milestone: .milestone, - url: .url, + body: $body_obj.body, + labels: (.content.labels // []), + milestone: .content.milestone, + url: .content.url, sub_issues_summary: $sub_issues_summary, parent: $parent }') @@ -286,7 +278,7 @@ else fi fi -# ─── 9. Emit JSON ───────────────────────────────────────────────────────────── +# ─── 8. Emit JSON ───────────────────────────────────────────────────────────── warnings_out=$(jq -nR '[inputs | select(length > 0)]' < "$WARNINGS_FILE" 2>/dev/null || echo "[]") jq -n \ diff --git a/skills/execute-item/SKILL.md b/skills/execute-item/SKILL.md index fc090ad..fbc0106 100644 --- a/skills/execute-item/SKILL.md +++ b/skills/execute-item/SKILL.md @@ -55,7 +55,7 @@ If any `warnings` are present, surface them before proceeding. | #N | ... | — | type:... | 🔧 No PR yet (work in progress) | Then use AskUserQuestion with options built dynamically: one option per in-progress item (title + PR status, e.g. "fix/auth-bug — PR #42 open" or "feat/dashboard — no PR yet") plus a final "Pick a new item" option. -- **In-progress item chosen:** that item becomes the winner. Skip Steps 2.6, 3, 5, and 6. Advise the user to check out the existing branch (`/`). If a linked PR exists, skip Step 9 as well. Proceed to Step 4. +- **In-progress item chosen:** that item becomes the winner. Skip Steps 3, 4, 6, and 7. Advise the user to check out the existing branch (`/`). If a linked PR exists, skip Step 10 as well. Proceed to Step 5. - **"Pick a new item" chosen:** proceed with `candidate`. If `skipped_blocked` is non-empty but `candidate` is not null, surface the blocked items table in the eventual plan output so the user knows why the queue was deeper than expected. @@ -66,7 +66,7 @@ If the picked item's `priority:*` label appears mismatched against its Project r --- -### 2.5. Per-Blocker Analysis Table +### 2. Per-Blocker Analysis Table Rendered when all candidates are blocked (`candidate` null, `skipped_blocked` non-empty). All facts come from the script output — no extra API calls needed. @@ -88,7 +88,7 @@ Rendered when all candidates are blocked (`candidate` null, `skipped_blocked` no --- -### 2.6. Sub-issue Scope Check +### 3. Sub-issue Scope Check Use `candidate.sub_issues_summary` from the script output — no API call needed. @@ -127,7 +127,7 @@ Entered when `candidate.sub_issues_summary.completed == total AND total > 0`. 5. **If "Close parent — scope complete":** - Post a comment with the full coverage checklist: `gh issue comment --body "..."` - Close the issue: `gh issue close ` - - STOP — do not proceed to Step 3. + - STOP — do not proceed to Step 4. 6. **If "Create sub-issues for gaps":** - For each uncovered criterion (marked `[ ]` in the checklist), invoke `/add-item` with the parent issue number so the new items become sub-issues. @@ -135,7 +135,7 @@ Entered when `candidate.sub_issues_summary.completed == total AND total > 0`. --- -### 3. Item Validation (MANDATORY) +### 4. Item Validation (MANDATORY) Use `candidate.body` and `candidate.labels` from the `bin/pick-item` output — no `gh issue view` call needed. @@ -158,9 +158,9 @@ If priority or effort labels are missing or duplicated, STOP and direct the user --- -### 4. Planning +### 5. Planning -#### 4.0. Parent Context (RELATIVE) +#### 5.1. Parent Context (RELATIVE) Use `candidate.parent` from the `bin/pick-item` output — no additional API call needed. @@ -193,7 +193,7 @@ Use `candidate.parent` from the `bin/pick-item` output — no additional API cal - After approval, invoke `/add-item` for each sub-issue in sequence, passing the parent issue number so it handles the sub-issue relationship - STOP after the sub-issues are created — re-run `/execute-item` to pick the first sub-issue -#### 4.1 Sibling Dependency Inference (RELATIVE) +#### 5.2 Sibling Dependency Inference (RELATIVE) If two or more sub-issues were just created: @@ -220,7 +220,7 @@ If two or more sub-issues were just created: --- -### 5. Status → In Progress (BEFORE BRANCH) +### 6. Status → In Progress (BEFORE BRANCH) Once the plan is approved: @@ -234,7 +234,7 @@ This makes the in-flight work visible on the Project board immediately. --- -### 6. Branching +### 7. Branching Determine the Conventional Commits prefix from the issue's `type:*` label: @@ -250,7 +250,7 @@ Branch name format: `/` (e.g. `fix/null-pointer-in-authn`). --- -### 7. Implementation +### 8. Implementation #### For Bugs @@ -286,7 +286,7 @@ A spike's deliverable is **knowledge** — a findings document plus the follow-o --- -### 8. Validation +### 9. Validation - Verify ALL Acceptance Criteria are satisfied - Run full test suite @@ -296,11 +296,11 @@ For `type:spike` items, additionally: - Confirm the findings document exists at `docs/spikes/-.md` with all required sections - Confirm every approved follow-on was created and that its issue number is referenced in the `## Follow-on Work` section -- Confirm follow-on parentage matches the rule in Step 7 (standalone if spike had no parent; peer sub-issues of the spike's parent otherwise) +- Confirm follow-on parentage matches the rule in Step 8 (standalone if spike had no parent; peer sub-issues of the spike's parent otherwise) --- -### 9. Delivery Workflow +### 10. Delivery Workflow - Commit using Conventional Commits format. Include `Refs #` in the commit body. - Push the branch. @@ -316,7 +316,7 @@ For `type:spike` items, the PR is typically a **findings-document-only** diff: --- -### 10. Status & Closure (POST-PR) +### 11. Status & Closure (POST-PR) GitHub handles the rest automatically: @@ -330,14 +330,14 @@ If the Project's `Issue closed → Status: Done` workflow is disabled, manually --- -### 11. Output +### 12. Output Print: - Issue URL and number - PR URL and number - Branch name -- Assignee (the authenticated user, assigned in step 5) +- Assignee (the authenticated user, assigned in step 6) - Final Project Status (typically `In Progress` until PR merges) - Whether the issue was assigned to the active milestone - Items skipped above this one because they were blocked (with `#N` and the open blockers that gated them; `type:external-blocker` blockers shown as `External: `) — surfaces why the picked item wasn't necessarily the topmost diff --git a/tests/pick-item.bats b/tests/pick-item.bats index 34f91a3..3e747d5 100644 --- a/tests/pick-item.bats +++ b/tests/pick-item.bats @@ -1,7 +1,7 @@ #!/usr/bin/env bats setup() { - REPO_ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)" + REPO_ROOT="$(dirname "$(dirname "$BATS_TEST_FILENAME")")" PICK_ITEM="$REPO_ROOT/bin/pick-item" TEST_DIR="$(mktemp -d)" @@ -42,9 +42,7 @@ shift || true if [[ "$subcmd" == "api" ]]; then path="${1:-}" - if [[ "$path" == "user" ]]; then - cat "$GH_MOCK_DIR/api_user.txt" - elif [[ "$path" =~ ^repos/[^/]+/[^/]+/issues/[0-9]+/dependencies/blocked_by$ ]]; then + if [[ "$path" =~ ^repos/[^/]+/[^/]+/issues/[0-9]+/dependencies/blocked_by$ ]]; then num=$(echo "$path" | grep -oE '/issues/[0-9]+/' | grep -oE '[0-9]+') file="$GH_MOCK_DIR/blockers_${num}.json" if [[ -f "${file}.404" ]]; then @@ -80,9 +78,19 @@ elif [[ "$subcmd" == "project" ]]; then if echo "$args" | grep -qF "In Progress"; then cat "$GH_MOCK_DIR/items_inprogress.json" elif echo "$args" | grep -qF "no:milestone"; then - cat "$GH_MOCK_DIR/items_tier2.json" + if echo "$args" | grep -qF -- "-label:type:external-blocker"; then + jq '.items = [.items[] | select((.content.labels // [] | map(.name) | index("type:external-blocker")) == null)]' \ + "$GH_MOCK_DIR/items_tier2.json" + else + cat "$GH_MOCK_DIR/items_tier2.json" + fi else - cat "$GH_MOCK_DIR/items_tier1.json" + if echo "$args" | grep -qF -- "-label:type:external-blocker"; then + jq '.items = [.items[] | select((.content.labels // [] | map(.name) | index("type:external-blocker")) == null)]' \ + "$GH_MOCK_DIR/items_tier1.json" + else + cat "$GH_MOCK_DIR/items_tier1.json" + fi fi else echo "Unhandled project subcmd: $subcmd2" >&2; exit 1 @@ -105,8 +113,6 @@ SCRIPT chmod +x "$MOCK_BIN/gh" # Default fixtures shared across tests - # api user --jq '.login' returns just the username string (no JSON quotes) - echo 'testuser' > "$GH_MOCK_DIR/api_user.txt" echo '{"items": []}' > "$GH_MOCK_DIR/items_inprogress.json" echo '{"items": []}' > "$GH_MOCK_DIR/items_tier1.json" echo '{"items": []}' > "$GH_MOCK_DIR/items_tier2.json" @@ -375,17 +381,16 @@ JSON # --------------------------------------------------------------------------- @test "AC7b: in_progress populated with current user's In Progress items" { + # Fixture reflects server-side assignee:@me filtering — only current user's items cat > "$GH_MOCK_DIR/items_inprogress.json" << 'JSON' {"items": [ - {"id": "PVTI_55", "type": "ISSUE", "content": {"number": 55, "title": "WIP item", "url": "https://github.com/testowner/testrepo/issues/55", "assignees": [{"login": "testuser"}], "labels": [{"name": "type:feature"}, {"name": "priority:P1"}, {"name": "effort:M"}], "milestone": {"number": 8, "title": "v0.6.0"}}, "status": "In Progress", "linkedPullRequests": [{"number": 88, "url": "https://github.com/testowner/testrepo/pull/88"}]}, - {"id": "PVTI_66", "type": "ISSUE", "content": {"number": 66, "title": "Another WIP", "url": "https://github.com/testowner/testrepo/issues/66", "assignees": [{"login": "otheruser"}], "labels": [{"name": "type:bug"}, {"name": "priority:P2"}, {"name": "effort:S"}], "milestone": {"number": 8, "title": "v0.6.0"}}, "status": "In Progress", "linkedPullRequests": []} + {"id": "PVTI_55", "type": "ISSUE", "content": {"number": 55, "title": "WIP item", "url": "https://github.com/testowner/testrepo/issues/55", "assignees": [{"login": "testuser"}], "labels": [{"name": "type:feature"}, {"name": "priority:P1"}, {"name": "effort:M"}], "milestone": {"number": 8, "title": "v0.6.0"}}, "status": "In Progress", "linkedPullRequests": [{"number": 88, "url": "https://github.com/testowner/testrepo/pull/88"}]} ]} JSON run "$PICK_ITEM" [[ "$status" -eq 0 ]] - # Only testuser's items appear in in_progress count=$(echo "$output" | jq '.in_progress | length') [[ "$count" == "1" ]] From ca12ef4d356baeee6da50177b98c0f2381a3b43b Mon Sep 17 00:00:00 2001 From: Filipe Utzig Date: Tue, 16 Jun 2026 15:37:59 -0300 Subject: [PATCH 3/4] perf(pick-item): read winner body from project item-list data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gh project item-list already returns .content.body — no need for a separate gh issue view call. Remove the fetch, use .content.body from winner_item directly, and add body to all test fixtures. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Filipe Utzig --- bin/pick-item | 10 ++-------- tests/pick-item.bats | 35 ++++++----------------------------- 2 files changed, 8 insertions(+), 37 deletions(-) diff --git a/bin/pick-item b/bin/pick-item index 37e5194..30f4e34 100755 --- a/bin/pick-item +++ b/bin/pick-item @@ -234,15 +234,11 @@ for (( i=0; i&1) || { - echo "Failed to fetch body for issue #${winner_num}: $winner_body" >&2; exit 1 - } - # Parent check (404 = no parent) parent_raw=$(gh api "repos/${owner}/${repo}/issues/${winner_num}/parent" 2>/dev/null) && { parent_out=$(echo "$parent_raw" | jq '{ @@ -257,12 +253,11 @@ if [[ -n "$winner_num" ]]; then --argjson tier "$winner_tier" \ --argjson sub_issues_summary "$winner_sub_issues_summary" \ --argjson parent "$parent_out" \ - --argjson body_obj "$winner_body" \ '{ number: .content.number, title: .content.title, tier: $tier, - body: $body_obj.body, + body: .content.body, labels: (.content.labels // []), milestone: .content.milestone, url: .content.url, @@ -278,7 +273,6 @@ else fi fi -# ─── 8. Emit JSON ───────────────────────────────────────────────────────────── warnings_out=$(jq -nR '[inputs | select(length > 0)]' < "$WARNINGS_FILE" 2>/dev/null || echo "[]") jq -n \ diff --git a/tests/pick-item.bats b/tests/pick-item.bats index 3e747d5..55adf70 100644 --- a/tests/pick-item.bats +++ b/tests/pick-item.bats @@ -96,16 +96,6 @@ elif [[ "$subcmd" == "project" ]]; then echo "Unhandled project subcmd: $subcmd2" >&2; exit 1 fi -elif [[ "$subcmd" == "issue" ]]; then - subcmd2="${1:-}" - shift || true - if [[ "$subcmd2" == "view" ]]; then - num="$1" - cat "$GH_MOCK_DIR/issue_view_${num}.json" - else - echo "Unhandled issue subcmd: $subcmd2" >&2; exit 1 - fi - else echo "Unhandled gh subcmd: $subcmd ($*)" >&2; exit 1 fi @@ -132,6 +122,7 @@ issue_item() { "id": "PVTI_%s", "type": "ISSUE", "content": { "number": %s, "title": "%s", + "body": "Issue body.", "url": "https://github.com/testowner/testrepo/issues/%s", "assignees": [], "labels": [{"name": "type:feature"}, {"name": "priority:P1"}, {"name": "effort:S"}], "milestone": {"number": 8, "title": "%s"} @@ -146,13 +137,6 @@ no_blockers_summary() { "$num" "$title" } -issue_view() { - local num="$1" title="$2" - # \\n → printf outputs literal \n (JSON escape), not actual newline - printf '{"number": %s, "title": "%s", "body": "### What\\nDo X\\n\\n### Why\\nBecause\\n\\n### In Scope\\n- step\\n\\n### Out of Scope\\n- nothing\\n\\n### Acceptance Criteria\\n- [ ] AC1\\n\\n### INVEST Notes\\nOK", "labels": [{"name": "type:feature"}, {"name": "priority:P1"}, {"name": "effort:S"}], "milestone": {"number": 8, "title": "v0.6.0"}, "url": "https://github.com/testowner/testrepo/issues/%s"}' \ - "$num" "$title" "$num" -} - # --------------------------------------------------------------------------- # AC1 — script exists and is executable # --------------------------------------------------------------------------- @@ -184,7 +168,6 @@ issue_view() { no_blockers_summary 42 "Do something" > "$GH_MOCK_DIR/issue_42.json" echo '[]' > "$GH_MOCK_DIR/sub_issues_42.json" # No parent file → 404 - issue_view 42 "Do something" > "$GH_MOCK_DIR/issue_view_42.json" run "$PICK_ITEM" [[ "$status" -eq 0 ]] @@ -207,12 +190,11 @@ issue_view() { > "$GH_MOCK_DIR/items_tier2.json" # Override: strip milestone from content cat > "$GH_MOCK_DIR/items_tier2.json" << 'JSON' -{"items": [{"id": "PVTI_99", "type": "ISSUE", "content": {"number": 99, "title": "No-milestone item", "url": "https://github.com/testowner/testrepo/issues/99", "assignees": [], "labels": [{"name": "type:feature"}, {"name": "priority:P2"}, {"name": "effort:S"}], "milestone": null}, "status": "Todo", "linkedPullRequests": []}]} +{"items": [{"id": "PVTI_99", "type": "ISSUE", "content": {"number": 99, "title": "No-milestone item", "body": "Issue body.", "url": "https://github.com/testowner/testrepo/issues/99", "assignees": [], "labels": [{"name": "type:feature"}, {"name": "priority:P2"}, {"name": "effort:S"}], "milestone": null}, "status": "Todo", "linkedPullRequests": []}]} JSON no_blockers_summary 99 "No-milestone item" > "$GH_MOCK_DIR/issue_99.json" echo '[]' > "$GH_MOCK_DIR/sub_issues_99.json" - issue_view 99 "No-milestone item" > "$GH_MOCK_DIR/issue_view_99.json" run "$PICK_ITEM" [[ "$status" -eq 0 ]] @@ -283,7 +265,6 @@ JSON no_blockers_summary 20 "Unblocked item" > "$GH_MOCK_DIR/issue_20.json" echo '[]' > "$GH_MOCK_DIR/sub_issues_20.json" - issue_view 20 "Unblocked item" > "$GH_MOCK_DIR/issue_view_20.json" run "$PICK_ITEM" [[ "$status" -eq 0 ]] @@ -322,7 +303,6 @@ JSON touch "$GH_MOCK_DIR/blockers_42.json.404" echo '[]' > "$GH_MOCK_DIR/sub_issues_42.json" - issue_view 42 "Do something" > "$GH_MOCK_DIR/issue_view_42.json" run "$PICK_ITEM" [[ "$status" -eq 0 ]] @@ -342,8 +322,8 @@ JSON # Tier 1: parent #100 first, then sub-issue #101 cat > "$GH_MOCK_DIR/items_tier1.json" << 'JSON' {"items": [ - {"id": "PVTI_100", "type": "ISSUE", "content": {"number": 100, "title": "Parent item", "url": "https://github.com/testowner/testrepo/issues/100", "assignees": [], "labels": [{"name": "type:feature"}, {"name": "priority:P1"}, {"name": "effort:L"}], "milestone": {"number": 8, "title": "v0.6.0"}}, "status": "Todo", "linkedPullRequests": []}, - {"id": "PVTI_101", "type": "ISSUE", "content": {"number": 101, "title": "Sub-issue item", "url": "https://github.com/testowner/testrepo/issues/101", "assignees": [], "labels": [{"name": "type:feature"}, {"name": "priority:P1"}, {"name": "effort:S"}], "milestone": {"number": 8, "title": "v0.6.0"}}, "status": "Todo", "linkedPullRequests": []} + {"id": "PVTI_100", "type": "ISSUE", "content": {"number": 100, "title": "Parent item", "body": "Issue body.", "url": "https://github.com/testowner/testrepo/issues/100", "assignees": [], "labels": [{"name": "type:feature"}, {"name": "priority:P1"}, {"name": "effort:L"}], "milestone": {"number": 8, "title": "v0.6.0"}}, "status": "Todo", "linkedPullRequests": []}, + {"id": "PVTI_101", "type": "ISSUE", "content": {"number": 101, "title": "Sub-issue item", "body": "Issue body.", "url": "https://github.com/testowner/testrepo/issues/101", "assignees": [], "labels": [{"name": "type:feature"}, {"name": "priority:P1"}, {"name": "effort:S"}], "milestone": {"number": 8, "title": "v0.6.0"}}, "status": "Todo", "linkedPullRequests": []} ]} JSON @@ -356,7 +336,6 @@ JSON no_blockers_summary 101 "Sub-issue item" > "$GH_MOCK_DIR/issue_101.json" echo '[]' > "$GH_MOCK_DIR/sub_issues_101.json" - issue_view 101 "Sub-issue item" > "$GH_MOCK_DIR/issue_view_101.json" # Sub-issue #101's parent is #100 cat > "$GH_MOCK_DIR/parent_101.json" << 'JSON' @@ -384,7 +363,7 @@ JSON # Fixture reflects server-side assignee:@me filtering — only current user's items cat > "$GH_MOCK_DIR/items_inprogress.json" << 'JSON' {"items": [ - {"id": "PVTI_55", "type": "ISSUE", "content": {"number": 55, "title": "WIP item", "url": "https://github.com/testowner/testrepo/issues/55", "assignees": [{"login": "testuser"}], "labels": [{"name": "type:feature"}, {"name": "priority:P1"}, {"name": "effort:M"}], "milestone": {"number": 8, "title": "v0.6.0"}}, "status": "In Progress", "linkedPullRequests": [{"number": 88, "url": "https://github.com/testowner/testrepo/pull/88"}]} + {"id": "PVTI_55", "type": "ISSUE", "content": {"number": 55, "title": "WIP item", "body": "Issue body.", "url": "https://github.com/testowner/testrepo/issues/55", "assignees": [{"login": "testuser"}], "labels": [{"name": "type:feature"}, {"name": "priority:P1"}, {"name": "effort:M"}], "milestone": {"number": 8, "title": "v0.6.0"}}, "status": "In Progress", "linkedPullRequests": [{"number": 88, "url": "https://github.com/testowner/testrepo/pull/88"}]} ]} JSON @@ -421,7 +400,7 @@ JSON @test "AC3b: items with type:external-blocker are never selected as candidates" { cat > "$GH_MOCK_DIR/items_tier1.json" << 'JSON' {"items": [ - {"id": "PVTI_77", "type": "ISSUE", "content": {"number": 77, "title": "External blocker stub", "url": "https://github.com/testowner/testrepo/issues/77", "assignees": [], "labels": [{"name": "type:external-blocker"}], "milestone": {"number": 8, "title": "v0.6.0"}}, "status": "Todo", "linkedPullRequests": []} + {"id": "PVTI_77", "type": "ISSUE", "content": {"number": 77, "title": "External blocker stub", "body": "Issue body.", "url": "https://github.com/testowner/testrepo/issues/77", "assignees": [], "labels": [{"name": "type:external-blocker"}], "milestone": {"number": 8, "title": "v0.6.0"}}, "status": "Todo", "linkedPullRequests": []} ]} JSON @@ -449,8 +428,6 @@ JSON ] JSON - issue_view 42 "Parent with sub-issues done" > "$GH_MOCK_DIR/issue_view_42.json" - run "$PICK_ITEM" [[ "$status" -eq 0 ]] From db426cbf57b3f7001e6f896e86f5eab4a1986663 Mon Sep 17 00:00:00 2001 From: Filipe Utzig Date: Tue, 16 Jun 2026 23:52:35 -0300 Subject: [PATCH 4/4] fix(pick-item): fix field paths to match live gh project item-list shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The live GitHub Projects API returns labels, milestone, linked pull requests, and assignees at the item top level — not under .content. Also, labels are string arrays and linked PRs are URL strings. Fix field paths in in_progress and candidate_out construction, update all test fixtures to match, and fix the mock's label filter accordingly. Also remove select(.type == "ISSUE") — the live API returns type:null; the is:issue query filter already guarantees issue-only results. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Filipe Utzig --- bin/pick-item | 17 ++++++++--------- tests/pick-item.bats | 32 +++++++++++++++++--------------- 2 files changed, 25 insertions(+), 24 deletions(-) diff --git a/bin/pick-item b/bin/pick-item index 30f4e34..2418a50 100755 --- a/bin/pick-item +++ b/bin/pick-item @@ -44,15 +44,14 @@ tier2_raw=$(gh project item-list "$proj_num" --owner "$owner" \ in_progress=$(echo "$inprogress_raw" | jq ' [.items // [] | .[] - | select(.type == "ISSUE") | { number: .content.number, title: .content.title, - milestone: (.content.milestone.title // null), - labels: (.content.labels // [] | map(.name)), + milestone: (.milestone.title // null), + labels: (.labels // []), linked_pr: ( - (.linkedPullRequests // []) | if length > 0 - then .[0] | {number: .number, url: .url} + (."linked pull requests" // []) | if length > 0 + then .[0] | {number: (split("/") | last | tonumber), url: .} else null end ) @@ -65,8 +64,8 @@ candidates=$(jq -n \ --argjson t1 "$tier1_raw" \ --argjson t2 "$tier2_raw" \ ' - ($t1.items // [] | map(select(.type == "ISSUE") | . + {_tier: 1})) - + ($t2.items // [] | map(select(.type == "ISSUE") | . + {_tier: 2})) + ($t1.items // [] | map(. + {_tier: 1})) + + ($t2.items // [] | map(. + {_tier: 2})) ') # ─── 5. Block-skipping helper ───────────────────────────────────────────────── @@ -258,8 +257,8 @@ if [[ -n "$winner_num" ]]; then title: .content.title, tier: $tier, body: .content.body, - labels: (.content.labels // []), - milestone: .content.milestone, + labels: (.labels // []), + milestone: (.milestone | if . then {title: .title} else null end), url: .content.url, sub_issues_summary: $sub_issues_summary, parent: $parent diff --git a/tests/pick-item.bats b/tests/pick-item.bats index 55adf70..c88eb3a 100644 --- a/tests/pick-item.bats +++ b/tests/pick-item.bats @@ -78,15 +78,15 @@ elif [[ "$subcmd" == "project" ]]; then if echo "$args" | grep -qF "In Progress"; then cat "$GH_MOCK_DIR/items_inprogress.json" elif echo "$args" | grep -qF "no:milestone"; then - if echo "$args" | grep -qF -- "-label:type:external-blocker"; then - jq '.items = [.items[] | select((.content.labels // [] | map(.name) | index("type:external-blocker")) == null)]' \ + if echo "$args" | grep -qF -- "-label:"; then + jq '.items = [.items[] | select((.labels // [] | index("type:external-blocker")) == null)]' \ "$GH_MOCK_DIR/items_tier2.json" else cat "$GH_MOCK_DIR/items_tier2.json" fi else - if echo "$args" | grep -qF -- "-label:type:external-blocker"; then - jq '.items = [.items[] | select((.content.labels // [] | map(.name) | index("type:external-blocker")) == null)]' \ + if echo "$args" | grep -qF -- "-label:"; then + jq '.items = [.items[] | select((.labels // [] | index("type:external-blocker")) == null)]' \ "$GH_MOCK_DIR/items_tier1.json" else cat "$GH_MOCK_DIR/items_tier1.json" @@ -118,17 +118,19 @@ teardown() { issue_item() { local num="$1" title="$2" milestone="${3:-v0.6.0}" + local ms_json + [[ -n "$milestone" ]] && ms_json="{\"title\": \"$milestone\"}" || ms_json="null" printf '{ - "id": "PVTI_%s", "type": "ISSUE", + "id": "PVTI_%s", "type": null, "content": { "number": %s, "title": "%s", "body": "Issue body.", - "url": "https://github.com/testowner/testrepo/issues/%s", - "assignees": [], "labels": [{"name": "type:feature"}, {"name": "priority:P1"}, {"name": "effort:S"}], - "milestone": {"number": 8, "title": "%s"} + "url": "https://github.com/testowner/testrepo/issues/%s" }, - "status": "Todo", "linkedPullRequests": [] - }' "$num" "$num" "$title" "$num" "$milestone" + "labels": ["type:feature", "priority:P1", "effort:S"], + "milestone": %s, + "status": "Todo", "linked pull requests": [] + }' "$num" "$num" "$title" "$num" "$ms_json" } no_blockers_summary() { @@ -190,7 +192,7 @@ no_blockers_summary() { > "$GH_MOCK_DIR/items_tier2.json" # Override: strip milestone from content cat > "$GH_MOCK_DIR/items_tier2.json" << 'JSON' -{"items": [{"id": "PVTI_99", "type": "ISSUE", "content": {"number": 99, "title": "No-milestone item", "body": "Issue body.", "url": "https://github.com/testowner/testrepo/issues/99", "assignees": [], "labels": [{"name": "type:feature"}, {"name": "priority:P2"}, {"name": "effort:S"}], "milestone": null}, "status": "Todo", "linkedPullRequests": []}]} +{"items": [{"id": "PVTI_99", "type": null, "content": {"number": 99, "title": "No-milestone item", "body": "Issue body.", "url": "https://github.com/testowner/testrepo/issues/99"}, "labels": ["type:feature", "priority:P2", "effort:S"], "milestone": null, "status": "Todo", "linked pull requests": []}]} JSON no_blockers_summary 99 "No-milestone item" > "$GH_MOCK_DIR/issue_99.json" @@ -322,8 +324,8 @@ JSON # Tier 1: parent #100 first, then sub-issue #101 cat > "$GH_MOCK_DIR/items_tier1.json" << 'JSON' {"items": [ - {"id": "PVTI_100", "type": "ISSUE", "content": {"number": 100, "title": "Parent item", "body": "Issue body.", "url": "https://github.com/testowner/testrepo/issues/100", "assignees": [], "labels": [{"name": "type:feature"}, {"name": "priority:P1"}, {"name": "effort:L"}], "milestone": {"number": 8, "title": "v0.6.0"}}, "status": "Todo", "linkedPullRequests": []}, - {"id": "PVTI_101", "type": "ISSUE", "content": {"number": 101, "title": "Sub-issue item", "body": "Issue body.", "url": "https://github.com/testowner/testrepo/issues/101", "assignees": [], "labels": [{"name": "type:feature"}, {"name": "priority:P1"}, {"name": "effort:S"}], "milestone": {"number": 8, "title": "v0.6.0"}}, "status": "Todo", "linkedPullRequests": []} + {"id": "PVTI_100", "type": null, "content": {"number": 100, "title": "Parent item", "body": "Issue body.", "url": "https://github.com/testowner/testrepo/issues/100"}, "labels": ["type:feature", "priority:P1", "effort:L"], "milestone": {"title": "v0.6.0"}, "status": "Todo", "linked pull requests": []}, + {"id": "PVTI_101", "type": null, "content": {"number": 101, "title": "Sub-issue item", "body": "Issue body.", "url": "https://github.com/testowner/testrepo/issues/101"}, "labels": ["type:feature", "priority:P1", "effort:S"], "milestone": {"title": "v0.6.0"}, "status": "Todo", "linked pull requests": []} ]} JSON @@ -363,7 +365,7 @@ JSON # Fixture reflects server-side assignee:@me filtering — only current user's items cat > "$GH_MOCK_DIR/items_inprogress.json" << 'JSON' {"items": [ - {"id": "PVTI_55", "type": "ISSUE", "content": {"number": 55, "title": "WIP item", "body": "Issue body.", "url": "https://github.com/testowner/testrepo/issues/55", "assignees": [{"login": "testuser"}], "labels": [{"name": "type:feature"}, {"name": "priority:P1"}, {"name": "effort:M"}], "milestone": {"number": 8, "title": "v0.6.0"}}, "status": "In Progress", "linkedPullRequests": [{"number": 88, "url": "https://github.com/testowner/testrepo/pull/88"}]} + {"id": "PVTI_55", "type": null, "content": {"number": 55, "title": "WIP item", "body": "Issue body.", "url": "https://github.com/testowner/testrepo/issues/55"}, "labels": ["type:feature", "priority:P1", "effort:M"], "milestone": {"title": "v0.6.0"}, "status": "In Progress", "linked pull requests": ["https://github.com/testowner/testrepo/pull/88"]} ]} JSON @@ -400,7 +402,7 @@ JSON @test "AC3b: items with type:external-blocker are never selected as candidates" { cat > "$GH_MOCK_DIR/items_tier1.json" << 'JSON' {"items": [ - {"id": "PVTI_77", "type": "ISSUE", "content": {"number": 77, "title": "External blocker stub", "body": "Issue body.", "url": "https://github.com/testowner/testrepo/issues/77", "assignees": [], "labels": [{"name": "type:external-blocker"}], "milestone": {"number": 8, "title": "v0.6.0"}}, "status": "Todo", "linkedPullRequests": []} + {"id": "PVTI_77", "type": null, "content": {"number": 77, "title": "External blocker stub", "body": "Issue body.", "url": "https://github.com/testowner/testrepo/issues/77"}, "labels": ["type:external-blocker"], "milestone": {"title": "v0.6.0"}, "status": "Todo", "linked pull requests": []} ]} JSON