Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/APPROVED_CONTRIBUTORS
Original file line number Diff line number Diff line change
@@ -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
175 changes: 175 additions & 0 deletions .github/workflows/approve-contributor.yml
Original file line number Diff line number Diff line change
@@ -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;
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

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}`,
});
84 changes: 84 additions & 0 deletions .github/workflows/issue-gate.yml
Original file line number Diff line number Diff line change
@@ -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',
});
84 changes: 84 additions & 0 deletions .github/workflows/pr-gate.yml
Original file line number Diff line number Diff line change
@@ -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;
Comment thread
nightt5879 marked this conversation as resolved.
Outdated
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated

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,
});
Comment thread
greptile-apps[bot] marked this conversation as resolved.
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;
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

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',
});
24 changes: 24 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading