|
5 | 5 | types: [opened, reopened] |
6 | 6 |
|
7 | 7 | jobs: |
8 | | - validate-non-maintainer-pr: |
9 | | - name: Validate Non-Maintainer PR |
| 8 | + validate-pr: |
10 | 9 | runs-on: ubuntu-24.04 |
11 | 10 | permissions: |
12 | 11 | pull-requests: write |
13 | | - contents: write |
14 | | - outputs: |
15 | | - was-closed: ${{ steps.validate.outputs.was-closed }} |
16 | 12 | 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 |
20 | 14 | with: |
21 | 15 | app-id: ${{ vars.SDK_MAINTAINER_BOT_APP_ID }} |
22 | 16 | 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