Skip to content

Commit f67631d

Browse files
chore: Add PR validation workflow (#618)
Automatically validates non-maintainer PRs by checking: - Issue reference exists in PR body - Referenced issue has discussion between author and maintainer - Referenced issue is not assigned to someone else Also enforces that all PRs start as drafts. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 91a0ff9 commit f67631d

File tree

1 file changed

+327
-0
lines changed

1 file changed

+327
-0
lines changed

.github/workflows/validate-pr.yml

Lines changed: 327 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,327 @@
1+
name: Validate PR
2+
3+
on:
4+
pull_request_target:
5+
types: [opened, reopened]
6+
7+
jobs:
8+
validate-non-maintainer-pr:
9+
name: Validate Non-Maintainer PR
10+
runs-on: ubuntu-24.04
11+
permissions:
12+
pull-requests: write
13+
contents: write
14+
outputs:
15+
was-closed: ${{ steps.validate.outputs.was-closed }}
16+
steps:
17+
- name: Generate GitHub App token
18+
id: app-token
19+
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v2
20+
with:
21+
app-id: ${{ vars.SDK_MAINTAINER_BOT_APP_ID }}
22+
private-key: ${{ secrets.SDK_MAINTAINER_BOT_PRIVATE_KEY }}
23+
24+
- name: Validate PR
25+
id: validate
26+
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
27+
with:
28+
github-token: ${{ steps.app-token.outputs.token }}
29+
script: |
30+
const pullRequest = context.payload.pull_request;
31+
const repo = context.repo;
32+
const prAuthor = pullRequest.user.login;
33+
const contributingUrl = `https://github.com/${repo.owner}/${repo.repo}/blob/${context.payload.repository.default_branch}/CONTRIBUTING.md`;
34+
35+
// --- Helper: check if a user has admin or maintain permission on a repo (cached) ---
36+
const maintainerCache = new Map();
37+
async function isMaintainer(owner, repoName, username) {
38+
const key = `${owner}/${repoName}:${username}`;
39+
if (maintainerCache.has(key)) return maintainerCache.get(key);
40+
let result = false;
41+
try {
42+
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
43+
owner,
44+
repo: repoName,
45+
username,
46+
});
47+
// permission field uses legacy values (admin/write/read/none) where
48+
// maintain maps to write. Use role_name for the actual role.
49+
result = ['admin', 'maintain'].includes(data.role_name);
50+
} catch {
51+
// noop — result stays false
52+
}
53+
maintainerCache.set(key, result);
54+
return result;
55+
}
56+
57+
// --- Step 1: Check if PR author is a maintainer (admin or maintain role) ---
58+
const authorIsMaintainer = await isMaintainer(repo.owner, repo.repo, prAuthor);
59+
if (authorIsMaintainer) {
60+
core.info(`PR author ${prAuthor} has admin/maintain access. Skipping.`);
61+
return;
62+
}
63+
core.info(`PR author ${prAuthor} is not a maintainer.`);
64+
65+
// --- Step 2: Parse issue references from PR body ---
66+
const body = pullRequest.body || '';
67+
68+
// Match all issue reference formats:
69+
// #123, Fixes #123, getsentry/repo#123, Fixes getsentry/repo#123
70+
// https://github.com/getsentry/repo/issues/123
71+
const issueRefs = [];
72+
const seen = new Set();
73+
74+
// Pattern 1: Full GitHub URLs
75+
const urlPattern = /https?:\/\/github\.com\/(getsentry)\/([\w.-]+)\/issues\/(\d+)/gi;
76+
for (const match of body.matchAll(urlPattern)) {
77+
const key = `${match[1]}/${match[2]}#${match[3]}`;
78+
if (!seen.has(key)) {
79+
seen.add(key);
80+
issueRefs.push({ owner: match[1], repo: match[2], number: parseInt(match[3]) });
81+
}
82+
}
83+
84+
// Pattern 2: Cross-repo references (getsentry/repo#123)
85+
const crossRepoPattern = /(?:(?:fix|fixes|fixed|close|closes|closed|resolve|resolves|resolved)\s+)?(getsentry)\/([\w.-]+)#(\d+)/gi;
86+
for (const match of body.matchAll(crossRepoPattern)) {
87+
const key = `${match[1]}/${match[2]}#${match[3]}`;
88+
if (!seen.has(key)) {
89+
seen.add(key);
90+
issueRefs.push({ owner: match[1], repo: match[2], number: parseInt(match[3]) });
91+
}
92+
}
93+
94+
// Pattern 3: Same-repo references (#123)
95+
// Negative lookbehind to avoid matching cross-repo refs or URLs already captured
96+
const sameRepoPattern = /(?:(?:fix|fixes|fixed|close|closes|closed|resolve|resolves|resolved)\s+)?(?<![/\w])#(\d+)/gi;
97+
for (const match of body.matchAll(sameRepoPattern)) {
98+
const key = `${repo.owner}/${repo.repo}#${match[1]}`;
99+
if (!seen.has(key)) {
100+
seen.add(key);
101+
issueRefs.push({ owner: repo.owner, repo: repo.repo, number: parseInt(match[1]) });
102+
}
103+
}
104+
105+
core.info(`Found ${issueRefs.length} issue reference(s): ${[...seen].join(', ')}`);
106+
107+
// --- Helper: close PR with comment and labels ---
108+
async function closePR(message, reasonLabel) {
109+
await github.rest.issues.addLabels({
110+
...repo,
111+
issue_number: pullRequest.number,
112+
labels: ['violating-contribution-guidelines', reasonLabel],
113+
});
114+
115+
await github.rest.issues.createComment({
116+
...repo,
117+
issue_number: pullRequest.number,
118+
body: message,
119+
});
120+
121+
await github.rest.pulls.update({
122+
...repo,
123+
pull_number: pullRequest.number,
124+
state: 'closed',
125+
});
126+
127+
core.setOutput('was-closed', 'true');
128+
}
129+
130+
// --- Step 3: No issue references ---
131+
if (issueRefs.length === 0) {
132+
core.info('No issue references found. Closing PR.');
133+
await closePR([
134+
'This PR has been automatically closed. All non-maintainer contributions must reference an existing GitHub issue.',
135+
'',
136+
'**Next steps:**',
137+
'1. Find or open an issue describing the problem or feature',
138+
'2. Discuss the approach with a maintainer in the issue',
139+
'3. Once a maintainer has acknowledged your proposed approach, open a new PR referencing the issue',
140+
'',
141+
`Please review our [contributing guidelines](${contributingUrl}) for more details.`,
142+
].join('\n'), 'missing-issue-reference');
143+
return;
144+
}
145+
146+
// --- Step 4: Validate each referenced issue ---
147+
// A PR is valid if ANY referenced issue passes all checks.
148+
let hasAssigneeConflict = false;
149+
let hasNoDiscussion = false;
150+
151+
for (const ref of issueRefs) {
152+
core.info(`Checking issue ${ref.owner}/${ref.repo}#${ref.number}...`);
153+
154+
let issue;
155+
try {
156+
const { data } = await github.rest.issues.get({
157+
owner: ref.owner,
158+
repo: ref.repo,
159+
issue_number: ref.number,
160+
});
161+
issue = data;
162+
} catch (e) {
163+
core.warning(`Could not fetch issue ${ref.owner}/${ref.repo}#${ref.number}: ${e.message}`);
164+
continue;
165+
}
166+
167+
// Check assignee: if assigned to someone other than PR author, flag it
168+
if (issue.assignees && issue.assignees.length > 0) {
169+
const assignedToAuthor = issue.assignees.some(a => a.login === prAuthor);
170+
if (!assignedToAuthor) {
171+
core.info(`Issue ${ref.owner}/${ref.repo}#${ref.number} is assigned to someone else.`);
172+
hasAssigneeConflict = true;
173+
continue;
174+
}
175+
}
176+
177+
// Check discussion: both PR author and a maintainer must have commented
178+
const comments = await github.paginate(github.rest.issues.listComments, {
179+
owner: ref.owner,
180+
repo: ref.repo,
181+
issue_number: ref.number,
182+
per_page: 100,
183+
});
184+
185+
// Also consider the issue author as a participant (opening the issue is a form of discussion)
186+
// Guard against null user (deleted/suspended GitHub accounts)
187+
const prAuthorParticipated =
188+
issue.user?.login === prAuthor ||
189+
comments.some(c => c.user?.login === prAuthor);
190+
191+
let maintainerParticipated = false;
192+
if (prAuthorParticipated) {
193+
// Check each commenter (and issue author) for admin/maintain access on the issue's repo
194+
const usersToCheck = new Set();
195+
if (issue.user?.login) usersToCheck.add(issue.user.login);
196+
for (const comment of comments) {
197+
if (comment.user?.login && comment.user.login !== prAuthor) {
198+
usersToCheck.add(comment.user.login);
199+
}
200+
}
201+
202+
for (const user of usersToCheck) {
203+
if (user === prAuthor) continue;
204+
if (await isMaintainer(repo.owner, repo.repo, user)) {
205+
maintainerParticipated = true;
206+
core.info(`Maintainer ${user} participated in ${ref.owner}/${ref.repo}#${ref.number}.`);
207+
break;
208+
}
209+
}
210+
}
211+
212+
if (prAuthorParticipated && maintainerParticipated) {
213+
core.info(`Issue ${ref.owner}/${ref.repo}#${ref.number} has valid discussion. PR is allowed.`);
214+
return; // PR is valid — at least one issue passes all checks
215+
}
216+
217+
core.info(`Issue ${ref.owner}/${ref.repo}#${ref.number} lacks discussion between author and maintainer.`);
218+
hasNoDiscussion = true;
219+
}
220+
221+
// --- Step 5: No valid issue found — close with the most relevant reason ---
222+
if (hasAssigneeConflict) {
223+
core.info('Closing PR: referenced issue is assigned to someone else.');
224+
await closePR([
225+
'This PR has been automatically closed. The referenced issue is already assigned to someone else.',
226+
'',
227+
'If you believe this assignment is outdated, please comment on the issue to discuss before opening a new PR.',
228+
'',
229+
`Please review our [contributing guidelines](${contributingUrl}) for more details.`,
230+
].join('\n'), 'issue-already-assigned');
231+
return;
232+
}
233+
234+
if (hasNoDiscussion) {
235+
core.info('Closing PR: no discussion between PR author and a maintainer in the referenced issue.');
236+
await closePR([
237+
'This PR has been automatically closed. The referenced issue does not show a discussion between you and a maintainer.',
238+
'',
239+
'To avoid wasted effort on both sides, please discuss your proposed approach in the issue first and wait for a maintainer to respond before opening a PR.',
240+
'',
241+
`Please review our [contributing guidelines](${contributingUrl}) for more details.`,
242+
].join('\n'), 'missing-maintainer-discussion');
243+
return;
244+
}
245+
246+
// If we get here, all issue refs were unfetchable
247+
core.info('Could not validate any referenced issues. Closing PR.');
248+
await closePR([
249+
'This PR has been automatically closed. The referenced issue(s) could not be found.',
250+
'',
251+
'**Next steps:**',
252+
'1. Ensure the issue exists and is in a `getsentry` repository',
253+
'2. Discuss the approach with a maintainer in the issue',
254+
'3. Once a maintainer has acknowledged your proposed approach, open a new PR referencing the issue',
255+
'',
256+
`Please review our [contributing guidelines](${contributingUrl}) for more details.`,
257+
].join('\n'), 'missing-issue-reference');
258+
259+
enforce-draft:
260+
name: Enforce Draft PR
261+
needs: [validate-non-maintainer-pr]
262+
if: |
263+
always()
264+
&& github.event.pull_request.draft == false
265+
&& needs.validate-non-maintainer-pr.outputs.was-closed != 'true'
266+
runs-on: ubuntu-24.04
267+
permissions:
268+
pull-requests: write
269+
contents: write
270+
steps:
271+
- name: Generate GitHub App token
272+
id: app-token
273+
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v2
274+
with:
275+
app-id: ${{ vars.SDK_MAINTAINER_BOT_APP_ID }}
276+
private-key: ${{ secrets.SDK_MAINTAINER_BOT_PRIVATE_KEY }}
277+
278+
- name: Convert PR to draft
279+
env:
280+
GH_TOKEN: ${{github.token}}
281+
PR_URL: ${{ github.event.pull_request.html_url }}
282+
run: |
283+
gh pr ready "$PR_URL" --undo
284+
285+
- name: Label and comment
286+
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
287+
with:
288+
github-token: ${{ steps.app-token.outputs.token }}
289+
script: |
290+
const pullRequest = context.payload.pull_request;
291+
const repo = context.repo;
292+
293+
// Label the PR so maintainers can filter/track violations
294+
await github.rest.issues.addLabels({
295+
...repo,
296+
issue_number: pullRequest.number,
297+
labels: ['converted-to-draft'],
298+
});
299+
300+
// Check for existing bot comment to avoid duplicates on reopen
301+
const comments = await github.rest.issues.listComments({
302+
...repo,
303+
issue_number: pullRequest.number,
304+
});
305+
const botComment = comments.data.find(c =>
306+
c.user.type === 'Bot' &&
307+
c.body.includes('automatically converted to draft')
308+
);
309+
if (botComment) {
310+
core.info('Bot comment already exists, skipping.');
311+
return;
312+
}
313+
314+
const contributingUrl = `https://github.com/${repo.owner}/${repo.repo}/blob/${context.payload.repository.default_branch}/CONTRIBUTING.md`;
315+
316+
await github.rest.issues.createComment({
317+
...repo,
318+
issue_number: pullRequest.number,
319+
body: [
320+
`This PR has been automatically converted to draft. All PRs must start as drafts per our [contributing guidelines](${contributingUrl}).`,
321+
'',
322+
'**Next steps:**',
323+
'1. Ensure CI passes',
324+
'2. Fill in the PR description completely',
325+
'3. Mark as "Ready for review" when you\'re done'
326+
].join('\n')
327+
});

0 commit comments

Comments
 (0)