forked from heavy-duty/ceremony
feat: transition merged refs work to post-merge
This commit is contained in:
parent
8e6423a07c
commit
bb7dd51ba7
2 changed files with 172 additions and 42 deletions
|
|
@ -21,7 +21,7 @@ ISSUEFLOW_STALE_HOURS="${ISSUEFLOW_STALE_HOURS:-48}"
|
|||
}
|
||||
NOW="$ISSUEFLOW_NOW"
|
||||
STALE_AFTER=$((ISSUEFLOW_STALE_HOURS * 3600))
|
||||
QUEUE_LABELS=(ready claimed blocked)
|
||||
QUEUE_LABELS=(ready claimed blocked post-merge)
|
||||
TRIAGE_ACTORS=()
|
||||
|
||||
# The needs-ruling invariants (#52) — one implementation for both surfaces.
|
||||
|
|
@ -122,6 +122,25 @@ claim_reclaim_marker() { # $1 = last activity epoch
|
|||
printf 'claim-reclaimed-%s\n' "$1"
|
||||
}
|
||||
|
||||
refs_references() { # PR body on stdin -> local issue numbers named by Refs
|
||||
awk '
|
||||
tolower($0) ~ /(^|[^[:alnum:]_-])refs([[:space:]]|:)/ { print }
|
||||
' | issue_references \
|
||||
| awk -F '\t' '$1 == "LOCAL" { print $2 }' | sort -nu
|
||||
}
|
||||
|
||||
unchecked_criteria() { # issue body on stdin -> unchecked task-list lines verbatim
|
||||
awk '/^[[:space:]]*[-*][[:space:]]+\[[[:space:]]\]/ { print }'
|
||||
}
|
||||
|
||||
post_merge_decision() { # $1 merged Refs-linked PR, unchecked criteria on stdin
|
||||
local merged="$1" unchecked
|
||||
unchecked="$(cat)"
|
||||
if [ "$merged" = true ] && [ -n "$unchecked" ]; then echo TRANSITION
|
||||
else echo KEEP
|
||||
fi
|
||||
}
|
||||
|
||||
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.
|
||||
|
|
@ -262,6 +281,7 @@ last_issue_activity() {
|
|||
|
||||
reconcile_issue() {
|
||||
local n="$1" decision refs cross_refs states age assignees open_pr=false label owners
|
||||
local merged_ref=false unchecked=""
|
||||
decision="$(queue_decision <<<"$ISSUE_LABELS")"
|
||||
case "$decision" in
|
||||
ADD_NEEDS_TRIAGE)
|
||||
|
|
@ -269,7 +289,7 @@ reconcile_issue() {
|
|||
log "#$n: needs-triage (no queue state)" ;;
|
||||
FLAG_CONFLICT)
|
||||
ensure_comment "$n" queue-conflict \
|
||||
'The issue-flow sweep found conflicting queue labels. It cannot infer intent safely; triage must leave exactly one of `needs-triage`, `epic`, `ready`, `claimed`, or `blocked`.'
|
||||
'The issue-flow sweep found conflicting queue labels. It cannot infer intent safely; triage must leave exactly one of `needs-triage`, `epic`, `ready`, `claimed`, `blocked`, or `post-merge`.'
|
||||
log "#$n: conflicting queue labels; flagged"
|
||||
return ;;
|
||||
esac
|
||||
|
|
@ -277,46 +297,73 @@ reconcile_issue() {
|
|||
if has_issue_label claimed; then
|
||||
assignees="$(jq '.assignees | length' <<<"$ISSUE_JSON")"
|
||||
grep -qxF "$n" <<<"${OPEN_PR_ISSUES:-}" && open_pr=true
|
||||
age="$(last_issue_activity "$n" "$(jq -r '.created_at' <<<"$ISSUE_JSON")")"
|
||||
if [ "$(claim_clock_exempt <<<"$ISSUE_LABELS")" = EXEMPT ]; then
|
||||
# Legitimately quiet work does not run the reclaim clock. Only the
|
||||
# clock stops: an unassigned claim is still a repair the decision must
|
||||
# see, so it runs on a zero age rather than being skipped.
|
||||
decision="$(claim_decision "$assignees" "$open_pr" 0)"
|
||||
grep -qxF "$n" <<<"${MERGED_REF_PR_ISSUES:-}" && merged_ref=true
|
||||
unchecked="$(unchecked_criteria <<<"$(jq -r '.body // ""' <<<"$ISSUE_JSON")")"
|
||||
if [ "$(post_merge_decision "$merged_ref" <<<"$unchecked")" = TRANSITION ]; then
|
||||
ensure_comment "$n" post-merge-transition \
|
||||
"The Refs-linked PR merged with these acceptance criteria still unchecked:
|
||||
|
||||
$unchecked
|
||||
|
||||
The merge releases the claim; no builder owes a draft. Triage owes completion in a follow-up comment that names the owner and wake condition."
|
||||
owners="$(jq -r '[.assignees[].login] | join(",")' <<<"$ISSUE_JSON")"
|
||||
if [ -n "$owners" ]; then
|
||||
run gh issue edit "$n" -R "$REPO" --remove-assignee "$owners" \
|
||||
--remove-label claimed --add-label post-merge >/dev/null
|
||||
else
|
||||
run gh issue edit "$n" -R "$REPO" \
|
||||
--remove-label claimed --add-label post-merge >/dev/null
|
||||
fi
|
||||
log "#$n: merged Refs PR -> post-merge; claim released"
|
||||
else
|
||||
decision="$(claim_decision_at "$assignees" "$open_pr" "$age")"
|
||||
fi
|
||||
case "$decision" in
|
||||
FLAG_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.' ;;
|
||||
RECLAIM)
|
||||
# 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
|
||||
run gh issue edit "$n" -R "$REPO" --remove-assignee "$owners" \
|
||||
--remove-label claimed --add-label ready >/dev/null
|
||||
else
|
||||
run gh issue edit "$n" -R "$REPO" --remove-label claimed --add-label ready >/dev/null
|
||||
fi
|
||||
log "#$n: stale claim reclaimed -> ready" ;;
|
||||
esac
|
||||
if has_issue_label offsite; then
|
||||
local timeline
|
||||
if timeline="$(offsite_timeline "$n")"; then
|
||||
refs="$(offsite_cross_referenced_prs <<<"$timeline")"
|
||||
states="$(offsite_pr_states <<<"$refs")"
|
||||
if [ "$(offsite_resolved_decision <<<"$states")" = NUDGE ]; then
|
||||
ensure_comment "$n" offsite-resolved \
|
||||
"$(tr '\n' ' ' <<<"$refs" | sed 's/[[:space:]]*$//') is closed; this issue's \`offsite\` flag is still up. Clear it and close the issue, or say what is still outstanding. @$(jq -r '.assignees[0].login' <<<"$ISSUE_JSON")"
|
||||
log "#$n: resolved offsite PRs nudged"
|
||||
age="$(last_issue_activity "$n" "$(jq -r '.created_at' <<<"$ISSUE_JSON")")"
|
||||
if [ "$(claim_clock_exempt <<<"$ISSUE_LABELS")" = EXEMPT ]; then
|
||||
# Legitimately quiet work does not run the reclaim clock. Only the
|
||||
# clock stops: an unassigned claim is still a repair the decision must
|
||||
# see, so it runs on a zero age rather than being skipped.
|
||||
decision="$(claim_decision "$assignees" "$open_pr" 0)"
|
||||
else
|
||||
decision="$(claim_decision_at "$assignees" "$open_pr" "$age")"
|
||||
fi
|
||||
case "$decision" in
|
||||
FLAG_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.' ;;
|
||||
RECLAIM)
|
||||
# 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
|
||||
run gh issue edit "$n" -R "$REPO" --remove-assignee "$owners" \
|
||||
--remove-label claimed --add-label ready >/dev/null
|
||||
else
|
||||
run gh issue edit "$n" -R "$REPO" --remove-label claimed --add-label ready >/dev/null
|
||||
fi
|
||||
log "#$n: stale claim reclaimed -> ready" ;;
|
||||
esac
|
||||
if has_issue_label offsite; then
|
||||
local timeline
|
||||
if timeline="$(offsite_timeline "$n")"; then
|
||||
refs="$(offsite_cross_referenced_prs <<<"$timeline")"
|
||||
states="$(offsite_pr_states <<<"$refs")"
|
||||
if [ "$(offsite_resolved_decision <<<"$states")" = NUDGE ]; then
|
||||
ensure_comment "$n" offsite-resolved \
|
||||
"$(tr '\n' ' ' <<<"$refs" | sed 's/[[:space:]]*$//') is closed; this issue's \`offsite\` flag is still up. Clear it and close the issue, or say what is still outstanding. @$(jq -r '.assignees[0].login' <<<"$ISSUE_JSON")"
|
||||
log "#$n: resolved offsite PRs nudged"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
elif has_issue_label post-merge; then
|
||||
assignees="$(jq '.assignees | length' <<<"$ISSUE_JSON")"
|
||||
if [ "$assignees" -gt 0 ]; then
|
||||
ensure_comment "$n" post-merge-assigned \
|
||||
'This `post-merge` issue has an assignee. The sweep will not undo a hand-assignment; triage must either clear it or move the issue back into buildable queue state.'
|
||||
log "#$n: assigned post-merge issue flagged"
|
||||
fi
|
||||
elif has_issue_label blocked; then
|
||||
refs="$(blocked_references <<<"$(jq -r '.body // ""' <<<"$ISSUE_JSON")")"
|
||||
cross_refs="$(blocked_cross_references <<<"$(jq -r '.body // ""' <<<"$ISSUE_JSON")")"
|
||||
|
|
@ -406,6 +453,16 @@ main() {
|
|||
}
|
||||
}' --jq '.data.repository.pullRequests.nodes[].closingIssuesReferences.nodes[].number' \
|
||||
| sort -nu)"
|
||||
MERGED_REF_PR_ISSUES="$(gh api graphql --paginate -f owner="$owner" -f name="$name" -f query='
|
||||
query($owner: String!, $name: String!, $endCursor: String) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
pullRequests(first: 100, states: MERGED, after: $endCursor) {
|
||||
nodes { body }
|
||||
pageInfo { hasNextPage endCursor }
|
||||
}
|
||||
}
|
||||
}' --jq '.data.repository.pullRequests.nodes[].body' \
|
||||
| refs_references)"
|
||||
|
||||
local n
|
||||
for n in $(gh api --paginate "repos/$REPO/issues?state=open&per_page=100" \
|
||||
|
|
|
|||
|
|
@ -43,6 +43,10 @@ check "one ready queue label is valid" 0 "KEEP" queue_decision <<<"ready"
|
|||
check "zero queue labels is derivably needs-triage" 0 "ADD_NEEDS_TRIAGE" queue_decision <<<"enhancement"
|
||||
check "multiple queue labels are ambiguous" 0 "FLAG_CONFLICT" queue_decision <<< $'ready\nblocked'
|
||||
check "needs-triage plus queue is a conflict" 0 "FLAG_CONFLICT" queue_decision <<< $'needs-triage\nready'
|
||||
check "claimed plus post-merge is a conflict" 0 "FLAG_CONFLICT" \
|
||||
queue_decision <<< $'claimed\npost-merge'
|
||||
check "post-merge plus needs-ruling is healthy" 0 "KEEP" \
|
||||
queue_decision <<< $'post-merge\nneeds-ruling'
|
||||
|
||||
# Invariant 2: claims have an owner and either a PR or recent activity.
|
||||
check "claim with open PR stays claimed" 0 "KEEP" claim_decision 1 true 999999
|
||||
|
|
@ -78,6 +82,18 @@ check "attention does not exempt a claimed issue" 0 "SWEEP" \
|
|||
claim_clock_exempt <<<"attention"
|
||||
check "ready does not exempt a claimed issue" 0 "SWEEP" claim_clock_exempt <<<"ready"
|
||||
check "empty labels do not exempt a claimed issue" 0 "SWEEP" claim_clock_exempt </dev/null
|
||||
refs_body=$'Refs #12\nAlso refs: #8 and heavy-duty/rig#4.\nCloses #99\nNot refs-ish #7'
|
||||
check "Refs parser returns only local issues on Refs lines" 0 $'8\n12' \
|
||||
refs_references <<<"$refs_body"
|
||||
check "unchecked criteria preserve their source lines verbatim" 0 \
|
||||
$'- [ ] first criterion\n * [ ] indented criterion' \
|
||||
unchecked_criteria <<< $'- [x] done\n- [ ] first criterion\n * [ ] indented criterion'
|
||||
check "merged Refs with unchecked criteria transitions" 0 "TRANSITION" \
|
||||
post_merge_decision true <<<"- [ ] verify after merge"
|
||||
check "open Refs does not transition" 0 "KEEP" \
|
||||
post_merge_decision false <<<"- [ ] verify after merge"
|
||||
check "merged Refs with all criteria checked does not transition" 0 "KEEP" \
|
||||
post_merge_decision true </dev/null
|
||||
check "one closed offsite PR nudges" 0 "NUDGE" offsite_resolved_decision <<<"CLOSED"
|
||||
check "two closed offsite PRs nudge" 0 "NUDGE" offsite_resolved_decision <<< $'CLOSED\nCLOSED'
|
||||
check "one open offsite PR keeps quiet" 0 "QUIET" offsite_resolved_decision <<< $'CLOSED\nOPEN'
|
||||
|
|
@ -234,16 +250,18 @@ issue_stub_gh() {
|
|||
fi
|
||||
}
|
||||
|
||||
issue_probe() { # $1 issue, $2 labels, $3 assignees (default 1), $4 open PR
|
||||
issue_probe() { # $1 issue, $2 labels, $3 assignees, $4 open PR, $5 merged Refs, $6 body
|
||||
(
|
||||
local assignees="${3:-1}" open_pr="${4:-false}" assignee_json='[]'
|
||||
local assignees="${3:-1}" open_pr="${4:-false}" merged_ref="${5:-false}"
|
||||
local body="${6:-}" assignee_json='[]'
|
||||
[ "$assignees" -eq 0 ] || assignee_json='[{"login":"owner-bot"}]'
|
||||
REPO=owner/repo NOW="$INOW"
|
||||
ISSUE_LABELS="$2"
|
||||
ISSUE_JSON="$(jq -n --arg at "$(iso_at $((INOW - 10 * 86400)))" \
|
||||
--argjson assignees "$assignee_json" \
|
||||
'{created_at: $at, assignees: $assignees, body: ""}')"
|
||||
--argjson assignees "$assignee_json" --arg body "$body" \
|
||||
'{created_at: $at, assignees: $assignees, body: $body}')"
|
||||
if [ "$open_pr" = true ]; then OPEN_PR_ISSUES="$1"; else OPEN_PR_ISSUES=""; fi
|
||||
if [ "$merged_ref" = true ]; then MERGED_REF_PR_ISSUES="$1"; else MERGED_REF_PR_ISSUES=""; fi
|
||||
run() { "$@"; }
|
||||
gh() { issue_stub_gh "$@"; }
|
||||
reconcile_issue "$1" 2>&1
|
||||
|
|
@ -291,6 +309,34 @@ control="$(issue_probe 22 claimed)"
|
|||
check "the flag-free control is reclaimed (the clock still runs elsewhere)" 0 "" \
|
||||
grep -q 'stale claim reclaimed -> ready' <<<"$control"
|
||||
|
||||
# -- merged Refs work releases the claim before the reclaim clock ------------
|
||||
printf '[]\n' >"$(cfix 35)"
|
||||
transition="$(issue_probe 35 claimed 1 false true $'- [x] built\n- [ ] verify dispatch\n * [ ] confirm warning clears')"
|
||||
check "merged Refs + unchecked criteria transitions in the sweep body" 0 "" \
|
||||
grep -q 'merged Refs PR -> post-merge; claim released' <<<"$transition"
|
||||
check "...names every remaining criterion verbatim in the comment" 0 "" \
|
||||
bash -c 'grep -qF -- "- [ ] verify dispatch" "$1" &&
|
||||
grep -qF -- " * [ ] confirm warning clears" "$1"' _ "$TMP/posted-35"
|
||||
check "...states triage owes completion with owner and wake condition" 0 "" \
|
||||
grep -qF 'Triage owes completion in a follow-up comment that names the owner and wake condition.' \
|
||||
"$TMP/posted-35"
|
||||
check "...unassigns and swaps claimed to post-merge" 0 "" \
|
||||
grep -qF -- '--remove-assignee owner-bot --remove-label claimed --add-label post-merge' \
|
||||
"$TMP/issue-edits"
|
||||
|
||||
printf '[]\n' >"$(cfix 36)"
|
||||
post_merge_quiet="$(issue_probe 36 post-merge 0)"
|
||||
check "quiet unassigned post-merge work is not reclaimed" 1 "" \
|
||||
grep -q 'reclaimed' <<<"$post_merge_quiet"
|
||||
check "...and causes no comment or edit" 1 "" test -f "$TMP/posted-36"
|
||||
|
||||
printf '[]\n' >"$(cfix 37)"
|
||||
issue_probe 37 post-merge 1 >/dev/null
|
||||
check "assigned post-merge is flagged" 0 "" \
|
||||
grep -qF '<!-- issueflow:post-merge-assigned -->' "$TMP/posted-37"
|
||||
check "...and the hand-assignment is not repaired" 1 "" \
|
||||
grep -qF -- 'issue edit 37' "$TMP/issue-edits"
|
||||
|
||||
# -- offsite stops only the reclaim clock ------------------------------------
|
||||
offsite="$(issue_probe 25 $'claimed\noffsite')"
|
||||
check "a 10-day-quiet offsite claim is not reclaimed" 1 "" \
|
||||
|
|
@ -475,6 +521,33 @@ check "...stands down without minting" 1 "" test -s "$ARRIVAL/fixtures/edits"
|
|||
check "...and the sweep still runs" 0 "" \
|
||||
grep -qF 'issueflow: reconciled.' <<<"$pr_out"
|
||||
|
||||
# The merged-Refs transition must survive the executable's set -e path too.
|
||||
# Keep this at main() granularity: the GraphQL gather and loop are the code
|
||||
# a sourced decision probe cannot exercise (#91's lesson).
|
||||
printf '%s\n' \
|
||||
'{"data":{"repository":{"pullRequests":{"nodes":[{"body":"Refs #40","closingIssuesReferences":{"nodes":[]}}],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \
|
||||
>"$ARRIVAL/fixtures/graphql.json"
|
||||
printf '[{"number":40}]\n' \
|
||||
>"$ARRIVAL/fixtures/repos_owner_repo_issues_state_open_per_page_100.json"
|
||||
jq -n --arg at "$(iso_at "$INOW")" \
|
||||
'{number:40,user:{login:"triage-one"},created_at:$at,body:"- [x] built\n- [ ] verify live label",labels:[{name:"claimed"}],assignees:[{login:"builder"}]}' \
|
||||
>"$ARRIVAL/fixtures/repos_owner_repo_issues_40.json"
|
||||
printf '[]\n' >"$ARRIVAL/fixtures/repos_owner_repo_issues_40_comments.json"
|
||||
: >"$ARRIVAL/fixtures/edits"
|
||||
subprocess_out="$(
|
||||
env PATH="$ARRIVAL/stub:$PATH" GH_FIXTURES="$ARRIVAL/fixtures" \
|
||||
REPO=owner/repo LABELS_CONF="$ARRIVAL/labels.conf" \
|
||||
bash "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" 2>&1
|
||||
)"
|
||||
subprocess_rc=$?
|
||||
check "executable sweep transitions merged Refs work" 0 "" \
|
||||
test "$subprocess_rc" -eq 0
|
||||
check "...reaches the transition through GraphQL and the issue loop" 0 "" \
|
||||
grep -qF '#40: merged Refs PR -> post-merge; claim released' <<<"$subprocess_out"
|
||||
check "...and performs the release edit from the executable path" 0 "" \
|
||||
grep -qF -- 'issue edit 40 -R owner/repo --remove-assignee builder --remove-label claimed --add-label post-merge' \
|
||||
"$ARRIVAL/fixtures/edits"
|
||||
|
||||
# D2 preserved: only the deliberate stand-downs changed; a genuine failure on
|
||||
# the arrival path still kills the run loudly.
|
||||
: >"$ARRIVAL/fixtures/repos_owner_repo_issues_91.json.error"
|
||||
|
|
|
|||
Loading…
Reference in a new issue