feat: diagnose malformed attention targets

This commit is contained in:
Andriujose 2026-08-03 19:48:15 +00:00
parent 5626a4ef1e
commit e7750c0c8f
3 changed files with 121 additions and 0 deletions

View file

@ -27,6 +27,9 @@ TRIAGE_ACTORS=()
# The needs-ruling invariants (#52) — one implementation for both surfaces.
# shellcheck source=lib/ruling.sh
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/ruling.sh"
# The attention target invariants (#232) — diagnosis only, both surfaces.
# shellcheck source=lib/attention.sh
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/attention.sh"
# The guarded read and its reason line (#101, #247) — one implementation for
# both surfaces.
# shellcheck source=lib/read.sh
@ -452,6 +455,7 @@ reconcile_issue() {
local n="$1" decision refs cross_refs states age created assignees open_pr=false label owners
local merged_ref_pr="" transition_marker="" transition_handled=false
local unchecked="" remove_claimed=claimed
local attention_active=true attention_suppression=""
decision="$(queue_decision <<<"$ISSUE_LABELS")"
case "$decision" in
ADD_NEEDS_TRIAGE)
@ -495,6 +499,7 @@ The merge releases the claim; no builder owes a draft. Triage owes completion in
--remove-label "$remove_claimed" --add-label post-merge >/dev/null
fi
log "#$n: merged Refs PR -> post-merge; claim released"
attention_active=false
else
created="$(jq -r '.created_at' <<<"$ISSUE_JSON")"
guarded_read age last_issue_activity "$n" "$created" \
@ -526,6 +531,7 @@ The merge releases the claim; no builder owes a draft. Triage owes completion in
fi
log "#$n: stale claim reclaimed -> ready" ;;
esac
[ "$decision" != FLAG_UNASSIGNED ] || attention_suppression=claimed-unassigned
if has_issue_label offsite; then
local timeline
if timeline="$(offsite_timeline "$n")"; then
@ -546,6 +552,7 @@ The merge releases the claim; no builder owes a draft. Triage owes completion in
'This `post-merge` issue has an assignee or `attention`. The sweep will not undo hand-set intent; triage must clear the invalid composition or move the issue back into buildable queue state.'
log "#$n: assigned or attention-bearing post-merge issue flagged"
fi
attention_suppression=post-merge-assigned
elif has_issue_label blocked; then
refs="$(blocked_references <<<"$(jq -r '.body // ""' <<<"$ISSUE_JSON")")"
cross_refs="$(blocked_cross_references <<<"$(jq -r '.body // ""' <<<"$ISSUE_JSON")")"
@ -574,6 +581,14 @@ The merge releases the claim; no builder owes a draft. Triage owes completion in
fi
fi
# The flag composes with every build queue state, but requires an assignee.
# Existing post-merge/claimed diagnostics take precedence so one board bug
# draws one comment (#232 D5); the shared helper still logs the suppression.
if [ "$attention_active" = true ] && has_issue_label attention; then
[ -n "${assignees:-}" ] || assignees="$(jq '.assignees | length' <<<"$ISSUE_JSON")"
reconcile_attention "$n" issue "$assignees" "$attention_suppression"
fi
# ---- the ruling invariants (#52), on any queue state ----
# The flag composes with the queue labels (#50 D8), so this runs after the
# queue branches rather than inside one of them. The FLAG_CONFLICT return

View file

