Skip to content

Commit 8ec555e

Browse files
Merge pull request #136 from script-development/ci/crier-2approval-consensus
ci(town-crier): auto-resolve bus thread on 2-approval consensus
2 parents 0aa398a + 1b8862e commit 8ec555e

1 file changed

Lines changed: 72 additions & 12 deletions

File tree

.github/workflows/announce-pr.yml

Lines changed: 72 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,28 +4,35 @@
44
# - Variable TOWN_CRIER_URL = https://<app>.fly.dev
55
# - Secret TOWN_CRIER_TOKEN = the bearer token minted for "github-action"
66
#
7-
# Produces BOTH sides of a request's lifecycle, so the bus never drifts from GitHub:
8-
# - announce: when "Agent Review Requested" lands on a PR, tell the crier once.
9-
# - resolve: when that PR closes/merges or its review label is removed, retire its
10-
# thread — otherwise a landed PR sits "open" on the bus forever (there is
11-
# no GitHub->bus merge sync).
7+
# Produces the FULL lifecycle of a request, so the bus never drifts from GitHub:
8+
# - announce: "Agent Review Requested" lands on a PR (or a new commit is pushed) -> tell the crier.
9+
# - resolve (close): the PR closes/merges or its review label is removed -> retire its thread.
10+
# - resolve (consensus): a 2nd independent approval lands with no outstanding change request -> retire the
11+
# thread; consensus is reached, so further review turns are just churn.
12+
# Without these a landed/settled PR sits "open" on the bus forever (there is no GitHub->bus merge sync).
13+
# The consensus job counts only approvals cast at the CURRENT head, so a stale approval from an earlier push
14+
# can't satisfy consensus on a reopened thread — robust regardless of the repo's dismiss-stale-reviews setting.
15+
# The bus never stores approval state; the job reads the live count and calls the EXISTING resolve verb.
1216
# Joined harnesses pick up open requests from the bus — this workflow does NOT poll or review.
1317
#
1418
# Failure policy (two distinct modes, so a real problem is never silently masked):
1519
# - MISSING provisioning (no TOWN_CRIER_URL/TOKEN) is a config error -> fail LOUD (red).
16-
# - A bus HICCUP (cold start / transient 5xx / timeout) -> ::warning:: + stay GREEN (fail-open;
17-
# a coordination-layer blip must never red a contributor's PR checks).
18-
# Neither job uses the GITHUB_TOKEN (they auth to the bus with TOWN_CRIER_TOKEN), so permissions
19-
# are dropped to nothing.
20-
name: town-crier producer (announce + resolve)
20+
# - A bus HICCUP (cold start / transient 5xx / timeout) or an unreadable review list -> ::warning:: +
21+
# stay GREEN (fail-open; a coordination-layer blip must never red a contributor's PR checks).
22+
# The announce/resolve jobs use no GITHUB_TOKEN (they auth to the bus with TOWN_CRIER_TOKEN); the consensus
23+
# job needs pull-requests:read to count approvals, granted at job scope only.
24+
name: town-crier producer (announce + resolve + consensus)
2125

2226
permissions: {}
2327

2428
on:
2529
pull_request:
26-
# labeled -> first announce; synchronize -> re-announce each new commit (re-review on push);
27-
# closed/unlabeled -> resolve (de-announce). A synchronize on an unlabelled PR is ignored by the if.
30+
# labeled -> first announce; synchronize -> re-announce each new commit (re-review on push);
31+
# unlabeled/closed -> resolve (de-announce). A synchronize on an unlabelled PR is ignored by the if.
2832
types: [labeled, synchronize, unlabeled, closed]
33+
pull_request_review:
34+
# submitted -> a review landed; the consensus job checks whether 2 independent approvals now agree.
35+
types: [submitted]
2936

