From da40b1aa347d07e60057182f81e5bcd1d12e9fb1 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Sat, 25 Jul 2026 23:03:35 +0300 Subject: ci: gate pull request review by scope --- .github/workflows/pr-gate.yml | 238 +++++++++++++++++++++++++++++++----------- 1 file changed, 176 insertions(+), 62 deletions(-) (limited to '.github/workflows') diff --git a/.github/workflows/pr-gate.yml b/.github/workflows/pr-gate.yml index 289d39db..dd1f5193 100644 --- a/.github/workflows/pr-gate.yml +++ b/.github/workflows/pr-gate.yml @@ -2,7 +2,7 @@ name: PR Gate on: pull_request_target: - types: [opened, reopened] + types: [opened, reopened, synchronize] jobs: check-contributor: @@ -12,124 +12,238 @@ jobs: issues: write pull-requests: write steps: - - name: Check if contributor is approved + - name: Check pull request intake policy uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 with: github-token: ${{ secrets.KANGAL_GITHUB_TOKEN }} script: | - const prAuthor = context.payload.pull_request.user.login; + const CANONICAL_REPOSITORY = 'ogulcancelik/herdr'; + const KANGAL_USER_ID = 285672167; + const CI_ONLY_PR_AUTHOR_IDS = new Set([ + 49699333, // dependabot[bot] + 41898282, // github-actions[bot] + ]); + const MAX_UNAPPROVED_CHANGED_FILES = 20; + const MAX_UNAPPROVED_CHANGED_LINES = 1000; + const REVIEW_LABELS = ['greptile-review', 'coderabbit-review']; + const MAINTAINER_APPROVED_LABEL = 'maintainer-approved'; + const COMMENT_MARKER = ''; + + const pullNumber = context.payload.pull_request.number; const reopener = context.payload.sender?.login ?? null; const action = context.payload.action; const defaultBranch = context.payload.repository.default_branch; - const maintainerPermissions = ['admin', 'maintain', 'write']; + const repositoryName = context.payload.repository.full_name.toLowerCase(); - if (prAuthor.endsWith('[bot]') || prAuthor === 'dependabot[bot]') { - console.log(`Skipping bot: ${prAuthor}`); + if (repositoryName !== CANONICAL_REPOSITORY) { + core.setFailed(`PR Gate only runs for ${CANONICAL_REPOSITORY}`); return; } + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pullNumber, + }); + const prAuthor = pr.user.login; + const changedLines = pr.additions + pr.deletions; + async function getPermission(username) { try { - const { data: permissionLevel } = await github.rest.repos.getCollaboratorPermissionLevel({ + const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ owner: context.repo.owner, repo: context.repo.repo, username, }); - return permissionLevel.permission; + return data.permission; } catch { return null; } } async function getTextFile(path) { - const { data: fileContent } = await github.rest.repos.getContent({ + const { data } = await github.rest.repos.getContent({ owner: context.repo.owner, repo: context.repo.repo, path, ref: defaultBranch, }); - - if (!('content' in fileContent) || typeof fileContent.content !== 'string') { + if (!('content' in data) || typeof data.content !== 'string') { throw new Error(`Expected file content for ${path}`); } + return Buffer.from(data.content, 'base64').toString('utf8'); + } + + function parseUserList(content) { + return new Set(content + .split('\n') + .map(line => line.trim().toLowerCase()) + .filter(line => line && !line.startsWith('#'))); + } + + const [maintainersContent, approvedContent] = await Promise.all([ + getTextFile('.github/MAINTAINERS'), + getTextFile('.github/APPROVED_CONTRIBUTORS'), + ]); + const maintainers = parseUserList(maintainersContent); + const approvedContributors = parseUserList(approvedContent); - return Buffer.from(fileContent.content, 'base64').toString('utf8'); + async function isVerifiedMaintainer(username) { + if (!username || !maintainers.has(username.toLowerCase())) return false; + return ['admin', 'maintain', 'write'].includes(await getPermission(username)); } - async function addGreptileReviewLabel() { + async function currentLabels() { + const labels = await github.paginate(github.rest.issues.listLabelsOnIssue, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pullNumber, + per_page: 100, + }); + return new Set(labels.map(label => label.name)); + } + + async function addLabels(names) { + const labels = await currentLabels(); + const missing = names.filter(name => !labels.has(name)); + if (missing.length === 0) return; await github.rest.issues.addLabels({ owner: context.repo.owner, repo: context.repo.repo, - issue_number: context.payload.pull_request.number, - labels: ['greptile-review'], + issue_number: pullNumber, + labels: missing, }); } - async function closePullRequest(message) { + async function removeLabels(names) { + const labels = await currentLabels(); + for (const name of names) { + if (!labels.has(name)) continue; + await github.rest.issues.removeLabelForIssue({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pullNumber, + name, + }); + } + } + + async function addReviewLabels() { + await addLabels(REVIEW_LABELS); + } + + async function removeReviewLabels() { + await removeLabels(REVIEW_LABELS); + } + + async function upsertGateComment(message) { + const comments = await github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pullNumber, + per_page: 100, + }); + const existing = comments.find(comment => + comment.user?.id === KANGAL_USER_ID && comment.body?.includes(COMMENT_MARKER)); + const body = `${COMMENT_MARKER}\n${message}`; + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + return; + } await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, - issue_number: context.payload.pull_request.number, - body: message, + issue_number: pullNumber, + body, }); + } + async function closePullRequest(reason, { removeApproval = false } = {}) { + const labels = await currentLabels(); + if (!removeApproval && labels.has(MAINTAINER_APPROVED_LABEL)) { + core.info(`PR #${pullNumber} has a maintainer scope override; leaving it open`); + await addReviewLabels(); + return; + } + await removeLabels(removeApproval + ? [...REVIEW_LABELS, MAINTAINER_APPROVED_LABEL] + : REVIEW_LABELS); + const message = [ + `Hi @${prAuthor}, thanks for your interest in contributing!`, + '', + `Herdr automatically admits unapproved pull requests when the patch changes no more than ${MAX_UNAPPROVED_CHANGED_FILES} files and ${MAX_UNAPPROVED_CHANGED_LINES.toLocaleString('en-US')} total added or deleted lines.`, + '', + reason, + '', + 'Feature requests, behavior changes, and other proposals belong in GitHub Discussions and require maintainer approval before a pull request. Membership in `.github/APPROVED_CONTRIBUTORS` bypasses this automatic intake gate, but does not grant maintainer authority or guarantee that a change will be accepted.', + '', + 'If this gate classified the pull request incorrectly, reply and tag a maintainer listed in `.github/MAINTAINERS`. A verified maintainer can reopen it; reopening by anyone else will be closed again automatically.', + '', + `Patch size: ${pr.changed_files} changed files, ${changedLines} changed lines.`, + '', + `See https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md for the contribution policy.`, + ].join('\n'); + await upsertGateComment(message); await github.rest.pulls.update({ owner: context.repo.owner, repo: context.repo.repo, - pull_number: context.payload.pull_request.number, + pull_number: pullNumber, state: 'closed', }); } - const authorPermission = await getPermission(prAuthor); - if (maintainerPermissions.includes(authorPermission)) { - console.log(`${prAuthor} is a collaborator with ${authorPermission} access`); - await addGreptileReviewLabel(); - return; - } - if (action === 'reopened') { - const reopenerPermission = reopener ? await getPermission(reopener) : null; - if (maintainerPermissions.includes(reopenerPermission)) { - console.log(`${reopener} reopened this PR with ${reopenerPermission} access; leaving it open`); - await addGreptileReviewLabel(); + if (!(await isVerifiedMaintainer(reopener))) { + await closePullRequest( + 'This pull request was reopened by someone other than a verified maintainer.', + { removeApproval: true }, + ); return; } + core.info(`${reopener} is a verified maintainer; leaving reopened PR #${pullNumber} open`); + if (CI_ONLY_PR_AUTHOR_IDS.has(pr.user.id)) { + await removeReviewLabels(); + } else { + await addLabels([...REVIEW_LABELS, MAINTAINER_APPROVED_LABEL]); + } + return; + } + + if (CI_ONLY_PR_AUTHOR_IDS.has(pr.user.id)) { + core.info(`Leaving CI-only bot PR open without automated AI review: ${prAuthor}`); + await removeReviewLabels(); + return; + } + + if (await isVerifiedMaintainer(prAuthor)) { + core.info(`${prAuthor} is a verified maintainer`); + await addReviewLabels(); + return; + } + + if (approvedContributors.has(prAuthor.toLowerCase())) { + core.info(`${prAuthor} is in the approved contributors list`); + await addReviewLabels(); + return; } - const approvedContent = await getTextFile('.github/APPROVED_CONTRIBUTORS'); - const approvedList = approvedContent - .split('\n') - .map(line => line.trim().toLowerCase()) - .filter(line => line && !line.startsWith('#')); - const isApprovedContributor = approvedList.includes(prAuthor.toLowerCase()); + if ((await currentLabels()).has(MAINTAINER_APPROVED_LABEL)) { + core.info(`PR #${pullNumber} has a maintainer scope override`); + await addReviewLabels(); + return; + } - if (isApprovedContributor) { - console.log(`${prAuthor} is in the approved contributors list`); - await addGreptileReviewLabel(); + const exceedsBudget = pr.changed_files > MAX_UNAPPROVED_CHANGED_FILES || + changedLines > MAX_UNAPPROVED_CHANGED_LINES; + if (exceedsBudget) { + await closePullRequest('The current patch exceeds the automatic intake budget and needs maintainer alignment before review.'); return; } - console.log(`${prAuthor} is not approved, closing PR`); - - const message = [ - `Hi @${prAuthor}, thanks for your interest in contributing!`, - '', - 'New contributors need maintainer approval before opening PRs. This keeps review time focused on accepted work and avoids wasted effort.', - '', - 'Herdr is opinionated about how it should look, feel, and work. Feature requests, ideas, questions, contribution proposals, and product-direction checks belong in Discussions, not issues.', - '', - '**Next steps:**', - '1. If this is a feature or behavior change, open a Discussion describing what you want to change and why', - '2. If the work is accepted, a maintainer may convert the discussion into an issue or create a new issue for it', - '3. If this is a reproducible bug, open a bug report using the issue template', - '4. Wait for maintainer approval on an accepted issue before opening a PR', - ' Maintainers approve first-time PR paths with `/approve @your-github-username` on the accepted issue.', - '5. Keep it concise and write in your own voice', - '', - 'A discussion, issue, branch, or proposed implementation does not reserve the work and does not mean the PR path is approved.', - '', - `This PR will be closed automatically. See https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md for more details.`, - ].join('\n'); - - await closePullRequest(message); + core.info(`Admitting scoped pull request from ${prAuthor}: ${pr.changed_files} files, ${changedLines} lines`); + await addReviewLabels(); -- cgit v1.2.3