forked from heavy-duty/ceremony
fix: guard cross-repo issue references
This commit is contained in:
parent
66f1c08e1a
commit
bc099eb4e2
4 changed files with 52 additions and 11 deletions
|
|
@ -6,6 +6,7 @@ so entries say what changed, cite the issue, and stop.
|
|||
|
||||
## Unreleased
|
||||
|
||||
- `issueflow-reconcile` — keep cross-repo references out of local dependency decisions and require triage to resolve cross-repo blockers by hand (#61).
|
||||
- `needs-ruling` — the cross-cutting flag for a pending human decision, excluded from `state:needs-human` and from the staleness sweep (#51).
|
||||
|
||||
## 0.1.0 — 2026-07-22
|
||||
|
|
|
|||
|
|
@ -65,7 +65,9 @@ Every issue you mint carries, in this order:
|
|||
the builder's definition of done and the reviewer's review spec, verbatim.
|
||||
- **Test plan**: what proves it, including the cases that must fail.
|
||||
- **Dependencies**: `Blocked by #N` / `Blocks #N`, and `Part of #E` when an
|
||||
epic organizes it.
|
||||
epic organizes it. Name a cross-repo dependency the same way with its
|
||||
repository qualified (`Blocked by repo#N` or `owner/repo#N`); the sweep
|
||||
cannot resolve it, so triage verifies it and flips the issue by hand.
|
||||
- **Labels**: type (`bug`/`enhancement`/`documentation`), `scope:*`, and
|
||||
exactly one of `ready` / `blocked` (see [LABELS.md](LABELS.md)).
|
||||
|
||||
|
|
|
|||
|
|
@ -105,7 +105,17 @@ claim_reclaim_marker() { # $1 = last activity epoch
|
|||
printf 'claim-reclaimed-%s\n' "$1"
|
||||
}
|
||||
|
||||
blocked_references() { # body on stdin -> issue numbers, one per line
|
||||
issue_references() { # text on stdin -> LOCAL/CROSS<TAB>reference
|
||||
# A qualified reference belongs to another repository. Classify the whole
|
||||
# token before extracting numbers so rig#112 can never become local #112.
|
||||
{ grep -Eo '([[:alnum:]_.-]+/)?[[:alnum:]_.-]+#[0-9]+|#[0-9]+' || true; } \
|
||||
| awk '
|
||||
index($0, "#") == 1 { print "LOCAL\t" substr($0, 2); next }
|
||||
{ print "CROSS\t" $0 }
|
||||
'
|
||||
}
|
||||
|
||||
blocked_reference_records() { # body on stdin -> classified reference records
|
||||
# Dependency declarations sometimes soft-wrap after a comma. Continue
|
||||
# through the first sentence terminator; if prose omits one, conservatively
|
||||
# retain later references so ambiguity can keep an issue blocked, never
|
||||
|
|
@ -128,13 +138,21 @@ blocked_references() { # body on stdin -> issue numbers, one per line
|
|||
}
|
||||
print line
|
||||
}
|
||||
' \
|
||||
| { grep -Eo '#[0-9]+' || true; } | tr -d '#' | sort -nu
|
||||
' | issue_references
|
||||
}
|
||||
|
||||
blocked_decision() { # $1 refs, $2 OPEN/CLOSED states
|
||||
local refs="$1" states="$2"
|
||||
if [ -z "$refs" ]; then echo FLAG_UNPARSEABLE
|
||||
blocked_references() { # body on stdin -> local issue numbers, one per line
|
||||
blocked_reference_records | awk -F '\t' '$1 == "LOCAL" { print $2 }' | sort -nu
|
||||
}
|
||||
|
||||
blocked_cross_references() { # body on stdin -> qualified refs, one per line
|
||||
blocked_reference_records | awk -F '\t' '$1 == "CROSS" { print $2 }' | sort -u
|
||||
}
|
||||
|
||||
blocked_decision() { # $1 local refs, $2 OPEN/CLOSED states, $3 cross-repo refs
|
||||
local refs="$1" states="$2" cross_refs="${3:-}"
|
||||
if [ -n "$cross_refs" ]; then echo FLAG_CROSS_REPO
|
||||
elif [ -z "$refs" ]; then echo FLAG_UNPARSEABLE
|
||||
elif grep -qxF OPEN <<<"$states"; then echo KEEP
|
||||
elif grep -qxF UNKNOWN <<<"$states"; then echo FLAG_UNPARSEABLE
|
||||
else echo READY
|
||||
|
|
@ -146,8 +164,8 @@ epic_references() { # markdown task-list issue references from body on stdin
|
|||
tolower($0) ~ /^##[[:space:]]+task list[[:space:]]*$/ { in_list = 1; next }
|
||||
in_list && /^#/ { exit }
|
||||
in_list && /^[[:space:]]*[-*][[:space:]]+\[[ xX]\]/ { print }
|
||||
' \
|
||||
| { grep -Eo '#[0-9]+' || true; } | tr -d '#' | sort -nu
|
||||
' | issue_references \
|
||||
| awk -F '\t' '$1 == "LOCAL" { print $2 }' | sort -nu
|
||||
}
|
||||
|
||||
epic_decision() { # $1 refs, $2 states
|
||||
|
|
@ -190,7 +208,7 @@ last_issue_activity() {
|
|||
}
|
||||
|
||||
reconcile_issue() {
|
||||
local n="$1" decision refs states age assignees open_pr=false label owners
|
||||
local n="$1" decision refs cross_refs states age assignees open_pr=false label owners
|
||||
decision="$(queue_decision <<<"$ISSUE_LABELS")"
|
||||
case "$decision" in
|
||||
ADD_NEEDS_TRIAGE)
|
||||
|
|
@ -229,9 +247,13 @@ reconcile_issue() {
|
|||
esac
|
||||
elif has_issue_label blocked; then
|
||||
refs="$(blocked_references <<<"$(jq -r '.body // ""' <<<"$ISSUE_JSON")")"
|
||||
cross_refs="$(blocked_cross_references <<<"$(jq -r '.body // ""' <<<"$ISSUE_JSON")")"
|
||||
states="$(reference_states <<<"$refs")"
|
||||
decision="$(blocked_decision "$refs" "$states")"
|
||||
decision="$(blocked_decision "$refs" "$states" "$cross_refs")"
|
||||
case "$decision" in
|
||||
FLAG_CROSS_REPO)
|
||||
ensure_comment "$n" blocked-cross-repo \
|
||||
"This issue's \`Blocked by\` declaration names cross-repo dependencies that the sweep cannot resolve: $(tr '\n' ' ' <<<"$cross_refs" | sed 's/[[:space:]]*$//'). Triage must verify those dependencies and flip this issue to \`ready\` by hand." ;;
|
||||
FLAG_UNPARSEABLE)
|
||||
ensure_comment "$n" blocked-unparseable \
|
||||
'This issue is `blocked`, but its body has no parseable `Blocked by #N` declaration. The sweep will not guess the dependency.' ;;
|
||||
|
|
|
|||
|
|
@ -90,10 +90,23 @@ check "real issue 15 inline blocker parses" 0 "" test \
|
|||
body="Part of #1. Blocked by #11, #12 (needs a released ceremony + the bootstrap guide); benefits from #13's lessons but does not need #14/#15."
|
||||
check "real issue 16 inline blockers parse" 0 "" test \
|
||||
"$(blocked_references <<<"$body")" = $'11\n12'
|
||||
check "qualified short repository reference drops" 0 "" test \
|
||||
-z "$(blocked_references <<<"Blocked by rig#112.")"
|
||||
check "qualified owner/repository reference drops" 0 "" test \
|
||||
-z "$(blocked_references <<<"Blocked by heavy-duty/box#9.")"
|
||||
check "parenthesized local reference survives" 0 "13" blocked_references <<<"Blocked by (#13)."
|
||||
check "slash-adjacent local references survive" 0 $'14\n15' \
|
||||
blocked_references <<<"Blocked by #14/#15."
|
||||
check "comma-adjacent local references survive" 0 $'11\n12' \
|
||||
blocked_references <<<"Blocked by #11, #12."
|
||||
check "open blocker keeps issue blocked" 0 "KEEP" blocked_decision "$refs" $'CLOSED\nOPEN'
|
||||
check "all closed blockers release issue" 0 "READY" blocked_decision "$refs" $'CLOSED\nCLOSED'
|
||||
check "missing blocked declaration is flagged" 0 "FLAG_UNPARSEABLE" blocked_decision "" ""
|
||||
check "unreadable blocker is flagged" 0 "FLAG_UNPARSEABLE" blocked_decision "12" "UNKNOWN"
|
||||
check "cross-repo-only blocker is flagged distinctly" 0 "FLAG_CROSS_REPO" \
|
||||
blocked_decision "" "" "rig#112"
|
||||
check "cross-repo blocker prevents false promotion when locals close" 0 "FLAG_CROSS_REPO" \
|
||||
blocked_decision "9" "CLOSED" "rig#9"
|
||||
|
||||
# Invariant 4: only configured triage actors mint directly into the queue.
|
||||
check "triage-authored ready issue is accepted" 0 "KEEP" author_decision true <<<"ready"
|
||||
|
|
@ -107,6 +120,9 @@ check "epic parser reads task-list refs only" 0 $'2\n3' printf '%s\n' "$epic_ref
|
|||
body=$'## Task list\n- [x] #2 done\n- [x] #3 done\n\n## Definition of done\n- [ ] open issue #99 must not suppress the nudge'
|
||||
check "epic parser stops before later checkbox sections" 0 "" test \
|
||||
"$(epic_references <<<"$body")" = $'2\n3'
|
||||
body=$'## Task list\n- [x] #2 Scaffold\n- [x] #3 version\n- [ ] #13 Convert rig (pilot) — **PR [rig#112](https://github.com/heavy-duty/rig/pull/112) is approved by the whole panel on head `3c72c1b` and sits at `state:needs-human` since 2026-07-23 10:47Z; the merge is the human'\''s. #14/#15 unblock when it lands**\n- [ ] #14 Convert box\n- [ ] #15 Convert cast\n- [ ] #16 Adopt in incubator'
|
||||
check "real epic 1 row drops rig PR while retaining local children" 0 $'2\n3\n13\n14\n15\n16' \
|
||||
epic_references <<<"$body"
|
||||
check "completed epic is nudged" 0 "NUDGE" epic_decision "$epic_refs" $'CLOSED\nCLOSED'
|
||||
check "open epic child suppresses nudge" 0 "KEEP" epic_decision "$epic_refs" $'CLOSED\nOPEN'
|
||||
check "epic without parseable children is stable" 0 "KEEP" epic_decision "" ""
|
||||
|
|
|
|||
Loading…
Reference in a new issue