diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS new file mode 100644 index 000000000..23e1a5d45 --- /dev/null +++ b/.github/APPROVED_CONTRIBUTORS @@ -0,0 +1,11 @@ +# 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. +# Seed active contributors here before switching the gate workflows to enforce mode. +# +# 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 000000000..bdd54e026 --- /dev/null +++ b/.github/workflows/approve-contributor.yml @@ -0,0 +1,218 @@ +name: Approve gated contributor + +on: + issue_comment: + types: [created] + +permissions: + contents: write + issues: write + pull-requests: write + +concurrency: + group: contribution-gate-approval + cancel-in-progress: false + +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; + } + if (scope === 'issue' && issue.pull_request) { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issue.number, + body: '`/lgtmi` grants issue access and must be used on an issue. Use `/lgtm` to grant PR access.', + }); + return; + } + + const path = '.github/APPROVED_CONTRIBUTORS'; + const targetLogin = issue.user.login; + const normalizedLogin = targetLogin.toLowerCase(); + const entry = `${scope}:${normalizedLogin}`; + const branchSlug = normalizedLogin.replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'contributor'; + + 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.', + '# Seed active contributors here before switching the gate workflows to enforce mode.', + '#', + '# 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 openPrs = []; + for (let page = 1; ; page++) { + const { data: pagePrs } = await github.rest.pulls.list({ + owner, + repo, + state: 'open', + per_page: 100, + page, + }); + openPrs.push(...pagePrs); + if (pagePrs.length < 100) break; + } + const repoFullName = `${owner}/${repo}`.toLowerCase(); + const pendingPr = openPrs.find(openPr => { + const sameRepo = (openPr.head?.repo?.full_name || '').toLowerCase() === repoFullName; + const body = openPr.body || ''; + return sameRepo && body.includes(`Adds \`${entry}\` to \`${path}\`.`); + }); + + if (pendingPr) { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issue.number, + body: `@${targetLogin} already has a pending allowlist update PR for ${scope} contributions: ${pendingPr.html_url}`, + }); + 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 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 000000000..70bab864b --- /dev/null +++ b/.github/workflows/issue-gate.yml @@ -0,0 +1,99 @@ +name: Contribution gate - issues + +on: + issues: + types: [opened, reopened] + +permissions: + contents: read + issues: write + +env: + # Keep new gates observable first. Switch to "enforce" only after maintainers + # have seeded active contributors and reviewed the dry-run signal. + CONTRIBUTION_GATE_MODE: dry-run + +jobs: + gate: + runs-on: ubuntu-latest + steps: + - name: Gate 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']); + const gateMode = (process.env.CONTRIBUTION_GATE_MODE || 'dry-run').trim().toLowerCase(); + const enforceGate = gateMode === 'enforce'; + + if (!['dry-run', 'enforce'].includes(gateMode)) { + core.warning(`Unknown CONTRIBUTION_GATE_MODE "${gateMode}"; defaulting to dry-run.`); + } + + 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(`all:${login}`) || + allowlist.has(`issue:${login}`) + ) { + return; + } + + const gateMessage = enforceGate + ? 'This repository currently uses a maintainer-managed contribution gate, so issues from contributors who are not listed in `.github/APPROVED_CONTRIBUTORS` are closed automatically.' + : 'This repository is currently observing a maintainer-managed contribution gate in dry-run mode, so this issue is staying open. When enforcement is enabled, issues from contributors who are not listed in `.github/APPROVED_CONTRIBUTORS` will be closed automatically.'; + + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issue.number, + body: [ + `Thanks @${issue.user.login} for the report.`, + '', + gateMessage, + '', + 'Please read `CONTRIBUTING.md` for the expected issue shape. A maintainer can grant issue access by commenting `/lgtmi` on an issue.', + ].join('\n'), + }); + + if (!enforceGate) return; + + 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 000000000..3e4052dbd --- /dev/null +++ b/.github/workflows/pr-gate.yml @@ -0,0 +1,99 @@ +name: Contribution gate - pull requests + +on: + pull_request_target: + types: [opened, reopened] + +permissions: + contents: read + issues: write + pull-requests: write + +env: + # Keep new gates observable first. Switch to "enforce" only after maintainers + # have seeded active contributors and reviewed the dry-run signal. + CONTRIBUTION_GATE_MODE: dry-run + +jobs: + gate: + runs-on: ubuntu-latest + steps: + - name: Gate 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']); + const gateMode = (process.env.CONTRIBUTION_GATE_MODE || 'dry-run').trim().toLowerCase(); + const enforceGate = gateMode === 'enforce'; + + if (!['dry-run', 'enforce'].includes(gateMode)) { + core.warning(`Unknown CONTRIBUTION_GATE_MODE "${gateMode}"; defaulting to dry-run.`); + } + + if (privileged.has(pr.author_association)) return; + if (pr.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 = pr.user.login.toLowerCase(); + if ( + allowlist.has(`all:${login}`) || + allowlist.has(`pr:${login}`) + ) { + return; + } + + const gateMessage = enforceGate + ? '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.' + : 'This repository is currently observing a maintainer-managed contribution gate in dry-run mode, so this pull request is staying open. When enforcement is enabled, pull requests from contributors who are not listed in `.github/APPROVED_CONTRIBUTORS` will be closed automatically.'; + + await github.rest.issues.createComment({ + owner, + repo, + issue_number: pr.number, + body: [ + `Thanks @${pr.user.login} for taking the time to contribute.`, + '', + gateMessage, + '', + 'Please read `CONTRIBUTING.md` for the expected contribution shape. A maintainer can grant PR access by commenting `/lgtm` on a pull request.', + ].join('\n'), + }); + + if (!enforceGate) return; + + await github.rest.pulls.update({ + owner, + repo, + pull_number: pr.number, + state: 'closed', + }); diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5ccbf68c6..7ed555b9b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -167,6 +167,43 @@ Issues: Validation: ``` +## Contribution Gate + +CodeWhale uses a maintainer-managed contribution gate for the community front +door. Maintainers and collaborators bypass this gate automatically. The gate +workflows default to dry-run / comment-only mode so maintainers can observe the +signal before closing contributor work. In dry-run mode, unapproved external +issues and pull requests receive a short thank-you / CONTRIBUTING pointer and +remain open. + +When maintainers are ready to enforce the gate, set +`CONTRIBUTION_GATE_MODE: enforce` in the PR and issue gate workflows. In enforce +mode, external contributors must be listed in +`.github/APPROVED_CONTRIBUTORS` before their issues or pull requests remain +open. Before enabling enforcement, seed the allowlist broadly enough for active +external contributors who should not be interrupted by the rollout. + +The allowlist is scoped: + +- `pr:username` allows pull requests. +- `issue:username` allows issues. +- `all:username` allows both. + +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. + +If the gate fires on a good contributor incorrectly, use the same approval flow +to restore them: comment `/lgtm` or `/lgtmi`, merge the generated allowlist PR, +then reopen the affected issue or pull request. If GitHub will not allow the +closed item to be reopened, ask the contributor to resubmit after the allowlist +PR is merged. + ## Agent-Assisted Improvements CodeWhale is allowed to help improve CodeWhale, but the contribution still has