From a1b803ae64a8ef341356d7552e809c7c89f47b32 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Wed, 8 Apr 2026 01:11:14 +0300 Subject: chore: add issue-first contributor gate --- .github/workflows/pr-gate.yml | 107 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 .github/workflows/pr-gate.yml (limited to '.github/workflows/pr-gate.yml') diff --git a/.github/workflows/pr-gate.yml b/.github/workflows/pr-gate.yml new file mode 100644 index 00000000..2083569f --- /dev/null +++ b/.github/workflows/pr-gate.yml @@ -0,0 +1,107 @@ +name: PR Gate + +on: + pull_request_target: + types: [opened, reopened] + +jobs: + check-contributor: + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + pull-requests: write + steps: + - name: Check if contributor is approved + uses: actions/github-script@v7 + with: + script: | + const prAuthor = context.payload.pull_request.user.login; + const defaultBranch = context.payload.repository.default_branch; + + if (prAuthor.endsWith('[bot]') || prAuthor === 'dependabot[bot]') { + console.log(`Skipping bot: ${prAuthor}`); + return; + } + + async function getPermission(username) { + try { + const { data: permissionLevel } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username, + }); + return permissionLevel.permission; + } catch { + return null; + } + } + + async function getTextFile(path) { + const { data: fileContent } = await github.rest.repos.getContent({ + owner: context.repo.owner, + repo: context.repo.repo, + path, + ref: defaultBranch, + }); + + if (!('content' in fileContent) || typeof fileContent.content !== 'string') { + throw new Error(`Expected file content for ${path}`); + } + + return Buffer.from(fileContent.content, 'base64').toString('utf8'); + } + + async function closePullRequest(message) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + body: message, + }); + + await github.rest.pulls.update({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.pull_request.number, + state: 'closed', + }); + } + + const permission = await getPermission(prAuthor); + if (['admin', 'maintain', 'write'].includes(permission)) { + console.log(`${prAuthor} is a collaborator with ${permission} access`); + 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 (isApprovedContributor) { + console.log(`${prAuthor} is in the approved contributors list`); + return; + } + + console.log(`${prAuthor} is not approved, closing PR`); + + const message = [ + `Hi @${prAuthor}, thanks for your interest in contributing!`, + '', + 'We ask new contributors to open an issue first before submitting a PR. This helps us discuss the approach and avoid wasted effort.', + '', + 'Herdr is opinionated about how it should look, feel, and work. If your change affects UI, behavior, interaction patterns, or product direction, discuss it in an issue first.', + '', + '**Next steps:**', + '1. Open an issue describing what you want to change and why', + '2. Keep it concise and write in your own voice', + '3. If a maintainer approves with `lgtm`, you will be added to the approved contributors list', + '4. Then you can submit your PR', + '', + `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); -- cgit v1.2.3