From e7750c0c8ffd879eec65c46577bf8ac96e6fd011 Mon Sep 17 00:00:00 2001 From: Andriujose <43181885+andriujoseba@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:48:15 +0000 Subject: [PATCH] feat: diagnose malformed attention targets --- .../issueflow-reconcile.sh | 15 +++ actions/labels-reconcile/labels-reconcile.sh | 9 ++ lib/attention.sh | 97 +++++++++++++++++++ 3 files changed, 121 insertions(+) create mode 100644 lib/attention.sh diff --git a/actions/issueflow-reconcile/issueflow-reconcile.sh b/actions/issueflow-reconcile/issueflow-reconcile.sh index 4e29f84..5a09755 100644 --- a/actions/issueflow-reconcile/issueflow-reconcile.sh +++ b/actions/issueflow-reconcile/issueflow-reconcile.sh @@ -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 diff --git a/actions/labels-reconcile/labels-reconcile.sh b/actions/labels-reconcile/labels-reconcile.sh index 97ada8a..a2ef589 100755 --- a/actions/labels-reconcile/labels-reconcile.sh +++ b/actions/labels-reconcile/labels-reconcile.sh @@ -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() { diff --git a/lib/attention.sh b/lib/attention.sh new file mode 100644 index 0000000..e2e2e6a --- /dev/null +++ b/lib/attention.sh @@ -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='\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" +}