diff --git a/.github/workflows/announce-pr.yml b/.github/workflows/announce-pr.yml index 4d3802c..c3e6809 100644 --- a/.github/workflows/announce-pr.yml +++ b/.github/workflows/announce-pr.yml @@ -4,19 +4,24 @@ # - Variable TOWN_CRIER_URL = https://.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: {} @@ -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: @@ -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), + 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"