@ -74,6 +74,9 @@ SELF_WORKFLOW="${SELF_WORKFLOW:-${GITHUB_WORKFLOW:-}}"
# The needs-ruling invariants (#52) — one implementation for both surfaces.
# shellcheck source=lib/ruling.sh
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/ruling.sh"
# The attention target invariants (#232) — diagnosis only, both surfaces.
# shellcheck source=lib/attention.sh
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/attention.sh"
# The guarded read and its reason line (#101) — one implementation for both
# surfaces. read_failure_reason lived here until the issue surface needed the
# identical rule (#247); a second copy of it is the failure lib/ruling.sh's
@ -921,6 +924,12 @@ reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch
if has_label needs-ruling; then
reconcile_ruling "$n" "$last_activity_epoch" "$NOW"
fi
# `attention` belongs on the assigned issue that owns the claim, never on
# a pull request (#232). Behind the label gate so ordinary PRs pay no read.
if has_label attention; then
reconcile_attention "$n" pr "$(jq '.assignees | length' <<<"$PR_JSON")" ""
fi
}
main() {

97
lib/attention.sh Normal file
View file

@ -0,0 +1,97 @@
#!/usr/bin/env bash
# lib/attention.sh — the `attention` target invariants (#232, epic #229).
#
# Both reconcilers source this file. Pure decisions sit above the divider;
# the impure orchestrator below reads the current label episode and comments
# through the sourcing script's run()/log(). The machine diagnoses only: it
# never sets, clears, retargets or assigns anything on the strength of these
# checks (#229 D2).
ATTENTION_MARKER_PREFIX='<!-- ceremony:attention-malformed:'
# ---------------------------------------------------------------------------
# Pure decisions. Facts in, verdict out. No gh, no clock.
# ---------------------------------------------------------------------------
attention_target_decision() { # $1 pr|issue, $2 assignee count → MALFORMED_* | KEEP
case "$1" in
pr) echo MALFORMED_PR ;;
issue)
if [ "$2" -eq 0 ]; then echo MALFORMED_UNASSIGNED; else echo KEEP; fi ;;
*) return 2 ;;
esac
}
attention_comment_decision() { # $1 target verdict, $2 suppression → POST | SUPPRESS | KEEP
case "$1" in
KEEP) echo KEEP ;;
MALFORMED_UNASSIGNED)
if [ -n "$2" ]; then echo SUPPRESS; else echo POST; fi ;;
MALFORMED_PR) echo POST ;;
*) return 2 ;;
esac
}
attention_newest_flag() { # labeled-event ISO-8601 timestamps on stdin → newest
sort | tail -n1
}
attention_episode_marker() { # $1 current episode's labeled timestamp
printf '%s%s -->\n' "$ATTENTION_MARKER_PREFIX" "$1"
}
# ---------------------------------------------------------------------------
# The impure orchestrator. Called only behind a has-attention gate.
# Needs REPO; uses the caller's run() and log().
# ---------------------------------------------------------------------------
reconcile_attention() { # $1 item, $2 pr|issue, $3 assignees, $4 suppression
local n="$1" surface="$2" assignees="$3" suppression="${4:-}"
local target comment labeled_events labeled_at marker comments body
: "${REPO:?reconcile_attention: REPO is required}"
target="$(attention_target_decision "$surface" "$assignees")"
comment="$(attention_comment_decision "$target" "$suppression")"
[ "$comment" != KEEP ] || return 0
if [ "$comment" = SUPPRESS ]; then
log "#$n: malformed attention detected; comment suppressed by $suppression precedence"
return 0
fi
if ! labeled_events="$(gh api --paginate "repos/$REPO/issues/$n/timeline" \
--jq '.[] | select(.event == "labeled" and .label.name == "attention")
| .created_at' 2>/dev/null)"; then
log "#$n: attention timeline unreadable — no verdict invented this pass"
return 0
fi
if [ -z "$labeled_events" ]; then
log "#$n: attention flag has no visible labeled event — no verdict invented this pass"
return 0
fi
labeled_at="$(attention_newest_flag <<<"$labeled_events")"
marker="$(attention_episode_marker "$labeled_at")"
if ! comments="$(gh api --paginate "repos/$REPO/issues/$n/comments" \
--jq '.[].body // ""' 2>/dev/null)"; then
log "#$n: attention comments unreadable — no verdict invented this pass"
return 0
fi
grep -qF "$marker" <<<"$comments" && return 0
case "$target" in
MALFORMED_PR)
body="$marker
This pull request carries \`attention\`, but that label is issue-only. Put
the label on the assigned issue that owns the claim. The sweep cannot infer
which issue that is, so it reports the malformed target without removing or
retargeting the label (heavy-duty/ceremony#232)." ;;
MALFORMED_UNASSIGNED)
body="$marker
This issue carries \`attention\` but has no assignee to receive the demand.
Assign the intended builder or remove the flag. The sweep reports the board
bug without assigning anyone or changing the label (heavy-duty/ceremony#232)." ;;
esac
run gh issue comment "$n" -R "$REPO" --body "$body" >/dev/null
log "#$n: malformed attention ($surface) — commented; no label or assignee changed"
}