Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


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

permissions: {}

Expand All @@ -25,6 +30,9 @@ on:
# labeled -> first announce; synchronize -> re-announce each new commit (re-review on push);
# unlabeled/closed -> resolve (de-announce). A synchronize on an unlabelled PR is ignored by the if.
types: [labeled, synchronize, unlabeled, closed]
pull_request_review:
# submitted -> a review landed; the consensus job checks whether 2 independent approvals now agree.
types: [submitted]

jobs:
announce:
Expand Down Expand Up @@ -92,3 +100,56 @@ jobs:
-H "Content-Type: application/json" \
-d "$(jq -n --arg pr "$PR_URL" --arg note "$NOTE" '{pr_url:$pr, note:$note}')" \
|| echo "::warning::town-crier resolve failed (transient bus issue?) — not blocking the PR"

consensus:
# A review landed — if it's an approval on a labelled PR, retire the bus thread once TWO independent
# approvals agree with no outstanding change request. We read the live count (filtered to the current
# head, so stale approvals can't satisfy consensus) rather than tracking it on the bus.
if: >-
github.event.review.state == 'approved' &&
contains(github.event.pull_request.labels.*.name, 'Agent Review Requested')
runs-on: ubuntu-latest
permissions:
pull-requests: read # read the PR's reviews to count approvals (GITHUB_TOKEN, this job only)
steps:
- name: Resolve on consensus
env:
CRIER_URL: ${{ vars.TOWN_CRIER_URL }}
CRIER_TOKEN: ${{ secrets.TOWN_CRIER_TOKEN }}
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_URL: ${{ github.event.pull_request.html_url }}
HEAD_OID: ${{ github.event.pull_request.head.sha }}
run: |
if [ -z "$CRIER_URL" ] || [ -z "$CRIER_TOKEN" ]; then
echo "::error::town-crier not provisioned — set the TOWN_CRIER_URL variable + TOWN_CRIER_TOKEN secret"
exit 1
fi
# Tally the LATEST opinionated review per reviewer (ignore COMMENTED/PENDING), counting an
# approval ONLY if it was cast at the CURRENT head — a stale approval from an earlier push must
# not satisfy consensus on a reopened thread (correct regardless of dismiss_stale_reviews).
# NDJSON via --paginate --jq '.[]' then slurp with jq -s — avoids `gh api --slurp` (newer-gh only).
tally="$(gh api --paginate "repos/$REPO/pulls/$PR_NUMBER/reviews" --jq '.[]' \
| jq -s --arg head "$HEAD_OID" '
map(select(.state == "APPROVED" or .state == "CHANGES_REQUESTED" or .state == "DISMISSED"))
| group_by(.user.login) | map(max_by(.submitted_at))
| {approved: (map(select(.state == "APPROVED" and .commit_id == $head)) | length),
changes: (map(select(.state == "CHANGES_REQUESTED")) | length),
Comment thread
jasperboerhof marked this conversation as resolved.
who: (map(select(.state == "APPROVED" and .commit_id == $head)) | map(.user.login) | join(", "))}' \
)" || { echo "::warning::could not read PR reviews (transient?) — not blocking the PR"; exit 0; }
approved="$(printf '%s' "$tally" | jq -r '.approved')"
changes="$(printf '%s' "$tally" | jq -r '.changes')"
who="$(printf '%s' "$tally" | jq -r '.who')"
echo "approvals=$approved changes_requested=$changes approvers=[$who]"
# Consensus = >= 2 distinct CURRENT-HEAD approvals AND no outstanding change request.
if [ "${approved:-0}" -lt 2 ] || [ "${changes:-0}" -ne 0 ]; then
echo "no consensus yet — leaving the thread open for another turn"
exit 0
fi
NOTE="consensus: ${approved} approvals (${who}) @${HEAD_OID:0:12}"
curl -fsS --max-time 10 -X POST "$CRIER_URL/resolve" \
-H "Authorization: Bearer $CRIER_TOKEN" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg pr "$PR_URL" --arg note "$NOTE" '{pr_url:$pr, note:$note}')" \
|| echo "::warning::town-crier consensus-resolve failed (transient bus issue?) — not blocking the PR"