ceremony/lib/attention.sh

105 lines
4.2 KiB
Bash
Raw Normal View History

#!/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:-}"
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
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
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
# 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
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
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")"
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
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
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
run forge_issue_comment "$n" "$body" >/dev/null
log "#$n: malformed attention ($surface) — commented; no label or assignee changed"
}