diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-04-08 01:11:14 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-04-08 01:11:14 +0300 |
| commit | a1b803ae64a8ef341356d7552e809c7c89f47b32 (patch) | |
| tree | d89dc4813af0ddece130d855e60ba0924b7bb1c4 /.github/workflows | |
| parent | 25974892c779e994f9bc40ac357f40dc0b5e0347 (diff) | |
chore: add issue-first contributor gate
Diffstat (limited to '.github/workflows')
| -rw-r--r-- | .github/workflows/approve-contributor.yml | 100 | ||||
| -rw-r--r-- | .github/workflows/pr-gate.yml | 107 |
2 files changed, 207 insertions, 0 deletions
diff --git a/.github/workflows/approve-contributor.yml b/.github/workflows/approve-contributor.yml new file mode 100644 index 00000000..07176a67 --- /dev/null +++ b/.github/workflows/approve-contributor.yml @@ -0,0 +1,100 @@ +name: Approve Contributor + +on: + issue_comment: + types: [created] + +jobs: + approve: + if: ${{ !github.event.issue.pull_request }} + runs-on: ubuntu-latest + permissions: + contents: write + issues: write + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.event.repository.default_branch }} + + - name: Add contributor to approved list + id: update + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + + const issueAuthor = context.payload.issue.user.login; + const commenter = context.payload.comment.user.login; + const commentBody = context.payload.comment.body || ''; + const approvedFile = '.github/APPROVED_CONTRIBUTORS'; + + if (!/^\s*lgtm\b/i.test(commentBody)) { + console.log('Comment does not match lgtm'); + core.setOutput('status', 'skipped'); + return; + } + + try { + const { data: permissionLevel } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username: commenter, + }); + + if (!['admin', 'write'].includes(permissionLevel.permission)) { + console.log(`${commenter} does not have write access`); + core.setOutput('status', 'skipped'); + return; + } + } catch (error) { + console.log(`${commenter} does not have collaborator access`); + core.setOutput('status', 'skipped'); + return; + } + + let content = fs.readFileSync(approvedFile, 'utf8'); + const approvedList = content + .split('\n') + .map(line => line.trim().toLowerCase()) + .filter(line => line && !line.startsWith('#')); + + if (approvedList.includes(issueAuthor.toLowerCase())) { + console.log(`${issueAuthor} is already approved`); + core.setOutput('status', 'already'); + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: `@${issueAuthor} is already in the approved contributors list.`, + }); + return; + } + + content = content.trimEnd() + '\n' + issueAuthor + '\n'; + fs.writeFileSync(approvedFile, content); + + console.log(`Added ${issueAuthor} to approved contributors`); + core.setOutput('status', 'added'); + + - name: Commit and push + if: steps.update.outputs.status == 'added' + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add .github/APPROVED_CONTRIBUTORS + git diff --staged --quiet || git commit -m "chore: approve contributor ${{ github.event.issue.user.login }}" + git push + + - name: Comment on issue + if: steps.update.outputs.status == 'added' + uses: actions/github-script@v7 + with: + script: | + const issueAuthor = context.payload.issue.user.login; + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: `@${issueAuthor} has been added to the approved contributors list. You can now submit PRs. Thanks for contributing!`, + }); 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); |
