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 | |
| parent | 25974892c779e994f9bc40ac357f40dc0b5e0347 (diff) | |
chore: add issue-first contributor gate
| -rw-r--r-- | .github/APPROVED_CONTRIBUTORS | 2 | ||||
| -rw-r--r-- | .github/workflows/approve-contributor.yml | 100 | ||||
| -rw-r--r-- | .github/workflows/pr-gate.yml | 107 | ||||
| -rw-r--r-- | CONTRIBUTING.md | 73 |
4 files changed, 282 insertions, 0 deletions
diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS new file mode 100644 index 00000000..55680459 --- /dev/null +++ b/.github/APPROVED_CONTRIBUTORS @@ -0,0 +1,2 @@ +# GitHub usernames, one per line. +# Maintainers and trusted contributors can be listed here. 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); diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..915b2370 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,73 @@ +# Contributing to herdr + +Thanks for wanting to contribute. + +Herdr came from my own need for a fast, simple, effective workspace manager for coding agents. I care a lot about how it looks, feels, and works, so many design and technical decisions here are deliberate. + +This guide exists so I can keep herdr manageable as a solo project and keep it from drifting from what it is supposed to be. + +## The One Rule + +**You must understand your code.** If you cannot explain what your changes do, how they behave at the edges, and how they fit herdr's existing design, your PR will be closed. + +Using AI to write code is fine. Submitting code you do not understand is not. + +## Herdr is opinionated + +Herdr has a specific direction for how it should look, feel, and work. + +That includes interaction patterns, layout behavior, mouse ergonomics, terminology, and how features fit the product as a whole. + +If your idea changes or contradicts that direction, do not start with a PR. Start with an issue. + +If you have a suggestion, disagreement, or contradiction with the current product direction, that belongs in an issue first. + +## First-time contributors + +We use an approval gate for new contributors. + +Before opening your first PR, open an issue describing what you want to change and why. Keep it short. Write in your own voice. If you intend to implement it yourself, add the `intends-to-pr` label. A maintainer will comment `lgtm` if the direction is approved. Once approved, you can open PRs. + +This exists because AI makes it trivial to generate plausible-looking contributions that do not fit the app. + +## What to put in a first issue + +Your first issue should answer these questions clearly: + +- what is the current behavior +- what do you want to change +- why this change belongs in herdr +- whether this changes UI, interaction, or workflow expectations +- whether you intend to implement it yourself + +If your proposal changes the visual language, interaction model, or overall product direction, say that directly. That is exactly what issues are for. + +If your issue does not make the direction clear, it will likely be closed. + +If you plan to implement the change yourself, say that directly in the issue and use the `intends-to-pr` label. That signals intent. It is not approval. + +## Before submitting a PR + +Run the project checks and make sure they pass. + +```bash +just check +``` + +Do not open a PR that bypasses failing tests, formatting, or build errors. + +## PR scope + +Small bug fixes that clearly match the existing design are good candidates for PRs after approval. + +Bigger changes to UI, behavior, interaction patterns, persistence, or architecture need issue discussion first. + +If a PR introduces a feature without prior alignment, or changes herdr's feel without discussion, it will likely be closed. + +## Questions? + +Open an issue first. + +--- + +clank'd from [pi](https://github.com/badlogic/pi-mono/) |
