diff --git a/.github/workflows/validate-pr.yml b/.github/workflows/validate-pr.yml index 5f489cd415..c05657993e 100644 --- a/.github/workflows/validate-pr.yml +++ b/.github/workflows/validate-pr.yml @@ -5,323 +5,12 @@ on: types: [opened, reopened] jobs: - validate-non-maintainer-pr: - name: Validate Non-Maintainer PR + validate-pr: runs-on: ubuntu-24.04 permissions: pull-requests: write - contents: write - outputs: - was-closed: ${{ steps.validate.outputs.was-closed }} steps: - - name: Generate GitHub App token - id: app-token - uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v2 + - uses: getsentry/github-workflows/validate-pr@4243265ac9cc3ee5b89ad2b30c3797ac8483d63a with: app-id: ${{ vars.SDK_MAINTAINER_BOT_APP_ID }} private-key: ${{ secrets.SDK_MAINTAINER_BOT_PRIVATE_KEY }} - - - name: Validate PR - id: validate - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 - with: - github-token: ${{ steps.app-token.outputs.token }} - script: | - const pullRequest = context.payload.pull_request; - const repo = context.repo; - const prAuthor = pullRequest.user.login; - const contributingUrl = `https://github.com/${repo.owner}/${repo.repo}/blob/master/CONTRIBUTING.md`; - - // --- Helper: check if a user has admin or maintain permission on a repo (cached) --- - const maintainerCache = new Map(); - async function isMaintainer(owner, repoName, username) { - const key = `${owner}/${repoName}:${username}`; - if (maintainerCache.has(key)) return maintainerCache.get(key); - let result = false; - try { - const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ - owner, - repo: repoName, - username, - }); - // permission field uses legacy values (admin/write/read/none) where - // maintain maps to write. Use role_name for the actual role. - result = ['admin', 'maintain'].includes(data.role_name); - } catch { - // noop — result stays false - } - maintainerCache.set(key, result); - return result; - } - - // --- Step 1: Check if PR author is a maintainer (admin or maintain role) --- - const authorIsMaintainer = await isMaintainer(repo.owner, repo.repo, prAuthor); - if (authorIsMaintainer) { - core.info(`PR author ${prAuthor} has admin/maintain access. Skipping.`); - return; - } - core.info(`PR author ${prAuthor} is not a maintainer.`); - - // --- Step 2: Parse issue references from PR body --- - const body = pullRequest.body || ''; - - // Match all issue reference formats: - // #123, Fixes #123, getsentry/repo#123, Fixes getsentry/repo#123 - // https://github.com/getsentry/repo/issues/123 - const issueRefs = []; - const seen = new Set(); - - // Pattern 1: Full GitHub URLs - const urlPattern = /https?:\/\/github\.com\/(getsentry)\/([\w.-]+)\/issues\/(\d+)/gi; - for (const match of body.matchAll(urlPattern)) { - const key = `${match[1]}/${match[2]}#${match[3]}`; - if (!seen.has(key)) { - seen.add(key); - issueRefs.push({ owner: match[1], repo: match[2], number: parseInt(match[3]) }); - } - } - - // Pattern 2: Cross-repo references (getsentry/repo#123) - const crossRepoPattern = /(?:(?:fix|fixes|fixed|close|closes|closed|resolve|resolves|resolved)\s+)?(getsentry)\/([\w.-]+)#(\d+)/gi; - for (const match of body.matchAll(crossRepoPattern)) { - const key = `${match[1]}/${match[2]}#${match[3]}`; - if (!seen.has(key)) { - seen.add(key); - issueRefs.push({ owner: match[1], repo: match[2], number: parseInt(match[3]) }); - } - } - - // Pattern 3: Same-repo references (#123) - // Negative lookbehind to avoid matching cross-repo refs or URLs already captured - const sameRepoPattern = /(?:(?:fix|fixes|fixed|close|closes|closed|resolve|resolves|resolved)\s+)?(? 0) { - const assignedToAuthor = issue.assignees.some(a => a.login === prAuthor); - if (!assignedToAuthor) { - core.info(`Issue ${ref.owner}/${ref.repo}#${ref.number} is assigned to someone else.`); - hasAssigneeConflict = true; - continue; - } - } - - // Check discussion: both PR author and a maintainer must have commented - const comments = await github.paginate(github.rest.issues.listComments, { - owner: ref.owner, - repo: ref.repo, - issue_number: ref.number, - per_page: 100, - }); - - // Also consider the issue author as a participant (opening the issue is a form of discussion) - // Guard against null user (deleted/suspended GitHub accounts) - const prAuthorParticipated = - issue.user?.login === prAuthor || - comments.some(c => c.user?.login === prAuthor); - - let maintainerParticipated = false; - if (prAuthorParticipated) { - // Check each commenter (and issue author) for admin/maintain access on the issue's repo - const usersToCheck = new Set(); - if (issue.user?.login) usersToCheck.add(issue.user.login); - for (const comment of comments) { - if (comment.user?.login && comment.user.login !== prAuthor) { - usersToCheck.add(comment.user.login); - } - } - - for (const user of usersToCheck) { - if (user === prAuthor) continue; - if (await isMaintainer(repo.owner, repo.repo, user)) { - maintainerParticipated = true; - core.info(`Maintainer ${user} participated in ${ref.owner}/${ref.repo}#${ref.number}.`); - break; - } - } - } - - if (prAuthorParticipated && maintainerParticipated) { - core.info(`Issue ${ref.owner}/${ref.repo}#${ref.number} has valid discussion. PR is allowed.`); - return; // PR is valid — at least one issue passes all checks - } - - core.info(`Issue ${ref.owner}/${ref.repo}#${ref.number} lacks discussion between author and maintainer.`); - hasNoDiscussion = true; - } - - // --- Step 5: No valid issue found — close with the most relevant reason --- - if (hasAssigneeConflict) { - core.info('Closing PR: referenced issue is assigned to someone else.'); - await closePR([ - 'This PR has been automatically closed. The referenced issue is already assigned to someone else.', - '', - 'If you believe this assignment is outdated, please comment on the issue to discuss before opening a new PR.', - '', - `Please review our [contributing guidelines](${contributingUrl}) for more details.`, - ].join('\n'), 'issue-already-assigned'); - return; - } - - if (hasNoDiscussion) { - core.info('Closing PR: no discussion between PR author and a maintainer in the referenced issue.'); - await closePR([ - 'This PR has been automatically closed. The referenced issue does not show a discussion between you and a maintainer.', - '', - '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.', - '', - `Please review our [contributing guidelines](${contributingUrl}) for more details.`, - ].join('\n'), 'missing-maintainer-discussion'); - return; - } - - // If we get here, all issue refs were unfetchable - core.info('Could not validate any referenced issues. Closing PR.'); - await closePR([ - 'This PR has been automatically closed. The referenced issue(s) could not be found.', - '', - '**Next steps:**', - '1. Ensure the issue exists and is in a `getsentry` repository', - '2. Discuss the approach with a maintainer in the issue', - '3. Once a maintainer has acknowledged your proposed approach, open a new PR referencing the issue', - '', - `Please review our [contributing guidelines](${contributingUrl}) for more details.`, - ].join('\n'), 'missing-issue-reference'); - - enforce-draft: - name: Enforce Draft PR - needs: [validate-non-maintainer-pr] - if: | - always() - && github.event.pull_request.draft == false - && needs.validate-non-maintainer-pr.outputs.was-closed != 'true' - runs-on: ubuntu-24.04 - permissions: - pull-requests: write - contents: write - steps: - - name: Generate GitHub App token - id: app-token - uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v2 - with: - app-id: ${{ vars.SDK_MAINTAINER_BOT_APP_ID }} - private-key: ${{ secrets.SDK_MAINTAINER_BOT_PRIVATE_KEY }} - - - name: Convert PR to draft - env: - GH_TOKEN: ${{github.token}} - PR_URL: ${{ github.event.pull_request.html_url }} - run: | - gh pr ready "$PR_URL" --undo - - - name: Label and comment - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 - with: - github-token: ${{ steps.app-token.outputs.token }} - script: | - const pullRequest = context.payload.pull_request; - const repo = context.repo; - - // Label the PR so maintainers can filter/track violations - await github.rest.issues.addLabels({ - ...repo, - issue_number: pullRequest.number, - labels: ['converted-to-draft'], - }); - - // Check for existing bot comment to avoid duplicates on reopen - const comments = await github.rest.issues.listComments({ - ...repo, - issue_number: pullRequest.number, - }); - const botComment = comments.data.find(c => - c.user.type === 'Bot' && - c.body.includes('automatically converted to draft') - ); - if (botComment) { - core.info('Bot comment already exists, skipping.'); - return; - } - - const contributingUrl = `https://github.com/${repo.owner}/${repo.repo}/blob/master/CONTRIBUTING.md`; - - await github.rest.issues.createComment({ - ...repo, - issue_number: pullRequest.number, - body: [ - `This PR has been automatically converted to draft. All PRs must start as drafts per our [contributing guidelines](${contributingUrl}).`, - '', - '**Next steps:**', - '1. Ensure CI passes', - '2. Fill in the PR description completely', - '3. Mark as "Ready for review" when you\'re done' - ].join('\n') - });