chore: replace OSS weekend with permanent contribution gate

This commit is contained in:
Mario Zechner
2026-04-14 23:16:11 +02:00
parent 8f66938c80
commit d62d22173a
14 changed files with 424 additions and 753 deletions

View File

@@ -17,20 +17,26 @@ jobs:
with:
ref: ${{ github.event.repository.default_branch }}
- name: Add contributor to approved list
- name: Update contributor approval
id: update
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const APPROVED_FILE = '.github/APPROVED_CONTRIBUTORS';
const VALID_CAPABILITIES = new Set(['issue', 'pr']);
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';
const commentBody = (context.payload.comment.body || '').trim();
if (!/^\s*lgtm\b/i.test(commentBody)) {
console.log('Comment does not match lgtm');
let targetCapability;
if (/\blgtmi\b/i.test(commentBody)) {
targetCapability = 'issue';
} else if (/\blgtm\b/i.test(commentBody)) {
targetCapability = 'pr';
} else {
console.log('Comment does not match lgtm or lgtmi');
core.setOutput('status', 'skipped');
return;
}
@@ -39,46 +45,89 @@ jobs:
const { data: permissionLevel } = await github.rest.repos.getCollaboratorPermissionLevel({
owner: context.repo.owner,
repo: context.repo.repo,
username: commenter
username: commenter,
});
if (!['admin', 'write'].includes(permissionLevel.permission)) {
if (!['admin', 'maintain', 'write'].includes(permissionLevel.permission)) {
console.log(`${commenter} does not have write access`);
core.setOutput('status', 'skipped');
return;
}
} catch (error) {
} catch {
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('#'));
function parseApprovedUsers(content) {
const lines = content.split('\n');
const entries = [];
const users = new Map();
if (approvedList.includes(issueAuthor.toLowerCase())) {
console.log(`${issueAuthor} is already approved`);
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) {
entries.push({ type: 'other', line });
continue;
}
const parts = trimmed.split(/\s+/);
if (parts.length !== 2) {
console.log(`Skipping malformed line: ${line}`);
entries.push({ type: 'other', line });
continue;
}
const [username, capability] = parts;
const normalizedCapability = capability.toLowerCase();
if (!VALID_CAPABILITIES.has(normalizedCapability)) {
console.log(`Skipping line with invalid capability: ${line}`);
entries.push({ type: 'other', line });
continue;
}
const normalizedUser = username.toLowerCase();
const entry = { type: 'user', username, normalizedUser, capability: normalizedCapability };
entries.push(entry);
users.set(normalizedUser, entry);
}
return { entries, users };
}
function stringifyApprovedUsers(entries) {
return `${entries
.map((entry) => (entry.type === 'user' ? `${entry.username} ${entry.capability}` : entry.line))
.join('\n')
.replace(/\n+$/g, '')}\n`;
}
const content = fs.readFileSync(APPROVED_FILE, 'utf8');
const { entries, users } = parseApprovedUsers(content);
const normalizedAuthor = issueAuthor.toLowerCase();
const existingEntry = users.get(normalizedAuthor);
const existingCapability = existingEntry?.capability ?? null;
if (existingCapability === 'pr' || existingCapability === targetCapability) {
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.`
});
core.setOutput('capability', existingCapability);
console.log(`${issueAuthor} is already approved for ${existingCapability}`);
return;
}
content = content.trimEnd() + '\n' + issueAuthor + '\n';
fs.writeFileSync(approvedFile, content);
if (existingEntry) {
existingEntry.capability = targetCapability;
} else {
entries.push({ type: 'user', username: issueAuthor, normalizedUser: normalizedAuthor, capability: targetCapability });
}
console.log(`Added ${issueAuthor} to approved contributors`);
core.setOutput('status', 'added');
fs.writeFileSync(APPROVED_FILE, stringifyApprovedUsers(entries));
core.setOutput('status', existingCapability ? 'updated' : 'added');
core.setOutput('capability', targetCapability);
console.log(`Set ${issueAuthor} capability to ${targetCapability}`);
- name: Commit and push
if: steps.update.outputs.status == 'added'
if: steps.update.outputs.status == 'added' || steps.update.outputs.status == 'updated'
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
@@ -87,14 +136,46 @@ jobs:
git push
- name: Comment on issue
if: steps.update.outputs.status == 'added'
if: steps.update.outputs.status == 'added' || steps.update.outputs.status == 'updated' || steps.update.outputs.status == 'already'
uses: actions/github-script@v7
with:
script: |
const issueAuthor = context.payload.issue.user.login;
const capability = '${{ steps.update.outputs.capability }}';
const defaultBranch = context.payload.repository.default_branch;
let body;
if ('${{ steps.update.outputs.status }}' === 'already') {
body = `@${issueAuthor} is already approved.`;
} else if (capability === 'issue') {
body = [
`@${issueAuthor} approved for issues. Your future issues will not be auto-closed. PRs still require \`lgtm\`.`,
'',
`See [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md).`,
].join('\n');
} else {
body = [
`@${issueAuthor} approved for issues and PRs. Your future issues and PRs will not be auto-closed.`,
'',
`See [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md).`,
].join('\n');
}
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!`
body,
});
- name: Close issue after PR approval
if: (steps.update.outputs.status == 'added' || steps.update.outputs.status == 'updated') && steps.update.outputs.capability == 'pr'
uses: actions/github-script@v7
with:
script: |
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
state: 'closed',
});

View File

@@ -1,20 +1,22 @@
name: OSS Weekend Issues
name: Issue Gate
on:
issues:
types: [opened]
jobs:
close-issues-during-weekend:
check-contributor:
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
steps:
- name: Close new issues during OSS weekend
- name: Check issue author
uses: actions/github-script@v7
with:
script: |
const APPROVED_FILE = '.github/APPROVED_CONTRIBUTORS';
const VALID_CAPABILITIES = new Set(['issue', 'pr']);
const issueAuthor = context.payload.issue.user.login;
const defaultBranch = context.payload.repository.default_branch;
@@ -51,50 +53,55 @@ jobs:
return Buffer.from(fileContent.content, 'base64').toString('utf8');
}
function parseApprovedUsers(content) {
const users = new Map();
for (const rawLine of content.split('\n')) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
const parts = line.split(/\s+/);
if (parts.length !== 2) {
console.log(`Skipping malformed line: ${rawLine}`);
continue;
}
const [username, capability] = parts;
const normalizedCapability = capability.toLowerCase();
if (!VALID_CAPABILITIES.has(normalizedCapability)) {
console.log(`Skipping line with invalid capability: ${rawLine}`);
continue;
}
users.set(username.toLowerCase(), normalizedCapability);
}
return users;
}
const permission = await getPermission(issueAuthor);
if (['admin', 'maintain', 'write'].includes(permission)) {
console.log(`${issueAuthor} 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(issueAuthor.toLowerCase());
const approvedContent = await getTextFile(APPROVED_FILE);
const approvedUsers = parseApprovedUsers(approvedContent);
const capability = approvedUsers.get(issueAuthor.toLowerCase());
let weekendState;
try {
weekendState = JSON.parse(await getTextFile('.github/oss-weekend.json'));
} catch (error) {
if (error && typeof error === 'object' && 'status' in error && error.status === 404) {
console.log('OSS weekend is not active');
return;
}
throw error;
}
if (!weekendState?.active) {
console.log('OSS weekend is not active');
if (capability === 'issue' || capability === 'pr') {
console.log(`${issueAuthor} is approved for ${capability}`);
return;
}
if (isApprovedContributor) {
console.log(`${issueAuthor} is in the approved contributors list`);
return;
}
const reopenDate = weekendState.reopensOnText || weekendState.reopensOn || 'after the weekend';
const discordUrl = weekendState.discordUrl || 'https://discord.com/invite/3cU7Bz4UPx';
const reason = typeof weekendState.reason === 'string' && weekendState.reason.trim() ? weekendState.reason.trim() : null;
const message = [
`Hi @${issueAuthor}, thanks for opening an issue.`,
'This issue was auto-closed. All issues from new contributors are auto-closed by default.',
'',
`OSS weekend is active until ${reopenDate}, so new issues from unapproved contributors are being auto-closed for now.`,
...(reason ? ['', `Current focus: ${reason}`] : []),
`Maintainers review auto-closed issues daily and reopen worthwhile ones. Issues that do not meet the quality bar in [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md) will not be reopened or receive a reply.`,
'',
`Please reopen or submit this issue again after ${reopenDate}. For support, join [Discord](${discordUrl}).`,
'If a maintainer replies `lgtmi` on one of your issues, your future issues will stay open. If a maintainer replies `lgtm`, your future issues and PRs will stay open.',
'',
`See [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md).`,
].join('\n');
await github.rest.issues.createComment({

View File

@@ -32,6 +32,9 @@ jobs:
return;
}
const APPROVED_FILE = '.github/APPROVED_CONTRIBUTORS';
const VALID_CAPABILITIES = new Set(['issue', 'pr']);
// --- Check APPROVED_CONTRIBUTORS ---
async function getTextFile(path) {
const { data } = await github.rest.repos.getContent({
@@ -47,12 +50,23 @@ jobs:
}
try {
const content = await getTextFile('.github/APPROVED_CONTRIBUTORS');
const approved = content
.split('\n')
.map(l => l.trim().toLowerCase())
.filter(l => l && !l.startsWith('#'));
if (approved.includes(author.toLowerCase())) {
const content = await getTextFile(APPROVED_FILE);
const approved = new Map();
for (const rawLine of content.split('\n')) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
const parts = line.split(/\s+/);
if (parts.length !== 2) continue;
const [username, capability] = parts;
const normalizedCapability = capability.toLowerCase();
if (!VALID_CAPABILITIES.has(normalizedCapability)) continue;
approved.set(username.toLowerCase(), normalizedCapability);
}
if (approved.has(author.toLowerCase())) {
console.log(`${author} is in APPROVED_CONTRIBUTORS, passing`);
return;
}

View File

@@ -16,6 +16,8 @@ jobs:
uses: actions/github-script@v7
with:
script: |
const APPROVED_FILE = '.github/APPROVED_CONTRIBUTORS';
const VALID_CAPABILITIES = new Set(['issue', 'pr']);
const prAuthor = context.payload.pull_request.user.login;
const defaultBranch = context.payload.repository.default_branch;
@@ -52,6 +54,32 @@ jobs:
return Buffer.from(fileContent.content, 'base64').toString('utf8');
}
function parseApprovedUsers(content) {
const users = new Map();
for (const rawLine of content.split('\n')) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
const parts = line.split(/\s+/);
if (parts.length !== 2) {
console.log(`Skipping malformed line: ${rawLine}`);
continue;
}
const [username, capability] = parts;
const normalizedCapability = capability.toLowerCase();
if (!VALID_CAPABILITIES.has(normalizedCapability)) {
console.log(`Skipping line with invalid capability: ${rawLine}`);
continue;
}
users.set(username.toLowerCase(), normalizedCapability);
}
return users;
}
async function closePullRequest(message) {
await github.rest.issues.createComment({
owner: context.repo.owner,
@@ -74,31 +102,25 @@ jobs:
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());
const approvedContent = await getTextFile(APPROVED_FILE);
const approvedUsers = parseApprovedUsers(approvedContent);
const capability = approvedUsers.get(prAuthor.toLowerCase());
if (isApprovedContributor) {
console.log(`${prAuthor} is in the approved contributors list`);
if (capability === 'pr') {
console.log(`${prAuthor} is approved for PRs`);
return;
}
console.log(`${prAuthor} is not approved, closing PR`);
const message = [
`Hi @${prAuthor}, thanks for your interest in contributing!`,
'This PR was auto-closed. Only contributors approved with `lgtm` can open PRs. Open an issue first.',
'',
'We ask new contributors to open an issue first before submitting a PR. This helps us discuss the approach and avoid wasted effort.',
`Maintainers review auto-closed issues daily. Issues that do not meet the quality bar in [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md) will not be reopened or receive a reply.`,
'',
'**Next steps:**',
'1. Open an issue describing what you want to change and why (keep it concise, write in your human voice, AI slop will be closed)',
'2. Once a maintainer approves with `lgtm`, you\'ll be added to the approved contributors list',
'3. Then you can submit your PR',
'If a maintainer replies `lgtmi`, your future issues will stay open. If a maintainer replies `lgtm`, your future issues and PRs will stay open.',
'',
`This PR will be closed automatically. See https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md for more details.`,
`See [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md).`,
].join('\n');
await closePullRequest(message);