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
29 changes: 29 additions & 0 deletions scripts/initiative-driver.sh
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,15 @@ labels_of() {
gh api "repos/$REPO/issues/$1" --jq '.labels[].name'
}

# sub_issue_numbers <issue-number> — newline-separated native sub-issue numbers.
sub_issue_numbers() {
if [[ $# -lt 1 ]]; then
echo "::error::sub_issue_numbers requires an issue number" >&2
return 1
fi
gh api --paginate "repos/$REPO/issues/$1/sub_issues" --jq '.[].number'
}
Comment thread
don-petry marked this conversation as resolved.

# drive_epic <epic-number> — release the ready sub-issues of one epic.
# Returns 0 on success (including no-ops); never aborts the caller's sweep.
drive_epic() {
Expand Down Expand Up @@ -165,6 +174,26 @@ drive_epic() {
log " #$n — skip: $HOLD_LABEL (held)."
continue
fi
# Never release a sub-issue that is itself an epic (#882; the #934/#938
# incident): an armed nested epic appears in the sweep and is driven on its
# own, so the parent must skip it rather than implement it as a story.
# Signals: it carries GATE_LABEL, or it has ≥1 native sub-issue of its own.
# The plain `initiative` label is deliberately NOT used — initiative-planner
# labels BOTH epics and their story sub-issues `initiative`, so it cannot
# distinguish the two; keying on it would skip every story. GATE_LABEL is
# checked first (no API call needed) so a transient sub-issues lookup cannot
# block a gate-labeled nested epic from being skipped. The children fetch is
# a direct assignment (not an `if` condition) so a transient API failure
# trips errexit instead of being masked into a false "not an epic".
if has_label "$labels" "$GATE_LABEL"; then
log " #$n — skip: is itself an epic (drive it independently)."
continue
fi
n_children="$(sub_issue_numbers "$n")"
if [[ -n "$n_children" ]]; then
log " #$n — skip: is itself an epic (drive it independently)."
continue
fi
Comment thread
coderabbitai[bot] marked this conversation as resolved.

open_blockers="$(
gh api "repos/$REPO/issues/$n/dependencies/blocked_by" \
Expand Down
106 changes: 102 additions & 4 deletions tests/test_initiative_driver.bats
Original file line number Diff line number Diff line change
Expand Up @@ -123,8 +123,10 @@ args="$*"
if [[ "$args" == *"issues/1 --jq"* ]] && [[ "$args" != *"sub_issues"* ]]; then
printf 'initiative:auto'; exit 0
fi
# Sub-issues: only issue 2
if [[ "$args" == *"sub_issues"* ]]; then printf '2'; exit 0; fi
# Epic #1 sub-issues: only issue 2
if [[ "$args" == *"issues/1/sub_issues"* ]]; then printf '2'; exit 0; fi
# Issue 2 has no sub-issues of its own (a story, not an epic)
if [[ "$args" == *"issues/2/sub_issues"* ]]; then printf ''; exit 0; fi
# Issue 2 has no dev-lead yet
if [[ "$args" == *"issues/2 --jq"* ]]; then printf ''; exit 0; fi
# Issue 2 has an open blocker (issue 99)
Expand All @@ -148,8 +150,10 @@ args="$*"
if [[ "$args" == *"issues/1 --jq"* ]] && [[ "$args" != *"sub_issues"* ]]; then
printf 'initiative:auto'; exit 0
fi
# Sub-issues: only issue 2
if [[ "$args" == *"sub_issues"* ]]; then printf '2'; exit 0; fi
# Epic #1 sub-issues: only issue 2
if [[ "$args" == *"issues/1/sub_issues"* ]]; then printf '2'; exit 0; fi
# Issue 2 has no sub-issues of its own (a story, not an epic)
if [[ "$args" == *"issues/2/sub_issues"* ]]; then printf ''; exit 0; fi
# Issue 2 has no dev-lead yet
if [[ "$args" == *"issues/2 --jq"* ]]; then printf ''; exit 0; fi
# No open blockers
Expand All @@ -166,6 +170,98 @@ EOF
grep -qF "issue edit" "$GH_LOG"
}

# ── nested-epic skip: never release a sub-issue that is itself an epic ────────
# Incident #934/#938 (refs #882): an `initiative:auto` epic nested as a native
# sub-issue of an armed parent epic was released as a "story" and closed by
# dev-lead, orphaning its own real stories. A nested epic is driven on its own
# via the sweep, so the parent must skip it.

@test "nested-epic: skips a sub-issue carrying the gate label (drive it independently)" {
cat > "$MOCK_BIN/gh" <<'EOF'
#!/usr/bin/env bash
printf '%s\n' "$*" >> "$GH_LOG"
args="$*"
# Epic #1 carries the gate label
if [[ "$args" == *"issues/1 --jq"* ]] && [[ "$args" != *"sub_issues"* ]]; then
printf 'initiative:auto'; exit 0
fi
# Epic #1 sub-issues: only issue 2 (which is itself a nested epic)
if [[ "$args" == *"issues/1/sub_issues"* ]]; then printf '2'; exit 0; fi
# Issue 2 carries the gate label too — it is itself an armed epic
if [[ "$args" == *"issues/2 --jq"* ]] && [[ "$args" != *"sub_issues"* ]]; then
printf 'initiative:auto'; exit 0
fi
# Issue 2 has no materialized children yet — the gate label alone must skip it
if [[ "$args" == *"issues/2/sub_issues"* ]]; then printf ''; exit 0; fi
printf '[]'
EOF
chmod +x "$MOCK_BIN/gh"

run bash "$SCRIPT"
[ "$status" -eq 0 ]
[[ "$output" == *"#2 — skip: is itself an epic"* ]]
! grep -qF "issue edit" "$GH_LOG"
}

@test "nested-epic: skips a sub-issue that has its own native sub-issues" {
cat > "$MOCK_BIN/gh" <<'EOF'
#!/usr/bin/env bash
printf '%s\n' "$*" >> "$GH_LOG"
args="$*"
# Epic #1 carries the gate label
if [[ "$args" == *"issues/1 --jq"* ]] && [[ "$args" != *"sub_issues"* ]]; then
printf 'initiative:auto'; exit 0
fi
# Epic #1 sub-issues: only issue 2
if [[ "$args" == *"issues/1/sub_issues"* ]]; then printf '2'; exit 0; fi
# Issue 2 has only the plain initiative label (no gate) but has its own children
if [[ "$args" == *"issues/2 --jq"* ]] && [[ "$args" != *"sub_issues"* ]]; then
printf 'initiative'; exit 0
fi
# Issue 2 has a native sub-issue of its own (issue 5) — so it is itself an epic
if [[ "$args" == *"issues/2/sub_issues"* ]]; then printf '5'; exit 0; fi
printf '[]'
EOF
chmod +x "$MOCK_BIN/gh"

run bash "$SCRIPT"
[ "$status" -eq 0 ]
[[ "$output" == *"#2 — skip: is itself an epic"* ]]
! grep -qF "issue edit" "$GH_LOG"
}

@test "nested-epic: a plain story (initiative label, no children, no gate) is still released" {
cat > "$MOCK_BIN/gh" <<'EOF'
#!/usr/bin/env bash
printf '%s\n' "$*" >> "$GH_LOG"
args="$*"
# Epic #1 carries the gate label
if [[ "$args" == *"issues/1 --jq"* ]] && [[ "$args" != *"sub_issues"* ]]; then
printf 'initiative:auto'; exit 0
fi
# Epic #1 sub-issues: only issue 2
if [[ "$args" == *"issues/1/sub_issues"* ]]; then printf '2'; exit 0; fi
# Issue 2 is an ordinary story: plain initiative label, no gate label
if [[ "$args" == *"issues/2 --jq"* ]] && [[ "$args" != *"sub_issues"* ]]; then
printf 'initiative'; exit 0
fi
# Issue 2 has no native sub-issues of its own — it is NOT an epic
if [[ "$args" == *"issues/2/sub_issues"* ]]; then printf ''; exit 0; fi
# Issue 2 has no open blockers
if [[ "$args" == *"issues/2/dependencies/blocked_by"* ]]; then printf ''; exit 0; fi
# Label application succeeds
if [[ "$args" == "issue edit"* ]]; then exit 0; fi
printf '[]'
EOF
chmod +x "$MOCK_BIN/gh"

run bash "$SCRIPT"
[ "$status" -eq 0 ]
[[ "$output" == *"RELEASED"* ]]
[[ "$output" != *"is itself an epic"* ]]
grep -qF "issue edit" "$GH_LOG"
}

# ── sweep mode: EPIC empty discovers all gated epics ─────────────────────────

@test "sweep: drives every open epic carrying initiative:auto when EPIC is empty" {
Expand Down Expand Up @@ -258,6 +354,8 @@ if [[ "$args" == *"repos/other/repo/issues -f state=open"* ]]; then printf '10';
if [[ "$args" == *"repos/other/repo/issues/10 --jq"* ]]; then printf 'initiative:auto'; exit 0; fi
# Sub-issues of epic 10 in the target repo: one open child, issue 2
if [[ "$args" == *"repos/other/repo/issues/10/sub_issues"* ]]; then printf '2'; exit 0; fi
# Issue 2 has no sub-issues of its own (a story, not an epic)
if [[ "$args" == *"repos/other/repo/issues/2/sub_issues"* ]]; then printf ''; exit 0; fi
# Issue 2 has no dev-lead yet
if [[ "$args" == *"repos/other/repo/issues/2 --jq"* ]]; then printf ''; exit 0; fi
# Issue 2 has no open blockers
Expand Down
Loading