Skip to content

Commit 48617f0

Browse files
authored
Merge pull request #230 from ChBrain/governance/prose-review-on-comment
feat: on-demand prose review via PR comment commands
2 parents cc75f33 + d2583bc commit 48617f0

3 files changed

Lines changed: 443 additions & 1 deletion

File tree

Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
name: prose-review-on-comment
2+
3+
# On-demand prose naturalness review, triggered by a `/review` PR comment.
4+
# The automatic prose-review.yml workflow is unaffected and still runs.
5+
#
6+
# Security model: issue_comment workflows always run from the default branch
7+
# with a write token and repo secrets. This workflow is therefore gated to
8+
# 1. non-fork PRs only (resolved via the API), and
9+
# 2. repo OWNER / MEMBER / COLLABORATOR actors.
10+
# Both gates together mean the actor already has write access, so running the
11+
# PR-head copy of scripts/prose_review.py carries no extra privilege.
12+
13+
on:
14+
issue_comment:
15+
types: [created]
16+
17+
permissions:
18+
contents: read
19+
issues: write
20+
pull-requests: write
21+
22+
concurrency:
23+
group: prose-review-comment-${{ github.event.issue.number }}
24+
cancel-in-progress: true
25+
26+
jobs:
27+
review:
28+
# Cheap pre-filter: a PR comment that looks like the command.
29+
if: >-
30+
github.event.issue.pull_request &&
31+
startsWith(github.event.comment.body, '/review')
32+
runs-on: ubuntu-latest
33+
steps:
34+
- name: Acknowledge command
35+
id: ack
36+
uses: actions/github-script@v7
37+
with:
38+
script: |
39+
const { data } = await github.rest.reactions.createForIssueComment({
40+
owner: context.repo.owner,
41+
repo: context.repo.repo,
42+
comment_id: context.payload.comment.id,
43+
content: 'eyes',
44+
});
45+
core.setOutput('reaction_id', data.id);
46+
47+
- name: Gate and resolve PR
48+
id: gate
49+
uses: actions/github-script@v7
50+
with:
51+
script: |
52+
const prNumber = context.issue.number;
53+
const allowed = ['OWNER', 'MEMBER', 'COLLABORATOR'];
54+
55+
const deny = async (msg) => {
56+
await github.rest.issues.createComment({
57+
owner: context.repo.owner,
58+
repo: context.repo.repo,
59+
issue_number: prNumber,
60+
body: msg,
61+
});
62+
core.setOutput('proceed', 'false');
63+
};
64+
65+
if (!allowed.includes(context.payload.comment.author_association)) {
66+
await deny('Sorry @' + context.payload.comment.user.login +
67+
' — on-demand prose review is limited to repo members and ' +
68+
'collaborators.');
69+
return;
70+
}
71+
72+
const { data: pr } = await github.rest.pulls.get({
73+
owner: context.repo.owner,
74+
repo: context.repo.repo,
75+
pull_number: prNumber,
76+
});
77+
78+
if (pr.head.repo.full_name !== pr.base.repo.full_name) {
79+
await deny('On-demand prose review is available on branch PRs ' +
80+
'only (not forks). The automatic prose review still runs on ' +
81+
'this PR.');
82+
return;
83+
}
84+
85+
core.setOutput('proceed', 'true');
86+
core.setOutput('head_sha', pr.head.sha);
87+
core.setOutput('base_ref', pr.base.ref);
88+
89+
- name: Checkout PR head
90+
if: steps.gate.outputs.proceed == 'true'
91+
uses: actions/checkout@v4
92+
with:
93+
ref: ${{ steps.gate.outputs.head_sha }}
94+
fetch-depth: 0
95+
96+
- name: Set up Python
97+
if: steps.gate.outputs.proceed == 'true'
98+
uses: actions/setup-python@v5
99+
with:
100+
python-version: '3.11'
101+
102+
- name: Install deps
103+
if: steps.gate.outputs.proceed == 'true'
104+
run: pip install pyyaml
105+
106+
- name: Resolve review scope
107+
id: scope
108+
if: steps.gate.outputs.proceed == 'true'
109+
env:
110+
COMMENT_BODY: ${{ github.event.comment.body }}
111+
BASE_REF: ${{ steps.gate.outputs.base_ref }}
112+
run: |
113+
set -euo pipefail
114+
115+
PARSED=$(printf '%s' "$COMMENT_BODY" | python3 scripts/parse_review_command.py)
116+
echo "Parsed command: $PARSED"
117+
118+
MODE=$(printf '%s' "$PARSED" | python3 -c 'import json,sys; print(json.load(sys.stdin)["mode"])')
119+
echo "mode=$MODE" >> "$GITHUB_OUTPUT"
120+
121+
# Parser-level errors (bad syntax / unsafe paths) — stop with a message.
122+
ERRORS=$(printf '%s' "$PARSED" | python3 -c 'import json,sys; print("\n".join(json.load(sys.stdin)["errors"]))')
123+
if [ -n "$ERRORS" ]; then
124+
{ echo "errors<<EOF"; printf '%s\n' "$ERRORS"; echo "EOF"; } >> "$GITHUB_OUTPUT"
125+
echo "any=false" >> "$GITHUB_OUTPUT"
126+
exit 0
127+
fi
128+
129+
if [ "$MODE" = "none" ] || [ "$MODE" = "help" ]; then
130+
echo "any=false" >> "$GITHUB_OUTPUT"
131+
exit 0
132+
fi
133+
134+
if [ "$MODE" = "changed" ]; then
135+
git fetch --no-tags origin "$BASE_REF"
136+
FILES=$(git diff --name-only --diff-filter=ACMRT "origin/$BASE_REF...HEAD" \
137+
| grep -E '^regions/.*/culture_.*\.md$' || true)
138+
else
139+
# file / files — paths already validated as safe culture_*.md by the parser.
140+
FILES=$(printf '%s' "$PARSED" | python3 -c 'import json,sys; print("\n".join(json.load(sys.stdin)["files"]))')
141+
fi
142+
143+
# Keep only files that exist in the PR head tree.
144+
: > /tmp/review_files.txt
145+
MISSING=""
146+
while IFS= read -r f; do
147+
[ -z "$f" ] && continue
148+
if [ -f "$f" ]; then
149+
echo "$f" >> /tmp/review_files.txt
150+
else
151+
MISSING="${MISSING}- ${f}"$'\n'
152+
fi
153+
done <<< "$FILES"
154+
155+
if [ -n "$MISSING" ]; then
156+
{
157+
echo "errors<<EOF"
158+
echo "These files were not found in the PR branch:"
159+
printf '%s' "$MISSING"
160+
echo "EOF"
161+
} >> "$GITHUB_OUTPUT"
162+
echo "any=false" >> "$GITHUB_OUTPUT"
163+
exit 0
164+
fi
165+
166+
if [ -s /tmp/review_files.txt ]; then
167+
echo "any=true" >> "$GITHUB_OUTPUT"
168+
else
169+
echo "any=false" >> "$GITHUB_OUTPUT"
170+
fi
171+
172+
- name: Post help
173+
if: steps.gate.outputs.proceed == 'true' && steps.scope.outputs.mode == 'help'
174+
uses: actions/github-script@v7
175+
with:
176+
script: |
177+
const body = [
178+
'**Prose review commands**',
179+
'',
180+
'- `/review prose changed` — review changed `culture_*.md` files in this PR',
181+
'- `/review prose file <path>` — review one culture file',
182+
'- `/review prose files <pathA> <pathB> ...` — review several',
183+
'- `/review help` — show this message',
184+
'',
185+
'_Advisory only — does not block merge. ' +
186+
'Repo members & collaborators only._',
187+
].join('\n');
188+
await github.rest.issues.createComment({
189+
owner: context.repo.owner,
190+
repo: context.repo.repo,
191+
issue_number: context.issue.number,
192+
body,
193+
});
194+
195+
- name: Post command errors
196+
if: steps.gate.outputs.proceed == 'true' && steps.scope.outputs.errors != ''
197+
uses: actions/github-script@v7
198+
env:
199+
ERRORS: ${{ steps.scope.outputs.errors }}
200+
with:
201+
script: |
202+
const lines = process.env.ERRORS.split('\n').filter(Boolean);
203+
const body = '⚠️ Could not run the prose review:\n\n' +
204+
lines.map((e) => (e.startsWith('-') ? e : '- ' + e)).join('\n') +
205+
'\n\nRun `/review help` for the command syntax.';
206+
await github.rest.issues.createComment({
207+
owner: context.repo.owner,
208+
repo: context.repo.repo,
209+
issue_number: context.issue.number,
210+
body,
211+
});
212+
213+
- name: Post nothing-to-review
214+
if: >-
215+
steps.gate.outputs.proceed == 'true' &&
216+
steps.scope.outputs.errors == '' &&
217+
steps.scope.outputs.any == 'false' &&
218+
steps.scope.outputs.mode != 'none' &&
219+
steps.scope.outputs.mode != 'help'
220+
uses: actions/github-script@v7
221+
with:
222+
script: |
223+
await github.rest.issues.createComment({
224+
owner: context.repo.owner,
225+
repo: context.repo.repo,
226+
issue_number: context.issue.number,
227+
body: 'No `culture_*.md` files in scope to review.',
228+
});
229+
230+
- name: Run prose review
231+
if: steps.gate.outputs.proceed == 'true' && steps.scope.outputs.any == 'true'
232+
env:
233+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
234+
run: |
235+
set -euo pipefail
236+
mapfile -t FILES < /tmp/review_files.txt
237+
python3 scripts/prose_review.py "${FILES[@]}" > /tmp/review.md
238+
cat /tmp/review.md
239+
240+
- name: Post review result
241+
if: steps.gate.outputs.proceed == 'true' && steps.scope.outputs.any == 'true'
242+
uses: actions/github-script@v7
243+
with:
244+
script: |
245+
const fs = require('fs');
246+
const review = fs.readFileSync('/tmp/review.md', 'utf8');
247+
const files = fs.readFileSync('/tmp/review_files.txt', 'utf8')
248+
.split('\n').filter(Boolean);
249+
const header =
250+
'**On-demand prose review** — requested by @' +
251+
context.payload.comment.user.login + '\n\n' +
252+
'Files reviewed (' + files.length + '):\n' +
253+
files.map((f) => '- `' + f + '`').join('\n') + '\n\n---\n\n';
254+
await github.rest.issues.createComment({
255+
owner: context.repo.owner,
256+
repo: context.repo.repo,
257+
issue_number: context.issue.number,
258+
body: header + review,
259+
});
260+
261+
- name: Finish reaction
262+
if: steps.gate.outputs.proceed == 'true'
263+
uses: actions/github-script@v7
264+
with:
265+
script: |
266+
await github.rest.reactions.createForIssueComment({
267+
owner: context.repo.owner,
268+
repo: context.repo.repo,
269+
comment_id: context.payload.comment.id,
270+
content: 'rocket',
271+
});
272+
const rid = '${{ steps.ack.outputs.reaction_id }}';
273+
if (rid) {
274+
await github.rest.reactions.deleteForIssueComment({
275+
owner: context.repo.owner,
276+
repo: context.repo.repo,
277+
comment_id: context.payload.comment.id,
278+
reaction_id: Number(rid),
279+
});
280+
}

.validation-stamp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
45e551c1dfbc4d14413fac72f47ba3ea9300c90e
1+
1d509f10731cf6c7cb9e8dd46019d62b6c7d008c

0 commit comments

Comments
 (0)