feat: show visual tab indices alongside names #525
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: PR Gate | |
| on: | |
| pull_request_target: | |
| types: [opened, edited, reopened, synchronize] | |
| jobs: | |
| check-contributor: | |
| if: github.repository == 'herdrdev/herdr' || github.repository == 'ogulcancelik/herdr' | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| issues: write | |
| pull-requests: write | |
| steps: | |
| - name: Check pull request intake policy | |
| uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 | |
| with: | |
| github-token: ${{ secrets.KANGAL_GITHUB_TOKEN }} | |
| script: | | |
| const KANGAL_USER_ID = 285672167; | |
| const CI_ONLY_PR_AUTHOR_IDS = new Set([ | |
| 49699333, // dependabot[bot] | |
| 41898282, // github-actions[bot] | |
| ]); | |
| const MAX_EXTERNAL_CHANGED_FILES = 20; | |
| const MAX_EXTERNAL_CHANGED_LINES = 1000; | |
| const REVIEW_LABELS = ['ai-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 { 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; | |
| const eventPullRequestState = context.payload.pull_request.state; | |
| if (action === 'edited' && | |
| (eventPullRequestState !== 'open' || pr.state !== 'open')) { | |
| core.info(`Ignoring edits to closed PR #${pullNumber}`); | |
| return; | |
| } | |
| async function getPermission(username) { | |
| try { | |
| const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| username, | |
| }); | |
| return data.permission; | |
| } catch { | |
| return null; | |
| } | |
| } | |
| async function getTextFile(path) { | |
| const { data } = await github.rest.repos.getContent({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| path, | |
| ref: defaultBranch, | |
| }); | |
| 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 maintainers = parseUserList(await getTextFile('.github/MAINTAINERS')); | |
| async function isVerifiedMaintainer(username) { | |
| if (!username || !maintainers.has(username.toLowerCase())) return false; | |
| return ['admin', 'maintain', 'write'].includes(await getPermission(username)); | |
| } | |
| 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: pullNumber, | |
| labels: missing, | |
| }); | |
| } | |
| 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: 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 focused bug fixes from contributors who are not maintainers when the title uses \`fix: ...\` or \`fix(scope): ...\` and the patch changes no more than ${MAX_EXTERNAL_CHANGED_FILES} files and ${MAX_EXTERNAL_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.', | |
| '', | |
| '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: pullNumber, | |
| state: 'closed', | |
| }); | |
| } | |
| if (action === 'reopened') { | |
| 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 ((await currentLabels()).has(MAINTAINER_APPROVED_LABEL)) { | |
| core.info(`PR #${pullNumber} has a maintainer scope override`); | |
| await addReviewLabels(); | |
| return; | |
| } | |
| const hasFixTitle = /^fix(?:\([^)]+\))?:\s+\S/.test(pr.title); | |
| if (!hasFixTitle) { | |
| await closePullRequest( | |
| 'Contributors who are not maintainers may submit only focused bug fixes. If this pull request fixes a bug, rename it to use a conventional `fix: ...` or `fix(scope): ...` title, then tag a maintainer to review and reopen it.', | |
| ); | |
| return; | |
| } | |
| const exceedsBudget = pr.changed_files > MAX_EXTERNAL_CHANGED_FILES || | |
| changedLines > MAX_EXTERNAL_CHANGED_LINES; | |
| if (exceedsBudget) { | |
| await closePullRequest('The current patch exceeds the automatic intake budget and needs maintainer alignment before review.'); | |
| return; | |
| } | |
| core.info(`Admitting scoped pull request from ${prAuthor}: ${pr.changed_files} files, ${changedLines} lines`); | |
| await addReviewLabels(); |