diff --git a/actions/issueflow-reconcile/issueflow-reconcile.sh b/actions/issueflow-reconcile/issueflow-reconcile.sh index 327fec6..deee082 100644 --- a/actions/issueflow-reconcile/issueflow-reconcile.sh +++ b/actions/issueflow-reconcile/issueflow-reconcile.sh @@ -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 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 elif [ "$assignees" -eq 0 ]; then echo FLAG_UNASSIGNED 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_reclaim_marker() { # $1 = last activity epoch + printf 'claim-reclaimed-%s\n' "$1" +} + blocked_references() { # body on stdin -> issue numbers, one per line - sed -nE 's/.*Blocked by[[:space:]]+//Ip' \ - | sed -E 's/[.;][[:space:]].*$//' \ + # 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 + # 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 } @@ -186,7 +213,10 @@ reconcile_issue() { ensure_comment "$n" claimed-unassigned \ 'This issue is `claimed` but has no assignee. The sweep cannot infer an owner; triage must repair the claim.' ;; 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.' owners="$(jq -r '[.assignees[].login] | join(",")' <<<"$ISSUE_JSON")" if [ -n "$owners" ]; then diff --git a/docs/CONSUMERS.md b/docs/CONSUMERS.md index e216dc1..85de0c8 100644 --- a/docs/CONSUMERS.md +++ b/docs/CONSUMERS.md @@ -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 `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=` -setting or a label row, so `#`-prefixed prose is a parse failure, not a -comment (rig #13's conversion found this the hard way — keep the file data -only). +setting, the `triage-actors=` setting, or a label row, so `#`-prefixed prose +is a parse failure, not a comment (rig #13's conversion found this the hard +way — keep the file data only). Core state, blocker, work-queue, and release labels come from ceremony. Scope rows remain consumer-owned because paths and surfaces differ by repository. diff --git a/test/issueflow-reconcile.test.sh b/test/issueflow-reconcile.test.sh index e94b0b5..3029388 100644 --- a/test/issueflow-reconcile.test.sh +++ b/test/issueflow-reconcile.test.sh @@ -31,8 +31,10 @@ check "dogfood caller wakes on issue events" 0 " issues:" \ dogfood_pr_step="$(sed -n \ '/name: reconcile state + stale (dogfood/,/name: reconcile issue flow/p' \ "$ROOT/.github/workflows/labels.yml")" +# shellcheck disable=SC2016 # GitHub expressions are asserted as literals check "dogfood PR reconcile receives repository" 0 ' REPO: ${{ github.repository }}' \ 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 }}' \ 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" \ bash -c 'ISSUEFLOW_NOW=100000 ISSUEFLOW_STALE_HOURS=1 source "$1"; claim_decision_at 1 false 96399' _ \ "$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. 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" +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)." check "real issue 13 inline blockers parse" 0 "" test \ "$(blocked_references <<<"$body")" = $'11\n12\n19'