Skip to content

Commit 87149d7

Browse files
ci: Add workflow to close unvetted non-maintainer PRs
Automatically closes PRs from non-maintainers that don't meet contribution requirements: must reference a getsentry issue with prior discussion between the PR author and a maintainer, and the issue must not be assigned to someone else. Adds the 'violating-contribution-guidelines' label and posts a reason-specific comment explaining next steps. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 11d8a78 commit 87149d7

File tree

1 file changed

+243
-0
lines changed

1 file changed

+243
-0
lines changed
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
name: Close Unvetted Non-Maintainer PRs
2+
3+
on:
4+
pull_request_target:
5+
types: [opened]
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+
steps:
15+
- name: Generate GitHub App token
16+
id: app-token
17+
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v2
18+
with:
19+
app-id: ${{ vars.SDK_MAINTAINER_BOT_APP_ID }}
20+
private-key: ${{ secrets.SDK_MAINTAINER_BOT_PRIVATE_KEY }}
21+
22+
- name: Validate PR
23+
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
24+
with:
25+
github-token: ${{ steps.app-token.outputs.token }}
26+
script: |
27+
const pullRequest = context.payload.pull_request;
28+
const repo = context.repo;
29+
const prAuthor = pullRequest.user.login;
30+
const contributingUrl = `https://github.com/${repo.owner}/${repo.repo}/blob/master/CONTRIBUTING.md`;
31+
32+
// --- Helper: check if a user has write+ permission on a repo ---
33+
async function hasWriteAccess(owner, repoName, username) {
34+
try {
35+
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
36+
owner,
37+
repo: repoName,
38+
username,
39+
});
40+
return ['admin', 'maintain', 'write'].includes(data.permission);
41+
} catch {
42+
return false;
43+
}
44+
}
45+
46+
// --- Step 1: Check if PR author is a maintainer ---
47+
const isMaintainer = await hasWriteAccess(repo.owner, repo.repo, prAuthor);
48+
if (isMaintainer) {
49+
core.info(`PR author ${prAuthor} has write+ access. Skipping.`);
50+
return;
51+
}
52+
core.info(`PR author ${prAuthor} is not a maintainer.`);
53+
54+
// --- Step 2: Parse issue references from PR body ---
55+
const body = pullRequest.body || '';
56+
57+
// Match all issue reference formats:
58+
// #123, Fixes #123, getsentry/repo#123, Fixes getsentry/repo#123
59+
// https://github.com/getsentry/repo/issues/123
60+
const issueRefs = [];
61+
const seen = new Set();
62+
63+
// Pattern 1: Full GitHub URLs
64+
const urlPattern = /https?:\/\/github\.com\/(getsentry)\/([\w.-]+)\/issues\/(\d+)/gi;
65+
for (const match of body.matchAll(urlPattern)) {
66+
const key = `${match[1]}/${match[2]}#${match[3]}`;
67+
if (!seen.has(key)) {
68+
seen.add(key);
69+
issueRefs.push({ owner: match[1], repo: match[2], number: parseInt(match[3]) });
70+
}
71+
}
72+
73+
// Pattern 2: Cross-repo references (getsentry/repo#123)
74+
const crossRepoPattern = /(?:(?:fix|fixes|fixed|close|closes|closed|resolve|resolves|resolved)\s+)?(getsentry)\/([\w.-]+)#(\d+)/gi;
75+
for (const match of body.matchAll(crossRepoPattern)) {
76+
const key = `${match[1]}/${match[2]}#${match[3]}`;
77+
if (!seen.has(key)) {
78+
seen.add(key);
79+
issueRefs.push({ owner: match[1], repo: match[2], number: parseInt(match[3]) });
80+
}
81+
}
82+
83+
// Pattern 3: Same-repo references (#123)
84+
// Negative lookbehind to avoid matching cross-repo refs or URLs already captured
85+
const sameRepoPattern = /(?:(?:fix|fixes|fixed|close|closes|closed|resolve|resolves|resolved)\s+)?(?<![/\w])#(\d+)/gi;
86+
for (const match of body.matchAll(sameRepoPattern)) {
87+
const key = `${repo.owner}/${repo.repo}#${match[1]}`;
88+
if (!seen.has(key)) {
89+
seen.add(key);
90+
issueRefs.push({ owner: repo.owner, repo: repo.repo, number: parseInt(match[1]) });
91+
}
92+
}
93+
94+
core.info(`Found ${issueRefs.length} issue reference(s): ${[...seen].join(', ')}`);
95+
96+
// --- Helper: close PR with comment and label ---
97+
async function closePR(message) {
98+
await github.rest.issues.addLabels({
99+
...repo,
100+
issue_number: pullRequest.number,
101+
labels: ['violating-contribution-guidelines'],
102+
});
103+
104+
await github.rest.issues.createComment({
105+
...repo,
106+
issue_number: pullRequest.number,
107+
body: message,
108+
});
109+
110+
await github.rest.pulls.update({
111+
...repo,
112+
pull_number: pullRequest.number,
113+
state: 'closed',
114+
});
115+
}
116+
117+
// --- Step 3: No issue references ---
118+
if (issueRefs.length === 0) {
119+
core.info('No issue references found. Closing PR.');
120+
await closePR([
121+
'This PR has been automatically closed. All non-maintainer contributions must reference an existing GitHub issue.',
122+
'',
123+
'**Next steps:**',
124+
'1. Find or open an issue describing the problem or feature',
125+
'2. Discuss the approach with a maintainer in the issue',
126+
'3. Once a maintainer has acknowledged your proposed approach, open a new PR referencing the issue',
127+
'',
128+
`Please review our [contributing guidelines](${contributingUrl}) for more details.`,
129+
].join('\n'));
130+
return;
131+
}
132+
133+
// --- Step 4: Validate each referenced issue ---
134+
// A PR is valid if ANY referenced issue passes all checks.
135+
let hasAssigneeConflict = false;
136+
let hasNoDiscussion = false;
137+
138+
for (const ref of issueRefs) {
139+
core.info(`Checking issue ${ref.owner}/${ref.repo}#${ref.number}...`);
140+
141+
let issue;
142+
try {
143+
const { data } = await github.rest.issues.get({
144+
owner: ref.owner,
145+
repo: ref.repo,
146+
issue_number: ref.number,
147+
});
148+
issue = data;
149+
} catch (e) {
150+
core.warning(`Could not fetch issue ${ref.owner}/${ref.repo}#${ref.number}: ${e.message}`);
151+
continue;
152+
}
153+
154+
// Check assignee: if assigned to someone other than PR author, flag it
155+
if (issue.assignees && issue.assignees.length > 0) {
156+
const assignedToAuthor = issue.assignees.some(a => a.login === prAuthor);
157+
if (!assignedToAuthor) {
158+
core.info(`Issue ${ref.owner}/${ref.repo}#${ref.number} is assigned to someone else.`);
159+
hasAssigneeConflict = true;
160+
continue;
161+
}
162+
}
163+
164+
// Check discussion: both PR author and a maintainer must have commented
165+
const comments = await github.paginate(github.rest.issues.listComments, {
166+
owner: ref.owner,
167+
repo: ref.repo,
168+
issue_number: ref.number,
169+
per_page: 100,
170+
});
171+
172+
// Also consider the issue author as a participant (opening the issue is a form of discussion)
173+
const prAuthorParticipated =
174+
issue.user.login === prAuthor ||
175+
comments.some(c => c.user.login === prAuthor);
176+
177+
let maintainerParticipated = false;
178+
if (prAuthorParticipated) {
179+
// Check each commenter (and issue author) for write+ access on the issue's repo
180+
const usersToCheck = new Set();
181+
usersToCheck.add(issue.user.login);
182+
for (const comment of comments) {
183+
if (comment.user.login !== prAuthor) {
184+
usersToCheck.add(comment.user.login);
185+
}
186+
}
187+
188+
for (const user of usersToCheck) {
189+
if (user === prAuthor) continue;
190+
if (await hasWriteAccess(ref.owner, ref.repo, user)) {
191+
maintainerParticipated = true;
192+
core.info(`Maintainer ${user} participated in ${ref.owner}/${ref.repo}#${ref.number}.`);
193+
break;
194+
}
195+
}
196+
}
197+
198+
if (prAuthorParticipated && maintainerParticipated) {
199+
core.info(`Issue ${ref.owner}/${ref.repo}#${ref.number} has valid discussion. PR is allowed.`);
200+
return; // PR is valid — at least one issue passes all checks
201+
}
202+
203+
core.info(`Issue ${ref.owner}/${ref.repo}#${ref.number} lacks discussion between author and maintainer.`);
204+
hasNoDiscussion = true;
205+
}
206+
207+
// --- Step 5: No valid issue found — close with the most relevant reason ---
208+
if (hasAssigneeConflict) {
209+
core.info('Closing PR: referenced issue is assigned to someone else.');
210+
await closePR([
211+
'This PR has been automatically closed. The referenced issue is already assigned to someone else.',
212+
'',
213+
'If you believe this assignment is outdated, please comment on the issue to discuss before opening a new PR.',
214+
'',
215+
`Please review our [contributing guidelines](${contributingUrl}) for more details.`,
216+
].join('\n'));
217+
return;
218+
}
219+
220+
if (hasNoDiscussion) {
221+
core.info('Closing PR: no discussion between PR author and a maintainer in the referenced issue.');
222+
await closePR([
223+
'This PR has been automatically closed. The referenced issue does not show a discussion between you and a maintainer.',
224+
'',
225+
'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.',
226+
'',
227+
`Please review our [contributing guidelines](${contributingUrl}) for more details.`,
228+
].join('\n'));
229+
return;
230+
}
231+
232+
// If we get here, all issue refs were unfetchable
233+
core.info('Could not validate any referenced issues. Closing PR.');
234+
await closePR([
235+
'This PR has been automatically closed. The referenced issue(s) could not be found.',
236+
'',
237+
'**Next steps:**',
238+
'1. Ensure the issue exists and is in a `getsentry` repository',
239+
'2. Discuss the approach with a maintainer in the issue',
240+
'3. Once a maintainer has acknowledged your proposed approach, open a new PR referencing the issue',
241+
'',
242+
`Please review our [contributing guidelines](${contributingUrl}) for more details.`,
243+
].join('\n'));

0 commit comments

Comments
 (0)