forked from heavy-duty/ceremony
fix: harden issueflow reconciliation edges
This commit is contained in:
parent
70db91fa1a
commit
43092576ef
3 changed files with 52 additions and 6 deletions
|
|
@ -89,6 +89,8 @@ author_decision() { # $1 = true when author is triage; labels on stdin
|
||||||
|
|
||||||
claim_decision() { # $1 assignee count, $2 linked open PR, $3 age seconds
|
claim_decision() { # $1 assignee count, $2 linked open PR, $3 age seconds
|
||||||
local assignees="$1" open_pr="$2" age="$3"
|
local assignees="$1" open_pr="$2" age="$3"
|
||||||
|
# Staleness wins over missing ownership: a stale unassigned claim is
|
||||||
|
# derivably reclaimable, while a recent unassigned claim needs triage.
|
||||||
if [ "$open_pr" = false ] && [ "$age" -gt "$STALE_AFTER" ]; then echo RECLAIM
|
if [ "$open_pr" = false ] && [ "$age" -gt "$STALE_AFTER" ]; then echo RECLAIM
|
||||||
elif [ "$assignees" -eq 0 ]; then echo FLAG_UNASSIGNED
|
elif [ "$assignees" -eq 0 ]; then echo FLAG_UNASSIGNED
|
||||||
else echo KEEP
|
else echo KEEP
|
||||||
|
|
@ -99,9 +101,34 @@ claim_decision_at() { # $1 assignee count, $2 linked open PR, $3 last activity e
|
||||||
claim_decision "$1" "$2" "$((NOW - $3))"
|
claim_decision "$1" "$2" "$((NOW - $3))"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
claim_reclaim_marker() { # $1 = last activity epoch
|
||||||
|
printf 'claim-reclaimed-%s\n' "$1"
|
||||||
|
}
|
||||||
|
|
||||||
blocked_references() { # body on stdin -> issue numbers, one per line
|
blocked_references() { # body on stdin -> issue numbers, one per line
|
||||||
sed -nE 's/.*Blocked by[[:space:]]+//Ip' \
|
# Dependency declarations sometimes soft-wrap after a comma. Continue
|
||||||
| sed -E 's/[.;][[:space:]].*$//' \
|
# through the first sentence terminator; if prose omits one, conservatively
|
||||||
|
# retain later references so ambiguity can keep an issue blocked, never
|
||||||
|
# promote it prematurely.
|
||||||
|
awk '
|
||||||
|
{
|
||||||
|
line = $0
|
||||||
|
lower = tolower(line)
|
||||||
|
if (!active) {
|
||||||
|
marker = "blocked by"
|
||||||
|
start = index(lower, marker)
|
||||||
|
if (!start) next
|
||||||
|
line = substr(line, start + length(marker))
|
||||||
|
active = 1
|
||||||
|
}
|
||||||
|
if (line ~ /[.;]/) {
|
||||||
|
sub(/[.;].*/, "", line)
|
||||||
|
print line
|
||||||
|
exit
|
||||||
|
}
|
||||||
|
print line
|
||||||
|
}
|
||||||
|
' \
|
||||||
| { grep -Eo '#[0-9]+' || true; } | tr -d '#' | sort -nu
|
| { grep -Eo '#[0-9]+' || true; } | tr -d '#' | sort -nu
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -186,7 +213,10 @@ reconcile_issue() {
|
||||||
ensure_comment "$n" claimed-unassigned \
|
ensure_comment "$n" claimed-unassigned \
|
||||||
'This issue is `claimed` but has no assignee. The sweep cannot infer an owner; triage must repair the claim.' ;;
|
'This issue is `claimed` but has no assignee. The sweep cannot infer an owner; triage must repair the claim.' ;;
|
||||||
RECLAIM)
|
RECLAIM)
|
||||||
ensure_comment "$n" claim-reclaimed \
|
# The last-activity epoch identifies a claim episode. A fixed marker
|
||||||
|
# hid the required comment when the same issue was later claimed and
|
||||||
|
# reclaimed again.
|
||||||
|
ensure_comment "$n" "$(claim_reclaim_marker "$age")" \
|
||||||
'This claim has no linked open PR and no activity for 48 hours. The sweep is reclaiming it for the ready queue.'
|
'This claim has no linked open PR and no activity for 48 hours. The sweep is reclaiming it for the ready queue.'
|
||||||
owners="$(jq -r '[.assignees[].login] | join(",")' <<<"$ISSUE_JSON")"
|
owners="$(jq -r '[.assignees[].login] | join(",")' <<<"$ISSUE_JSON")"
|
||||||
if [ -n "$owners" ]; then
|
if [ -n "$owners" ]; then
|
||||||
|
|
|
||||||
|
|
@ -258,9 +258,9 @@ Both actor lists are whitespace-separated. `triage-actors` names the identities
|
||||||
allowed to mint issues without the sweep applying `needs-triage`. Label rows use exactly
|
allowed to mint issues without the sweep applying `needs-triage`. Label rows use exactly
|
||||||
`name|color|description`; blank lines are ignored and extra pipes are refused.
|
`name|color|description`; blank lines are ignored and extra pipes are refused.
|
||||||
There are no comment lines: every non-blank line must be the `panel=`
|
There are no comment lines: every non-blank line must be the `panel=`
|
||||||
setting or a label row, so `#`-prefixed prose is a parse failure, not a
|
setting, the `triage-actors=` setting, or a label row, so `#`-prefixed prose
|
||||||
comment (rig #13's conversion found this the hard way — keep the file data
|
is a parse failure, not a comment (rig #13's conversion found this the hard
|
||||||
only).
|
way — keep the file data only).
|
||||||
Core state, blocker, work-queue, and release labels come from ceremony. Scope
|
Core state, blocker, work-queue, and release labels come from ceremony. Scope
|
||||||
rows remain consumer-owned because paths and surfaces differ by repository.
|
rows remain consumer-owned because paths and surfaces differ by repository.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,8 +31,10 @@ check "dogfood caller wakes on issue events" 0 " issues:" \
|
||||||
dogfood_pr_step="$(sed -n \
|
dogfood_pr_step="$(sed -n \
|
||||||
'/name: reconcile state + stale (dogfood/,/name: reconcile issue flow/p' \
|
'/name: reconcile state + stale (dogfood/,/name: reconcile issue flow/p' \
|
||||||
"$ROOT/.github/workflows/labels.yml")"
|
"$ROOT/.github/workflows/labels.yml")"
|
||||||
|
# shellcheck disable=SC2016 # GitHub expressions are asserted as literals
|
||||||
check "dogfood PR reconcile receives repository" 0 ' REPO: ${{ github.repository }}' \
|
check "dogfood PR reconcile receives repository" 0 ' REPO: ${{ github.repository }}' \
|
||||||
grep -F ' REPO: ${{ github.repository }}' <<<"$dogfood_pr_step"
|
grep -F ' REPO: ${{ github.repository }}' <<<"$dogfood_pr_step"
|
||||||
|
# shellcheck disable=SC2016 # GitHub expressions are asserted as literals
|
||||||
check "dogfood PR reconcile receives token" 0 ' GH_TOKEN: ${{ github.token }}' \
|
check "dogfood PR reconcile receives token" 0 ' GH_TOKEN: ${{ github.token }}' \
|
||||||
grep -F ' GH_TOKEN: ${{ github.token }}' <<<"$dogfood_pr_step"
|
grep -F ' GH_TOKEN: ${{ github.token }}' <<<"$dogfood_pr_step"
|
||||||
|
|
||||||
|
|
@ -58,10 +60,24 @@ check "injected clock: exact stale boundary stays claimed" 0 "KEEP" \
|
||||||
check "injected clock: past stale boundary is reclaimed" 0 "RECLAIM" \
|
check "injected clock: past stale boundary is reclaimed" 0 "RECLAIM" \
|
||||||
bash -c 'ISSUEFLOW_NOW=100000 ISSUEFLOW_STALE_HOURS=1 source "$1"; claim_decision_at 1 false 96399' _ \
|
bash -c 'ISSUEFLOW_NOW=100000 ISSUEFLOW_STALE_HOURS=1 source "$1"; claim_decision_at 1 false 96399' _ \
|
||||||
"$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh"
|
"$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh"
|
||||||
|
# shellcheck disable=SC2016 # expansion belongs to the isolated bash -c process
|
||||||
|
check "invalid injected clock fails loudly" 1 "ISSUEFLOW_NOW must be UTC epoch seconds" \
|
||||||
|
bash -c 'ISSUEFLOW_NOW=garbage source "$1"' _ \
|
||||||
|
"$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh"
|
||||||
|
check "reclaim marker is stable within a claim episode" 0 "claim-reclaimed-96399" \
|
||||||
|
claim_reclaim_marker 96399
|
||||||
|
check "a later claim episode receives a new reclaim marker" 0 "claim-reclaimed-99999" \
|
||||||
|
claim_reclaim_marker 99999
|
||||||
|
|
||||||
# Invariant 3: blocked declarations parse and release only when all close.
|
# Invariant 3: blocked declarations parse and release only when all close.
|
||||||
refs="$(blocked_references <<< $'Context #99. Blocked by #12 (first), #7 (second). Blocks #44.')"
|
refs="$(blocked_references <<< $'Context #99. Blocked by #12 (first), #7 (second). Blocks #44.')"
|
||||||
check "blocked declaration extracts only declared refs" 0 $'7\n12' printf '%s\n' "$refs"
|
check "blocked declaration extracts only declared refs" 0 $'7\n12' printf '%s\n' "$refs"
|
||||||
|
body=$'Blocked by #12 (first),\n#7 (soft-wrapped second). Blocks #44.'
|
||||||
|
check "soft-wrapped blocker declaration retains continuation refs" 0 "" test \
|
||||||
|
"$(blocked_references <<<"$body")" = $'7\n12'
|
||||||
|
body=$'Blocked by #12 (known)\nFollow-up context mentions #7 without a sentence boundary'
|
||||||
|
check "unterminated blocker prose errs toward retaining dependencies" 0 "" test \
|
||||||
|
"$(blocked_references <<<"$body")" = $'7\n12'
|
||||||
body="Part of #1. Blocked by #11 (needs a ceremony tag to pin), #12 (must be executed from the guide), #19 (the conversion vendors the doctrine). Blocks #14, #15 (they inherit the pilot's lessons)."
|
body="Part of #1. Blocked by #11 (needs a ceremony tag to pin), #12 (must be executed from the guide), #19 (the conversion vendors the doctrine). Blocks #14, #15 (they inherit the pilot's lessons)."
|
||||||
check "real issue 13 inline blockers parse" 0 "" test \
|
check "real issue 13 inline blockers parse" 0 "" test \
|
||||||
"$(blocked_references <<<"$body")" = $'11\n12\n19'
|
"$(blocked_references <<<"$body")" = $'11\n12\n19'
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue