ceremony/lib/attention.sh
cluade-reviewer-andresmgsl e035130f65 merge upstream 0.6.0 onto the forge tree, and port every gh call site it brought (#198)
`git merge` of upstream `8c3a4d1` onto `dad99dd`, common ancestor `84bb1a4`.
18 hunks in 10 files; `lib/forge.sh`, `lib/forge-github.sh` and
`lib/forge-forgejo.sh` conflict in none and come out byte-identical.

The resolutions the issue decided: VERSION and both CEREMONY_SELF_REF
carriers take upstream's numbers; `.github/labels.conf` and `drills/0.4.1.md`
keep this forge's; CHANGELOG keeps both sides and names the upstream commit
this tree carries.

The part the hunks did not contain. Upstream's 0.5.0/0.6.0 work added whole
functions to files this tree already owned, so `git merge` took its side
without raising a conflict — and with them, EIGHT runtime `gh` call sites
that #188 had removed. Seven are ported onto the shim: two reads and four
comment writes in issueflow-reconcile, and labels-reconcile's HEAD_COMMIT_AT
read. The eighth is `gh workflow run` in labels.yml, which a workflow cannot
declare a client for and whose Forgejo equivalent this instance answers with
500 rather than a 4xx — named with its reason rather than ported on a guess.

test/no-runtime-gh.test.sh makes the rule mechanical, because reviewing the
diff could not: four reviewers reading it each found a different subset, and
the contract suite stubs `gh`, so a reintroduced call site passes it.

Three seams the resolution decides are silent when resolved wrongly, and each
now has a case that fails on the wrong one: the merged record's `merged_at`
third column (without it every sort key ties and the highest PR number comes
back), the open gather's one-BODY-row-per-line feed (a whole decoded body as
one record loses every declaration including the first), and the whole-board
read whose COLLISION_FLAGS/WINDOW_FLAGS consumers auto-merged.

The open gather carries CLOSING rows as well as BODY rows. `Refs` alone would
drop every `Closes #N` link on the open side and reclaim a claim the PR was
holding — the existing base64 round-trip case is red without it.

actions/refs-not-closing declares CEREMONY_FORGE_CLIENT=gh: its only gather
is GraphQL, which Forgejo does not serve at all. #199 ports it.

test/run.sh: 28 test files, 0 failed. shellcheck and actionlint clean.

Refs #198
2026-08-05 11:56:23 +00:00

104 lines
4.2 KiB
Bash

#!/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 timeline
: "${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
# forge_timeline projects both forges into the GitHub event shape
# (.event / .label.name) — Forgejo's raw timeline carries neither. Capture
# its status BEFORE jq: a pipeline's status is the last command's, so
# `forge_timeline | jq` would collapse an unreadable timeline into an empty
# one, and those are the two states this function exists to tell apart
# (#188, and lib/ruling.sh does the identical dance).
if ! timeline="$(forge_timeline "$n" 2>/dev/null)"; then
log "#$n: attention timeline unreadable — no verdict invented this pass"
return 0
fi
labeled_events="$(jq -r '.[]
| select(.event == "labeled" and .label.name == "attention")
| .created_at' <<<"$timeline")"
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="$(forge_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." ;;
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." ;;
esac
run forge_issue_comment "$n" "$body" >/dev/null
log "#$n: malformed attention ($surface) — commented; no label or assignee changed"
}