diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS new file mode 100644 index 00000000..10eae33f --- /dev/null +++ b/.github/APPROVED_CONTRIBUTORS @@ -0,0 +1,10 @@ +# Scoped contribution-gate allowlist. +# +# Maintainers and collaborators bypass the gate automatically. Use this file +# for external contributors who are allowed through the automated front door. +# +# Supported entries: +# pr:username +# issue:username +# all:username +all:Hmbown diff --git a/.github/workflows/approve-contributor.yml b/.github/workflows/approve-contributor.yml new file mode 100644 index 00000000..2818786f --- /dev/null +++ b/.github/workflows/approve-contributor.yml @@ -0,0 +1,175 @@ +name: Approve gated contributor + +on: + issue_comment: + types: [created] + +permissions: + contents: write + issues: write + pull-requests: write + +jobs: + approve: + runs-on: ubuntu-latest + steps: + - name: Open allowlist update PR + uses: actions/github-script@v7 + with: + script: | + const comment = context.payload.comment; + const issue = context.payload.issue; + const owner = context.repo.owner; + const repo = context.repo.repo; + const privileged = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']); + const command = (comment.body || '').trim().toLowerCase(); + const scopeByCommand = new Map([ + ['/lgtm', 'pr'], + ['lgtm', 'pr'], + ['/lgtmi', 'issue'], + ['lgtmi', 'issue'], + ]); + const scope = scopeByCommand.get(command); + + if (!scope) return; + if (!privileged.has(comment.author_association)) return; + if (scope === 'pr' && !issue.pull_request) { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issue.number, + body: '`/lgtm` grants PR access and must be used on a pull request. Use `/lgtmi` to grant issue access.', + }); + return; + } + + const path = '.github/APPROVED_CONTRIBUTORS'; + const targetLogin = issue.user.login; + const normalizedLogin = targetLogin.toLowerCase(); + const entry = `${scope}:${normalizedLogin}`; + + const defaultContent = [ + '# Scoped contribution-gate allowlist.', + '#', + '# Maintainers and collaborators bypass the gate automatically. Use this file', + '# for external contributors who are allowed through the automated front door.', + '#', + '# Supported entries:', + '# pr:username', + '# issue:username', + '# all:username', + '', + ].join('\n'); + + function parseAllowlist(content) { + return new Set( + content + .split(/\r?\n/) + .map(line => line.replace(/#.*/, '').trim().toLowerCase()) + .filter(Boolean) + ); + } + + const { data: repoData } = await github.rest.repos.get({ owner, repo }); + const defaultBranch = repoData.default_branch; + const { data: baseRef } = await github.rest.git.getRef({ + owner, + repo, + ref: `heads/${defaultBranch}`, + }); + const baseSha = baseRef.object.sha; + const { data: baseCommit } = await github.rest.git.getCommit({ + owner, + repo, + commit_sha: baseSha, + }); + + let content = defaultContent; + try { + const { data } = await github.rest.repos.getContent({ + owner, + repo, + path, + ref: defaultBranch, + }); + if (!Array.isArray(data) && data.type === 'file') { + content = Buffer.from(data.content, data.encoding || 'base64').toString('utf8'); + } + } catch (error) { + if (error.status !== 404) throw error; + } + + const existing = parseAllowlist(content); + if (existing.has(entry) || existing.has(`all:${normalizedLogin}`)) { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issue.number, + body: `@${targetLogin} is already approved for ${scope} contributions in \`${path}\`.`, + }); + return; + } + + const nextContent = `${content.trimEnd()}\n${entry}\n`; + const { data: blob } = await github.rest.git.createBlob({ + owner, + repo, + content: nextContent, + encoding: 'utf-8', + }); + const { data: tree } = await github.rest.git.createTree({ + owner, + repo, + base_tree: baseCommit.tree.sha, + tree: [ + { + path, + mode: '100644', + type: 'blob', + sha: blob.sha, + }, + ], + }); + + const branchSlug = normalizedLogin.replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'contributor'; + const branchName = `contribution-gate/${scope}-${branchSlug}-${Date.now()}`; + await github.rest.git.createRef({ + owner, + repo, + ref: `refs/heads/${branchName}`, + sha: baseSha, + }); + + const { data: commit } = await github.rest.git.createCommit({ + owner, + repo, + message: `chore: approve @${targetLogin} for ${scope} contributions`, + tree: tree.sha, + parents: [baseSha], + }); + await github.rest.git.updateRef({ + owner, + repo, + ref: `heads/${branchName}`, + sha: commit.sha, + }); + + const { data: pr } = await github.rest.pulls.create({ + owner, + repo, + title: `chore: approve @${targetLogin} for ${scope} contributions`, + head: branchName, + base: defaultBranch, + body: [ + `Adds \`${entry}\` to \`${path}\`.`, + '', + `Requested by @${comment.user.login} in #${issue.number}.`, + ].join('\n'), + }); + + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issue.number, + body: `Created allowlist update PR: ${pr.html_url}`, + }); diff --git a/.github/workflows/issue-gate.yml b/.github/workflows/issue-gate.yml new file mode 100644 index 00000000..70fe83eb --- /dev/null +++ b/.github/workflows/issue-gate.yml @@ -0,0 +1,84 @@ +name: Contribution gate - issues + +on: + issues: + types: [opened, reopened] + +permissions: + contents: read + issues: write + +jobs: + gate: + runs-on: ubuntu-latest + steps: + - name: Close unapproved external issues + uses: actions/github-script@v7 + with: + script: | + const issue = context.payload.issue; + const owner = context.repo.owner; + const repo = context.repo.repo; + const privileged = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']); + + if (issue.pull_request) return; + if (privileged.has(issue.author_association)) return; + if (issue.user.login === 'github-actions[bot]') return; + + function parseAllowlist(content) { + return new Set( + content + .split(/\r?\n/) + .map(line => line.replace(/#.*/, '').trim().toLowerCase()) + .filter(Boolean) + ); + } + + async function readAllowlist() { + try { + const { data } = await github.rest.repos.getContent({ + owner, + repo, + path: '.github/APPROVED_CONTRIBUTORS', + ref: context.payload.repository.default_branch, + }); + if (Array.isArray(data) || data.type !== 'file') return new Set(); + return parseAllowlist( + Buffer.from(data.content, data.encoding || 'base64').toString('utf8') + ); + } catch (error) { + if (error.status === 404) return new Set(); + throw error; + } + } + + const allowlist = await readAllowlist(); + const login = issue.user.login.toLowerCase(); + if ( + allowlist.has(login) || + allowlist.has(`all:${login}`) || + allowlist.has(`issue:${login}`) + ) { + return; + } + + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issue.number, + body: [ + `Thanks @${issue.user.login} for the report.`, + '', + 'This repository currently uses a maintainer-managed contribution gate, so issues from contributors who are not listed in `.github/APPROVED_CONTRIBUTORS` are closed automatically.', + '', + 'Please read `CONTRIBUTING.md` for the expected issue shape. A maintainer can grant issue access by commenting `/lgtmi` on an issue.', + ].join('\n'), + }); + + await github.rest.issues.update({ + owner, + repo, + issue_number: issue.number, + state: 'closed', + state_reason: 'not_planned', + }); diff --git a/.github/workflows/pr-gate.yml b/.github/workflows/pr-gate.yml new file mode 100644 index 00000000..4be1758a --- /dev/null +++ b/.github/workflows/pr-gate.yml @@ -0,0 +1,84 @@ +name: Contribution gate - pull requests + +on: + pull_request_target: + types: [opened, reopened] + +permissions: + contents: read + issues: write + pull-requests: write + +jobs: + gate: + runs-on: ubuntu-latest + steps: + - name: Close unapproved external pull requests + uses: actions/github-script@v7 + with: + script: | + const pr = context.payload.pull_request; + const owner = context.repo.owner; + const repo = context.repo.repo; + const privileged = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']); + + if (privileged.has(pr.author_association)) return; + if (pr.user.login === 'github-actions[bot]') return; + if ((pr.head.ref || '').startsWith('contribution-gate/')) return; + + function parseAllowlist(content) { + return new Set( + content + .split(/\r?\n/) + .map(line => line.replace(/#.*/, '').trim().toLowerCase()) + .filter(Boolean) + ); + } + + async function readAllowlist() { + try { + const { data } = await github.rest.repos.getContent({ + owner, + repo, + path: '.github/APPROVED_CONTRIBUTORS', + ref: pr.base.ref, + }); + if (Array.isArray(data) || data.type !== 'file') return new Set(); + return parseAllowlist( + Buffer.from(data.content, data.encoding || 'base64').toString('utf8') + ); + } catch (error) { + if (error.status === 404) return new Set(); + throw error; + } + } + + const allowlist = await readAllowlist(); + const login = pr.user.login.toLowerCase(); + if ( + allowlist.has(login) || + allowlist.has(`all:${login}`) || + allowlist.has(`pr:${login}`) + ) { + return; + } + + await github.rest.issues.createComment({ + owner, + repo, + issue_number: pr.number, + body: [ + `Thanks @${pr.user.login} for taking the time to contribute.`, + '', + 'This repository currently uses a maintainer-managed contribution gate, so pull requests from contributors who are not listed in `.github/APPROVED_CONTRIBUTORS` are closed automatically.', + '', + 'Please read `CONTRIBUTING.md` for the expected contribution shape. A maintainer can grant PR access by commenting `/lgtm` on a pull request.', + ].join('\n'), + }); + + await github.rest.pulls.update({ + owner, + repo, + pull_number: pr.number, + state: 'closed', + }); diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5ccbf68c..75dec731 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -167,6 +167,30 @@ Issues: Validation: ``` +## Contribution Gate + +CodeWhale uses a maintainer-managed contribution gate for the community front +door. Maintainers and collaborators bypass this gate automatically. External +contributors must be listed in `.github/APPROVED_CONTRIBUTORS` before their +issues or pull requests remain open. + +The allowlist is scoped: + +- `pr:username` allows pull requests. +- `issue:username` allows issues. +- `all:username` allows both. + +When an unapproved external contributor opens an issue or pull request, the +matching gate workflow leaves a short thank-you / CONTRIBUTING pointer and +closes it. A maintainer can approve someone by commenting `/lgtm` on a pull +request for PR access, or `/lgtmi` on an issue for issue access. The exact bare +commands `lgtm` and `lgtmi` are also accepted for compatibility, but the +prefixed forms are preferred because they are harder to trigger accidentally in +ordinary review discussion. + +Approvals do not edit `main` directly. The approval workflow opens a small +allowlist update PR so the new entry is reviewable before it takes effect. + ## Agent-Assisted Improvements CodeWhale is allowed to help improve CodeWhale, but the contribution still has