@@ -26,130 +26,130 @@ name: town-crier producer (announce + resolve + consensus)
2626permissions : {}
2727
2828on :
29- pull_request :
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.
32- 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]
29+ pull_request :
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.
32+ 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]
3636
3737jobs :
38- announce :
39- if : >-
40- (github.event.action == 'labeled' && github.event.label.name == 'Agent Review Requested') ||
41- (github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'Agent Review Requested'))
42- runs-on : ubuntu-latest
43- steps :
44- - name : Announce to the crier
45- env :
46- CRIER_URL : ${{ vars.TOWN_CRIER_URL }}
47- CRIER_TOKEN : ${{ secrets.TOWN_CRIER_TOKEN }}
48- PR_URL : ${{ github.event.pull_request.html_url }}
49- REPO : ${{ github.repository }}
50- TITLE : ${{ github.event.pull_request.title }}
51- REQUESTER : ${{ github.event.pull_request.user.login }}
52- # The head SHA is the change detector: a re-announce with the same head is a no-op on the
53- # bus; a new head reopens the thread for a fresh review round.
54- HEAD_OID : ${{ github.event.pull_request.head.sha }}
55- run : |
56- # Missing provisioning is a config error — fail LOUD so it can't pass silently.
57- if [ -z "$CRIER_URL" ] || [ -z "$CRIER_TOKEN" ]; then
58- echo "::error::town-crier not provisioned — set the TOWN_CRIER_URL variable + TOWN_CRIER_TOKEN secret"
59- exit 1
60- fi
61- # jq builds the JSON so a PR title with quotes can't break the payload.
62- # A bus hiccup is not the PR's fault — degrade to a warning, keep the check green.
63- curl -fsS --max-time 10 -X POST "$CRIER_URL/announce" \
64- -H "Authorization: Bearer $CRIER_TOKEN" \
65- -H "Content-Type: application/json" \
66- -d "$(jq -n \
67- --arg pr "$PR_URL" \
68- --arg repo "$REPO" \
69- --arg title "$TITLE" \
70- --arg requester "$REQUESTER" \
71- --arg head_oid "$HEAD_OID" \
72- '{pr_url:$pr, repo:$repo, title:$title, requester:$requester, head_oid:$head_oid}')" \
73- || echo "::warning::town-crier announce failed (transient bus issue?) — not blocking the PR"
38+ announce :
39+ if : >-
40+ (github.event.action == 'labeled' && github.event.label.name == 'Agent Review Requested') ||
41+ (github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'Agent Review Requested'))
42+ runs-on : ubuntu-latest
43+ steps :
44+ - name : Announce to the crier
45+ env :
46+ CRIER_URL : ${{ vars.TOWN_CRIER_URL }}
47+ CRIER_TOKEN : ${{ secrets.TOWN_CRIER_TOKEN }}
48+ PR_URL : ${{ github.event.pull_request.html_url }}
49+ REPO : ${{ github.repository }}
50+ TITLE : ${{ github.event.pull_request.title }}
51+ REQUESTER : ${{ github.event.pull_request.user.login }}
52+ # The head SHA is the change detector: a re-announce with the same head is a no-op on the
53+ # bus; a new head reopens the thread for a fresh review round.
54+ HEAD_OID : ${{ github.event.pull_request.head.sha }}
55+ run : |
56+ # Missing provisioning is a config error — fail LOUD so it can't pass silently.
57+ if [ -z "$CRIER_URL" ] || [ -z "$CRIER_TOKEN" ]; then
58+ echo "::error::town-crier not provisioned — set the TOWN_CRIER_URL variable + TOWN_CRIER_TOKEN secret"
59+ exit 1
60+ fi
61+ # jq builds the JSON so a PR title with quotes can't break the payload.
62+ # A bus hiccup is not the PR's fault — degrade to a warning, keep the check green.
63+ curl -fsS --max-time 10 -X POST "$CRIER_URL/announce" \
64+ -H "Authorization: Bearer $CRIER_TOKEN" \
65+ -H "Content-Type: application/json" \
66+ -d "$(jq -n \
67+ --arg pr "$PR_URL" \
68+ --arg repo "$REPO" \
69+ --arg title "$TITLE" \
70+ --arg requester "$REQUESTER" \
71+ --arg head_oid "$HEAD_OID" \
72+ '{pr_url:$pr, repo:$repo, title:$title, requester:$requester, head_oid:$head_oid}')" \
73+ || echo "::warning::town-crier announce failed (transient bus issue?) — not blocking the PR"
7474
75- resolve :
76- if : (github.event.action == 'closed' && contains(github.event.pull_request.labels.*.name, 'Agent Review Requested')) || (github.event.action == 'unlabeled' && github.event.label.name == 'Agent Review Requested')
77- runs-on : ubuntu-latest
78- steps :
79- - name : Resolve on the crier
80- env :
81- CRIER_URL : ${{ vars.TOWN_CRIER_URL }}
82- CRIER_TOKEN : ${{ secrets.TOWN_CRIER_TOKEN }}
83- PR_URL : ${{ github.event.pull_request.html_url }}
84- ACTION : ${{ github.event.action }}
85- MERGED : ${{ github.event.pull_request.merged }}
86- run : |
87- if [ -z "$CRIER_URL" ] || [ -z "$CRIER_TOKEN" ]; then
88- echo "::error::town-crier not provisioned — set the TOWN_CRIER_URL variable + TOWN_CRIER_TOKEN secret"
89- exit 1
90- fi
91- if [ "$ACTION" = "unlabeled" ]; then
92- NOTE="review label removed"
93- elif [ "$MERGED" = "true" ]; then
94- NOTE="merged"
95- else
96- NOTE="closed without merge"
97- fi
98- curl -fsS --max-time 10 -X POST "$CRIER_URL/resolve" \
99- -H "Authorization: Bearer $CRIER_TOKEN" \
100- -H "Content-Type: application/json" \
101- -d "$(jq -n --arg pr "$PR_URL" --arg note "$NOTE" '{pr_url:$pr, note:$note}')" \
102- || echo "::warning::town-crier resolve failed (transient bus issue?) — not blocking the PR"
75+ resolve :
76+ if : (github.event.action == 'closed' && contains(github.event.pull_request.labels.*.name, 'Agent Review Requested')) || (github.event.action == 'unlabeled' && github.event.label.name == 'Agent Review Requested')
77+ runs-on : ubuntu-latest
78+ steps :
79+ - name : Resolve on the crier
80+ env :
81+ CRIER_URL : ${{ vars.TOWN_CRIER_URL }}
82+ CRIER_TOKEN : ${{ secrets.TOWN_CRIER_TOKEN }}
83+ PR_URL : ${{ github.event.pull_request.html_url }}
84+ ACTION : ${{ github.event.action }}
85+ MERGED : ${{ github.event.pull_request.merged }}
86+ run : |
87+ if [ -z "$CRIER_URL" ] || [ -z "$CRIER_TOKEN" ]; then
88+ echo "::error::town-crier not provisioned — set the TOWN_CRIER_URL variable + TOWN_CRIER_TOKEN secret"
89+ exit 1
90+ fi
91+ if [ "$ACTION" = "unlabeled" ]; then
92+ NOTE="review label removed"
93+ elif [ "$MERGED" = "true" ]; then
94+ NOTE="merged"
95+ else
96+ NOTE="closed without merge"
97+ fi
98+ curl -fsS --max-time 10 -X POST "$CRIER_URL/resolve" \
99+ -H "Authorization: Bearer $CRIER_TOKEN" \
100+ -H "Content-Type: application/json" \
101+ -d "$(jq -n --arg pr "$PR_URL" --arg note "$NOTE" '{pr_url:$pr, note:$note}')" \
102+ || echo "::warning::town-crier resolve failed (transient bus issue?) — not blocking the PR"
103103
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"
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