2026-08-03 19:48:15 +00:00
|
|
|
#!/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
|
2026-08-03 19:48:15 +00:00
|
|
|
: "${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
|
2026-08-03 19:48:15 +00:00
|
|
|
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")"
|
2026-08-03 19:48:15 +00:00
|
|
|
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" \
|
2026-08-03 19:48:15 +00:00
|
|
|
--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
|
2026-08-03 19:53:25 +00:00
|
|
|
retargeting the label." ;;
|
2026-08-03 19:48:15 +00:00
|
|
|
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
|
2026-08-03 19:53:25 +00:00
|
|
|
bug without assigning anyone or changing the label." ;;
|
2026-08-03 19:48:15 +00:00
|
|
|
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
|
2026-08-03 19:48:15 +00:00
|
|
|
log "#$n: malformed attention ($surface) — commented; no label or assignee changed"
|
|
|
|
|
}
|