Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
285 changes: 285 additions & 0 deletions bin/pick-item
Original file line number Diff line number Diff line change
@@ -0,0 +1,285 @@
#!/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. 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" 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 -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 -label:type:external-blocker status:Todo no:milestone" 2>&1) || {
echo "Failed to fetch Tier 2 items: $tier2_raw" >&2; exit 1
}

# ─── 3. in_progress array ─────────────────────────────────────────────────────
in_progress=$(echo "$inprogress_raw" | jq '
[.items // []
| .[]
| {
number: .content.number,
title: .content.title,
milestone: (.milestone.title // null),
labels: (.labels // []),
linked_pr: (
(."linked pull requests" // []) | if length > 0
then .[0] | {number: (split("/") | last | tonumber), url: .}
else null
end
)
}
]
')

# ─── 4. Candidate list: tier1 then tier2 ─────────────────────────────────────
candidates=$(jq -n \
--argjson t1 "$tier1_raw" \
--argjson t2 "$tier2_raw" \
'
($t1.items // [] | map(. + {_tier: 1}))
+ ($t2.items // [] | map(. + {_tier: 2}))
')

# ─── 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() {
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"
}

# ─── 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')

for (( i=0; i<n_candidates; i++ )); do
item=$(echo "$candidates" | jq ".[$i]")
num=$(echo "$item" | jq -r '.content.number')
title=$(echo "$item" | jq -r '.content.title')
tier=$(echo "$item" | jq -r '._tier')

# Block-skipping for this candidate
OPEN_BL_FILE="$WORK_DIR/open_bl_${num}"
fetch_open_blockers "$num" "$OPEN_BL_FILE"
open_count=$(jq 'length' "$OPEN_BL_FILE")

if [[ "$open_count" -gt 0 ]]; then
entry=$(jq -n \
--argjson num "$num" --arg title "$title" \
--argjson blockers "$(cat "$OPEN_BL_FILE")" \
'{number: $num, title: $title, open_blockers: $blockers}')
skipped_blocked=$(echo "$skipped_blocked" | jq ". + [$entry]")
continue
fi

# Unblocked β€” check sub-issues
sub_raw=$(gh api "repos/${owner}/${repo}/issues/${num}/sub_issues" 2>/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<sub_n; j++ )); do
sub_item=$(echo "$sub_candidates" | jq ".[$j]")
sub_num=$(echo "$sub_item" | jq -r '.content.number')
sub_title=$(echo "$sub_item" | jq -r '.content.title')
sub_tier=$(echo "$sub_item" | jq -r '._tier')

SUB_BL_FILE="$WORK_DIR/open_bl_sub_${sub_num}"
fetch_open_blockers "$sub_num" "$SUB_BL_FILE"
sub_open_count=$(jq 'length' "$SUB_BL_FILE")

if [[ "$sub_open_count" -gt 0 ]]; then
entry=$(jq -n \
--argjson num "$sub_num" --arg title "$sub_title" \
--argjson blockers "$(cat "$SUB_BL_FILE")" \
'{number: $num, title: $title, open_blockers: $blockers}')
skipped_blocked=$(echo "$skipped_blocked" | jq ". + [$entry]")
continue
fi

# Sub-issue wins; get its own sub_issues_summary (single level only)
sub2_raw=$(gh api "repos/${owner}/${repo}/issues/${sub_num}/sub_issues" 2>/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"
winner_item="$sub_item"
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"
winner_item="$item"
break
done

# ─── 7. Emit JSON ─────────────────────────────────────────────────────────────
candidate_out="null"
message_out="null"

if [[ -n "$winner_num" ]]; then
# 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_item" | jq \
--argjson tier "$winner_tier" \
--argjson sub_issues_summary "$winner_sub_issues_summary" \
--argjson parent "$parent_out" \
'{
number: .content.number,
title: .content.title,
tier: $tier,
body: .content.body,
labels: (.labels // []),
milestone: (.milestone | if . then {title: .title} else null end),
url: .content.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

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'
Loading