Skip to content

Commit 357dd87

Browse files
chore: Use shared validate-pr composite action
Replace the inline PR validation workflow with the shared composite action from getsentry/github-workflows#153. This shrinks the workflow from 300+ lines to ~15 and ensures future updates to validation logic are picked up automatically. #skip-changelog Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 6285fcc commit 357dd87

File tree

1 file changed

+2
-313
lines changed

1 file changed

+2
-313
lines changed

.github/workflows/validate-pr.yml

Lines changed: 2 additions & 313 deletions
Original file line numberDiff line numberDiff line change
@@ -5,323 +5,12 @@ on:
55
types: [opened, reopened]
66

77
jobs:
8-
validate-non-maintainer-pr:
9-
name: Validate Non-Maintainer PR
8+
validate-pr:
109
runs-on: ubuntu-24.04
1110
permissions:
1211
pull-requests: write
13-
contents: write
14-
outputs:
15-
was-closed: ${{ steps.validate.outputs.was-closed }}
1612
steps:
17-
- name: Generate GitHub App token
18-
id: app-token
19-
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v2
13+
- uses: getsentry/github-workflows/validate-pr@4243265ac9cc3ee5b89ad2b30c3797ac8483d63a
2014
with:
2115
app-id: ${{ vars.SDK_MAINTAINER_BOT_APP_ID }}
2216
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)