diff options
Diffstat (limited to '.github')
| -rw-r--r-- | .github/APPROVED_CONTRIBUTORS | 4 | ||||
| -rw-r--r-- | .github/ISSUE_TEMPLATE/bug.yml | 2 | ||||
| -rw-r--r-- | .github/workflows/pr-gate.yml | 238 |
3 files changed, 179 insertions, 65 deletions
diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS index a3b31a32..3fb585f7 100644 --- a/.github/APPROVED_CONTRIBUTORS +++ b/.github/APPROVED_CONTRIBUTORS @@ -1,5 +1,5 @@ -# GitHub usernames approved to contribute, one per line. -# This does not grant maintainer authority; see .github/MAINTAINERS. +# GitHub usernames that bypass automated PR intake, one per line. +# This does not approve feature scope or grant maintainer authority; see .github/MAINTAINERS. Edmund-a7 othavioquiliao edheltzel diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml index 9aa3ed83..c4b2852d 100644 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -8,7 +8,7 @@ body: Issues are only for reproducible bugs and maintainer-created or maintainer-converted work items. Feature requests, ideas, questions, contribution proposals, and direction checks belong in [Discussions](https://github.com/ogulcancelik/herdr/discussions). - New PRs from unapproved contributors are auto-closed by default. Do not open a PR unless a maintainer has approved the work and approved you to contribute. + Unapproved contributors may open focused PRs when the patch changes no more than 20 files and 1,000 total added or deleted lines. Features and larger changes require maintainer approval first. Keep this short. If it does not fit on one screen, it is too long. Write in your own voice. 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 = '<!-- herdr:pr-gate -->'; + + 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(); |