3037
jobs:
3138
announce:
@@ -93,3 +100,56 @@ jobs:
93100
-H "Content-Type: application/json" \
94101
-d "$(jq -n --arg pr "$PR_URL" --arg note "$NOTE" '{pr_url:$pr, note:$note}')" \
95102
|| echo "::warning::town-crier resolve failed (transient bus issue?) — not blocking the PR"
103+
104+
consensus:
105+
# A review landed — if it's an approval on a labelled PR, retire the bus thread once TWO independent
106+
# approvals agree with no outstanding change request. We read the live count (filtered to the current
107+
# head, so stale approvals can't satisfy consensus) rather than tracking it on the bus.
108+
if: >-
109+
github.event.review.state == 'approved' &&
110+
contains(github.event.pull_request.labels.*.name, 'Agent Review Requested')
111+
runs-on: ubuntu-latest
112+
permissions:
113+
pull-requests: read # read the PR's reviews to count approvals (GITHUB_TOKEN, this job only)
114+
steps:
115+
- name: Resolve on consensus
116+
env:
117+
CRIER_URL: ${{ vars.TOWN_CRIER_URL }}
118+
CRIER_TOKEN: ${{ secrets.TOWN_CRIER_TOKEN }}
119+
GH_TOKEN: ${{ github.token }}
120+
REPO: ${{ github.repository }}
121+
PR_NUMBER: ${{ github.event.pull_request.number }}
122+
PR_URL: ${{ github.event.pull_request.html_url }}
123+
HEAD_OID: ${{ github.event.pull_request.head.sha }}
124+
run: |
125+
if [ -z "$CRIER_URL" ] || [ -z "$CRIER_TOKEN" ]; then
126+
echo "::error::town-crier not provisioned — set the TOWN_CRIER_URL variable + TOWN_CRIER_TOKEN secret"
127+
exit 1
128+
fi
129+
# Tally the LATEST opinionated review per reviewer (ignore COMMENTED/PENDING), counting an
130+
# approval ONLY if it was cast at the CURRENT head — a stale approval from an earlier push must
131+
# not satisfy consensus on a reopened thread (correct regardless of dismiss_stale_reviews).
132+
# NDJSON via --paginate --jq '.[]' then slurp with jq -s — avoids `gh api --slurp` (newer-gh only).
133+
tally="$(gh api --paginate "repos/$REPO/pulls/$PR_NUMBER/reviews" --jq '.[]' \
134+
| jq -s --arg head "$HEAD_OID" '
135+
map(select(.state == "APPROVED" or .state == "CHANGES_REQUESTED" or .state == "DISMISSED"))
136+
| group_by(.user.login) | map(max_by(.submitted_at))
137+
| {approved: (map(select(.state == "APPROVED" and .commit_id == $head)) | length),
138+
changes: (map(select(.state == "CHANGES_REQUESTED")) | length),
139+
who: (map(select(.state == "APPROVED" and .commit_id == $head)) | map(.user.login) | join(", "))}' \
140+
)" || { echo "::warning::could not read PR reviews (transient?) — not blocking the PR"; exit 0; }
141+
approved="$(printf '%s' "$tally" | jq -r '.approved')"
142+
changes="$(printf '%s' "$tally" | jq -r '.changes')"
143+
who="$(printf '%s' "$tally" | jq -r '.who')"
144+
echo "approvals=$approved changes_requested=$changes approvers=[$who]"
145+
# Consensus = >= 2 distinct CURRENT-HEAD approvals AND no outstanding change request.
146+
if [ "${approved:-0}" -lt 2 ] || [ "${changes:-0}" -ne 0 ]; then
147+
echo "no consensus yet — leaving the thread open for another turn"
148+
exit 0
149+
fi
150+
NOTE="consensus: ${approved} approvals (${who}) @${HEAD_OID:0:12}"
151+
curl -fsS --max-time 10 -X POST "$CRIER_URL/resolve" \
152+
-H "Authorization: Bearer $CRIER_TOKEN" \
153+
-H "Content-Type: application/json" \
154+
-d "$(jq -n --arg pr "$PR_URL" --arg note "$NOTE" '{pr_url:$pr, note:$note}')" \
155+
|| echo "::warning::town-crier consensus-resolve failed (transient bus issue?) — not blocking the PR"

0 commit comments

Comments
 (0)