aboutsummaryrefslogtreecommitdiff
path: root/.github/workflows
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-04-08 01:11:14 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-04-08 01:11:14 +0300
commita1b803ae64a8ef341356d7552e809c7c89f47b32 (patch)
treed89dc4813af0ddece130d855e60ba0924b7bb1c4 /.github/workflows
parent25974892c779e994f9bc40ac357f40dc0b5e0347 (diff)
chore: add issue-first contributor gate
Diffstat (limited to '.github/workflows')
-rw-r--r--.github/workflows/approve-contributor.yml100
-rw-r--r--.github/workflows/pr-gate.yml107
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);