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

@@ -1,147 +1,147 @@
# GitHub handles of users approved to submit PRs # GitHub handles approved to bypass contribution auto-close
# Format: <username> <capability>
# capability:
# issue future issues stay open
# pr future issues and PRs stay open
# One handle per line (without @) barapa pr
alasano pr
# Add new contributors by commenting lgtm on their issue aadishv pr
airtonix pr
barapa aliou pr
alasano aos pr
aadishv austinm911 pr
airtonix banteg pr
aliou ben-vargas pr
aos butelo pr
austinm911 can1357 pr
banteg CarlosGtrz pr
ben-vargas cau1k pr
butelo cmf pr
can1357 crcatala pr
CarlosGtrz Cursivez pr
cau1k cv pr
cmf dannote pr
crcatala default-anton pr
Cursivez dnouri pr
cv DronNick pr
dannote enisdenjo pr
default-anton ferologics pr
dnouri fightbulc pr
DronNick ghoulr pr
enisdenjo gnattu pr
ferologics HACKE-RC pr
fightbulc hewliyang pr
ghoulr hjanuschka pr
gnattu iamd3vil pr
HACKE-RC jblwilliams pr
hewliyang joshp123 pr
hjanuschka jsinge97 pr
iamd3vil justram pr
jblwilliams kaofelix pr
joshp123 kiliman pr
jsinge97 kim0 pr
justram lockmeister pr
kaofelix LukeFost pr
kiliman lukele pr
kim0 m-box-mr pr
lockmeister marckrenn pr
LukeFost markusylisiurunen pr
lukele mcinteerj pr
m-box-mr melihmucuk pr
marckrenn mitsuhiko pr
markusylisiurunen mrexodia pr
mcinteerj nathyong pr
melihmucuk nickseelert pr
mitsuhiko nicobailon pr
mrexodia ninlds pr
nathyong ogulcancelik pr
nickseelert patrick-kidger pr
nicobailon paulbettner pr
ninlds Perlence pr
ogulcancelik pjtf93 pr
patrick-kidger prateekmedia pr
paulbettner prathamdby pr
Perlence ribelo pr
pjtf93 richardgill pr
prateekmedia robinwander pr
prathamdby ronyrus pr
ribelo roshanasingh4 pr
richardgill scutifer pr
robinwander skuridin pr
ronyrus steipete pr
roshanasingh4 svkozak pr
scutifer tallshort pr
skuridin theBucky pr
steipete thomasmhr pr
svkozak tiagoefreitas pr
tallshort timolins pr
theBucky tmustier pr
thomasmhr tudoroancea pr
tiagoefreitas unexge pr
timolins vaayne pr
tmustier VaclavSynacek pr
tudoroancea vsabavat pr
unexge w-winter pr
vaayne Whamp pr
VaclavSynacek WismutHansen pr
vsabavat XesGaDeus pr
w-winter yevhen pr
Whamp badlogictest pr
WismutHansen terrorobe pr
XesGaDeus zedrdave pr
yevhen mrud pr
badlogictest toorusr pr
terrorobe andresaraujo pr
zedrdave lightningRalf pr
mrud williballenthin pr
toorusr masonc15 pr
andresaraujo 4h9fbZ pr
lightningRalf haoqixu pr
williballenthin Graffioh pr
masonc15 charles-cooper pr
4h9fbZ emanuelst pr
haoqixu juanibiapina pr
Graffioh liby pr
charles-cooper pasky pr
emanuelst odysseus0 pr
juanibiapina giuseppeg pr
liby michaelpersonal pr
pasky academo pr
odysseus0 PriNova pr
giuseppeg semtexzv pr
michaelpersonal jasonish pr
academo markusn pr
PriNova SamFold pr
semtexzv Soleone pr
jasonish virtuald pr
markusn NateSmyth pr
SamFold 7Sageer pr
Soleone MatthieuBizien pr
virtuald sumeet pr
NateSmyth marchellodev pr
7Sageer vedang pr
MatthieuBizien lucemia pr
sumeet mcollina pr
marchellodev lajarre pr
vedang smithbm2316 pr
lucemia drewburr pr
mcollina gordonhwc pr
lajarre deybhayden pr
smithbm2316 tintinweb pr
drewburr asoules pr
gordonhwc zhahaoyu pr
deybhayden in0vik pr
tintinweb jtac pr
asoules yzhg1983 pr
zhahaoyu smcllns pr
in0vik dmmulroy pr
jtac zmberber pr
yzhg1983 andresvi94 pr
smcllns bjesuiter pr
dmmulroy sudosubin pr
zmberber dyyz1993 pr
andresvi94 Mic92 pr
bjesuiter pmateusz pr
sudosubin wirjo pr
dyyz1993 jay-aye-see-kay pr
Mic92
pmateusz
wirjo
jay-aye-see-kay

View File

@@ -1,118 +0,0 @@
# GitHub handles of users approved to submit PRs
# One handle per line (without @)
# Add new contributors by commenting lgtm on their issue
aadishv
airtonix
aliou
aos
austinm911
banteg
ben-vargas
butelo
can1357
CarlosGtrz
cau1k
cmf
crcatala
Cursivez
cv
dannote
default-anton
dnouri
DronNick
enisdenjo
ferologics
fightbulc
ghoulr
gnattu
HACKE-RC
hewliyang
hjanuschka
iamd3vil
jblwilliams
joshp123
jsinge97
justram
kaofelix
kiliman
kim0
lockmeister
LukeFost
lukele
m-box-mr
marckrenn
markusylisiurunen
mcinteerj
melihmucuk
mitsuhiko
mrexodia
nathyong
nickseelert
nicobailon
ninlds
ogulcancelik
patrick-kidger
paulbettner
Perlence
pjtf93
prateekmedia
prathamdby
ribelo
richardgill
robinwander
ronyrus
roshanasingh4
scutifer
skuridin
steipete
svkozak
tallshort
theBucky
thomasmhr
tiagoefreitas
timolins
tmustier
tudoroancea
unexge
vaayne
VaclavSynacek
vsabavat
w-winter
Whamp
WismutHansen
XesGaDeus
yevhen
badlogictest
terrorobe
zedrdave
mrud
toorusr
andresaraujo
lightningRalf
williballenthin
masonc15
4h9fbZ
haoqixu
Graffioh
charles-cooper
emanuelst
juanibiapina
liby
pasky
odysseus0
giuseppeg
michaelpersonal
academo
PriNova
semtexzv
jasonish
markusn
SamFold
Soleone
virtuald
NateSmyth
7Sageer
MatthieuBizien
sumeet
marchellodev
vedang

View File

@@ -2,6 +2,15 @@ name: Bug Report
description: Report something that's broken description: Report something that's broken
labels: ["bug"] labels: ["bug"]
body: body:
- type: markdown
attributes:
value: |
**Before you start:** Read [CONTRIBUTING.md](https://github.com/badlogic/pi-mono/blob/main/CONTRIBUTING.md).
New issues from new contributors are auto-closed by default. Maintainers review auto-closed issues daily. Issues that do not meet the quality bar in [CONTRIBUTING.md](https://github.com/badlogic/pi-mono/blob/main/CONTRIBUTING.md) will not be reopened or receive a reply.
Keep this short. If it doesn't fit on one screen, it's too long. Write in your own voice.
- type: textarea - type: textarea
id: description id: description
attributes: attributes:

View File

@@ -6,7 +6,9 @@ body:
attributes: attributes:
value: | value: |
**Before you start:** Read [CONTRIBUTING.md](https://github.com/badlogic/pi-mono/blob/main/CONTRIBUTING.md). **Before you start:** Read [CONTRIBUTING.md](https://github.com/badlogic/pi-mono/blob/main/CONTRIBUTING.md).
New issues from new contributors are auto-closed by default. Maintainers review auto-closed issues daily. Issues that do not meet the quality bar in [CONTRIBUTING.md](https://github.com/badlogic/pi-mono/blob/main/CONTRIBUTING.md) will not be reopened or receive a reply.
Keep this short. If it doesn't fit on one screen, it's too long. Write in your own voice. Keep this short. If it doesn't fit on one screen, it's too long. Write in your own voice.
- type: textarea - type: textarea

View File

@@ -1,10 +0,0 @@
{
"active": true,
"mode": "weekend",
"startsAt": "2026-04-13T20:58:48.644Z",
"startsAtText": "Monday, April 13, 2026",
"reopensOn": "2026-04-20",
"reopensOnText": "Monday, April 20, 2026",
"reason": null,
"discordUrl": "https://discord.com/invite/3cU7Bz4UPx"
}

View File

@@ -17,20 +17,26 @@ jobs:
with: with:
ref: ${{ github.event.repository.default_branch }} ref: ${{ github.event.repository.default_branch }}
- name: Add contributor to approved list - name: Update contributor approval
id: update id: update
uses: actions/github-script@v7 uses: actions/github-script@v7
with: with:
script: | script: |
const fs = require('fs'); 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 issueAuthor = context.payload.issue.user.login;
const commenter = context.payload.comment.user.login; const commenter = context.payload.comment.user.login;
const commentBody = context.payload.comment.body || ''; const commentBody = (context.payload.comment.body || '').trim();
const approvedFile = '.github/APPROVED_CONTRIBUTORS';
if (!/^\s*lgtm\b/i.test(commentBody)) { let targetCapability;
console.log('Comment does not match lgtm'); 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'); core.setOutput('status', 'skipped');
return; return;
} }
@@ -39,46 +45,89 @@ jobs:
const { data: permissionLevel } = await github.rest.repos.getCollaboratorPermissionLevel({ const { data: permissionLevel } = await github.rest.repos.getCollaboratorPermissionLevel({
owner: context.repo.owner, owner: context.repo.owner,
repo: context.repo.repo, 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`); console.log(`${commenter} does not have write access`);
core.setOutput('status', 'skipped'); core.setOutput('status', 'skipped');
return; return;
} }
} catch (error) { } catch {
console.log(`${commenter} does not have collaborator access`); console.log(`${commenter} does not have collaborator access`);
core.setOutput('status', 'skipped'); core.setOutput('status', 'skipped');
return; return;
} }
let content = fs.readFileSync(approvedFile, 'utf8'); function parseApprovedUsers(content) {
const approvedList = content const lines = content.split('\n');
.split('\n') const entries = [];
.map(line => line.trim().toLowerCase()) const users = new Map();
.filter(line => line && !line.startsWith('#'));
if (approvedList.includes(issueAuthor.toLowerCase())) { for (const line of lines) {
console.log(`${issueAuthor} is already approved`); 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'); core.setOutput('status', 'already');
await github.rest.issues.createComment({ core.setOutput('capability', existingCapability);
owner: context.repo.owner, console.log(`${issueAuthor} is already approved for ${existingCapability}`);
repo: context.repo.repo,
issue_number: context.issue.number,
body: `@${issueAuthor} is already in the approved contributors list.`
});
return; return;
} }
content = content.trimEnd() + '\n' + issueAuthor + '\n'; if (existingEntry) {
fs.writeFileSync(approvedFile, content); existingEntry.capability = targetCapability;
} else {
entries.push({ type: 'user', username: issueAuthor, normalizedUser: normalizedAuthor, capability: targetCapability });
}
console.log(`Added ${issueAuthor} to approved contributors`); fs.writeFileSync(APPROVED_FILE, stringifyApprovedUsers(entries));
core.setOutput('status', 'added'); core.setOutput('status', existingCapability ? 'updated' : 'added');
core.setOutput('capability', targetCapability);
console.log(`Set ${issueAuthor} capability to ${targetCapability}`);
- name: Commit and push - name: Commit and push
if: steps.update.outputs.status == 'added' if: steps.update.outputs.status == 'added' || steps.update.outputs.status == 'updated'
run: | run: |
git config user.name "github-actions[bot]" git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com" git config user.email "github-actions[bot]@users.noreply.github.com"
@@ -87,14 +136,46 @@ jobs:
git push git push
- name: Comment on issue - 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 uses: actions/github-script@v7
with: with:
script: | script: |
const issueAuthor = context.payload.issue.user.login; 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({ await github.rest.issues.createComment({
owner: context.repo.owner, owner: context.repo.owner,
repo: context.repo.repo, repo: context.repo.repo,
issue_number: context.issue.number, 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: on:
issues: issues:
types: [opened] types: [opened]
jobs: jobs:
close-issues-during-weekend: check-contributor:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
issues: write issues: write
steps: steps:
- name: Close new issues during OSS weekend - name: Check issue author
uses: actions/github-script@v7 uses: actions/github-script@v7
with: with:
script: | script: |
const APPROVED_FILE = '.github/APPROVED_CONTRIBUTORS';
const VALID_CAPABILITIES = new Set(['issue', 'pr']);
const issueAuthor = context.payload.issue.user.login; const issueAuthor = context.payload.issue.user.login;
const defaultBranch = context.payload.repository.default_branch; const defaultBranch = context.payload.repository.default_branch;
@@ -51,50 +53,55 @@ jobs:
return Buffer.from(fileContent.content, 'base64').toString('utf8'); 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); const permission = await getPermission(issueAuthor);
if (['admin', 'maintain', 'write'].includes(permission)) { if (['admin', 'maintain', 'write'].includes(permission)) {
console.log(`${issueAuthor} is a collaborator with ${permission} access`); console.log(`${issueAuthor} is a collaborator with ${permission} access`);
return; return;
} }
const approvedContent = await getTextFile('.github/APPROVED_CONTRIBUTORS'); const approvedContent = await getTextFile(APPROVED_FILE);
const approvedList = approvedContent const approvedUsers = parseApprovedUsers(approvedContent);
.split('\n') const capability = approvedUsers.get(issueAuthor.toLowerCase());
.map(line => line.trim().toLowerCase())
.filter(line => line && !line.startsWith('#'));
const isApprovedContributor = approvedList.includes(issueAuthor.toLowerCase());
let weekendState; if (capability === 'issue' || capability === 'pr') {
try { console.log(`${issueAuthor} is approved for ${capability}`);
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');
return; 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 = [ 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.`, `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.`,
...(reason ? ['', `Current focus: ${reason}`] : []),
'', '',
`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'); ].join('\n');
await github.rest.issues.createComment({ await github.rest.issues.createComment({

View File

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

View File

@@ -16,6 +16,8 @@ jobs:
uses: actions/github-script@v7 uses: actions/github-script@v7
with: with:
script: | script: |
const APPROVED_FILE = '.github/APPROVED_CONTRIBUTORS';
const VALID_CAPABILITIES = new Set(['issue', 'pr']);
const prAuthor = context.payload.pull_request.user.login; const prAuthor = context.payload.pull_request.user.login;
const defaultBranch = context.payload.repository.default_branch; const defaultBranch = context.payload.repository.default_branch;
@@ -52,6 +54,32 @@ jobs:
return Buffer.from(fileContent.content, 'base64').toString('utf8'); 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) { async function closePullRequest(message) {
await github.rest.issues.createComment({ await github.rest.issues.createComment({
owner: context.repo.owner, owner: context.repo.owner,
@@ -74,31 +102,25 @@ jobs:
return; return;
} }
const approvedContent = await getTextFile('.github/APPROVED_CONTRIBUTORS'); const approvedContent = await getTextFile(APPROVED_FILE);
const approvedList = approvedContent const approvedUsers = parseApprovedUsers(approvedContent);
.split('\n') const capability = approvedUsers.get(prAuthor.toLowerCase());
.map(line => line.trim().toLowerCase())
.filter(line => line && !line.startsWith('#'));
const isApprovedContributor = approvedList.includes(prAuthor.toLowerCase());
if (isApprovedContributor) { if (capability === 'pr') {
console.log(`${prAuthor} is in the approved contributors list`); console.log(`${prAuthor} is approved for PRs`);
return; return;
} }
console.log(`${prAuthor} is not approved, closing PR`); console.log(`${prAuthor} is not approved, closing PR`);
const message = [ 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:**', 'If a maintainer replies `lgtmi`, your future issues will stay open. If a maintainer replies `lgtm`, your future issues and PRs will stay open.',
'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',
'', '',
`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'); ].join('\n');
await closePullRequest(message); await closePullRequest(message);

View File

@@ -40,12 +40,14 @@ When reading issues:
gh issue view <number> --json title,body,comments,labels,state gh issue view <number> --json title,body,comments,labels,state
``` ```
## OSS Weekend ## Contribution Gate
- If the user says `enable OSS weekend mode until X`, run `node scripts/oss-weekend.mjs --mode=close --end-date=YYYY-MM-DD --git` with the requested end date - New issues from new contributors are auto-closed by `.github/workflows/issue-gate.yml`
- If the user says `end OSS weekend mode`, run `node scripts/oss-weekend.mjs --mode=open --git` - New PRs from new contributors without PR rights are auto-closed by `.github/workflows/pr-gate.yml`
- The script updates `README.md`, `packages/coding-agent/README.md`, and `.github/oss-weekend.json` - Maintainer approval comments are handled by `.github/workflows/approve-contributor.yml`
- With `--git`, the script stages only those OSS weekend files, commits them, and pushes them - Maintainers review auto-closed issues daily
- During OSS weekend, `.github/workflows/oss-weekend-issues.yml` auto-closes new issues from non-maintainers, and `.github/workflows/pr-gate.yml` auto-closes PRs from approved non-maintainers with the weekend message - Issues that do not meet the quality bar in `CONTRIBUTING.md` are not reopened and do not receive a reply
- `lgtmi` approves future issues
- `lgtm` approves future issues and rights to submit PRs
When creating issues: When creating issues:
- Add `pkg:*` labels to indicate which package(s) the issue affects - Add `pkg:*` labels to indicate which package(s) the issue affects

View File

@@ -1,42 +1,69 @@
# Contributing to pi # Contributing to pi
Thanks for wanting to contribute! This guide exists to save both of us time. This guide exists to save both sides time.
## The One Rule ## The One Rule
**You must understand your code.** If you can't explain what your changes do and how they interact with the rest of the system, your PR will be closed. **You must understand your code.** If you cannot explain what your changes do and how they interact with the rest of the system, your PR will be closed.
Using AI to write code is fine. You can gain understanding by interrogating an agent with access to the codebase until you grasp all edge cases and effects of your changes. What's not fine is submitting agent-generated slop without that understanding. Using AI to write code is fine. Submitting AI-generated slop without understanding it is not.
If you use an agent, run it from the `pi-mono` root directory so it picks up `AGENTS.md` automatically. Your agent must follow the rules and guidelines in that file. If you use an agent, run it from the `pi-mono` root directory so it picks up `AGENTS.md` automatically. Your agent must follow the rules and guidelines in that file.
## First-Time Contributors ## Contribution Gate
We use an approval gate for new contributors: All issues and PRs from new contributors are auto-closed by default.
1. Open an issue describing what you want to change and why Maintainers review auto-closed issues daily and reopen worthwhile ones. Issues that do not meet the quality bar below will not be reopened or receive a reply.
2. Keep it concise (if it doesn't fit on one screen, it's too long)
3. Write in your own voice, at least for the intro
4. A maintainer will comment `lgtm` if approved
5. Once approved, you can submit PRs
This exists because AI makes it trivial to generate plausible-looking but low-quality contributions. The issue step lets us filter early. Approval happens through maintainer replies on issues:
- `lgtmi`: your future issues will not be auto-closed
- `lgtm`: your future issues and PRs will not be auto-closed
`lgtmi` does not grant rights to submit PRs. Only `lgtm` grants rights to submit PRs.
## Quality Bar For Issues
If you open an issue, you must use one of the two GitHub issue templates.
If you open an issue, keep it short, concrete, and worth reading.
- Keep it concise. If it does not fit on one screen, it is too long.
- Write in your own voice.
- State the bug or request clearly.
- Explain why it matters.
- If you want to implement the change yourself, say so.
If the issue is real and written well, a maintainer may reopen it, reply `lgtmi`, or reply `lgtm`.
## Blocking
If you ignore this document twice, or if you spam the tracker with agent-generated issues, your GitHub account will be permanently blocked.
If you send a large volume of issues through automation, your GitHub account will be permanently blocked. No taksies backsies.
## Before Submitting a PR ## Before Submitting a PR
Do not open a PR unless you have already been approved with `lgtm`.
Before submitting a PR:
```bash ```bash
npm run check # must pass with no errors npm run check
./test.sh # must pass ./test.sh
``` ```
Both must pass.
Do not edit `CHANGELOG.md`. Changelog entries are added by maintainers. Do not edit `CHANGELOG.md`. Changelog entries are added by maintainers.
If you're adding a new provider to `packages/ai`, see `AGENTS.md` for required tests. If you are adding a new provider to `packages/ai`, see `AGENTS.md` for required tests.
## Philosophy ## Philosophy
pi's core is minimal. If your feature doesn't belong in the core, it should be an extension. PRs that bloat the core will likely be rejected. pi's core is minimal. If your feature does not belong in the core, it should be an extension. PRs that bloat the core will likely be rejected.
## Questions? ## Questions?
Open an issue or ask on [Discord](https://discord.com/invite/nKXTsAcmbT). Ask on [Discord](https://discord.com/invite/nKXTsAcmbT).

View File

@@ -1,13 +1,3 @@
<!-- OSS_WEEKEND_START -->
# 🏖️ OSS Weekend
**Issue tracker reopens Monday, April 20, 2026.**
OSS weekend runs Monday, April 13, 2026 through Monday, April 20, 2026. New issues and PRs from unapproved contributors are auto-closed during this time. Approved contributors can still open issues and PRs if something is genuinely urgent, but please keep that to pressing matters only. For support, join [Discord](https://discord.com/invite/3cU7Bz4UPx).
<!-- OSS_WEEKEND_END -->
---
<p align="center"> <p align="center">
<a href="https://shittycodingagent.ai"> <a href="https://shittycodingagent.ai">
<img src="https://shittycodingagent.ai/logo.svg" alt="pi logo" width="128"> <img src="https://shittycodingagent.ai/logo.svg" alt="pi logo" width="128">
@@ -23,6 +13,10 @@ OSS weekend runs Monday, April 13, 2026 through Monday, April 20, 2026. New issu
<a href="https://exe.dev"><img src="packages/coding-agent/docs/images/exy.png" alt="Exy mascot" width="48" /><br />exe.dev</a> <a href="https://exe.dev"><img src="packages/coding-agent/docs/images/exy.png" alt="Exy mascot" width="48" /><br />exe.dev</a>
</p> </p>
> New issues and PRs from new contributors are auto-closed by default. Maintainers review auto-closed issues daily. See [CONTRIBUTING.md](CONTRIBUTING.md).
---
# Pi Monorepo # Pi Monorepo
> **Looking for the pi coding agent?** See **[packages/coding-agent](packages/coding-agent)** for installation and usage. > **Looking for the pi coding agent?** See **[packages/coding-agent](packages/coding-agent)** for installation and usage.

View File

@@ -1,13 +1,3 @@
<!-- OSS_WEEKEND_START -->
# 🏖️ OSS Weekend
**Issue tracker reopens Monday, April 20, 2026.**
OSS weekend runs Monday, April 13, 2026 through Monday, April 20, 2026. New issues and PRs from unapproved contributors are auto-closed during this time. Approved contributors can still open issues and PRs if something is genuinely urgent, but please keep that to pressing matters only. For support, join [Discord](https://discord.com/invite/3cU7Bz4UPx).
<!-- OSS_WEEKEND_END -->
---
<p align="center"> <p align="center">
<a href="https://shittycodingagent.ai"> <a href="https://shittycodingagent.ai">
<img src="https://shittycodingagent.ai/logo.svg" alt="pi logo" width="128"> <img src="https://shittycodingagent.ai/logo.svg" alt="pi logo" width="128">
@@ -24,6 +14,10 @@ OSS weekend runs Monday, April 13, 2026 through Monday, April 20, 2026. New issu
<a href="https://exe.dev"><img src="docs/images/exy.png" alt="Exy mascot" width="48" /><br />exe.dev</a> <a href="https://exe.dev"><img src="docs/images/exy.png" alt="Exy mascot" width="48" /><br />exe.dev</a>
</p> </p>
> New issues and PRs from new contributors are auto-closed by default. Maintainers review auto-closed issues daily. See [CONTRIBUTING.md](../../CONTRIBUTING.md).
---
Pi is a minimal terminal coding harness. Adapt pi to your workflows, not the other way around, without having to fork and modify pi internals. Extend it with TypeScript [Extensions](#extensions), [Skills](#skills), [Prompt Templates](#prompt-templates), and [Themes](#themes). Put your extensions, skills, prompt templates, and themes in [Pi Packages](#pi-packages) and share them with others via npm or git. Pi is a minimal terminal coding harness. Adapt pi to your workflows, not the other way around, without having to fork and modify pi internals. Extend it with TypeScript [Extensions](#extensions), [Skills](#skills), [Prompt Templates](#prompt-templates), and [Themes](#themes). Put your extensions, skills, prompt templates, and themes in [Pi Packages](#pi-packages) and share them with others via npm or git.
Pi ships with powerful defaults but skips features like sub agents and plan mode. Instead, you can ask pi to build what you want or install a third party pi package that matches your workflow. Pi ships with powerful defaults but skips features like sub agents and plan mode. Instead, you can ask pi to build what you want or install a third party pi package that matches your workflow.

View File

@@ -1,353 +0,0 @@
import { execFileSync } from "node:child_process";
import { readFile, rm, writeFile } from "node:fs/promises";
import process from "node:process";
const TIME_ZONE = "Europe/Berlin";
const DEFAULT_README_PATHS = ["README.md", "packages/coding-agent/README.md"];
const DEFAULT_STATE_PATH = ".github/oss-weekend.json";
const MARKER_START = "<!-- OSS_WEEKEND_START -->";
const MARKER_END = "<!-- OSS_WEEKEND_END -->";
const DISCORD_URL = "https://discord.com/invite/3cU7Bz4UPx";
function normalizeReason(reason) {
const trimmedReason = reason.trim();
return trimmedReason ? trimmedReason : null;
}
function parseArgs(argv) {
const options = {};
for (const arg of argv) {
if (!arg.startsWith("--")) continue;
const trimmedArg = arg.slice(2);
const separatorIndex = trimmedArg.indexOf("=");
if (separatorIndex === -1) {
options[trimmedArg] = "true";
continue;
}
const key = trimmedArg.slice(0, separatorIndex);
const value = trimmedArg.slice(separatorIndex + 1);
options[key] = value;
}
return options;
}
function getOption(name, cliOptions, envName, fallback) {
const cliValue = cliOptions[name];
if (cliValue !== undefined) return cliValue;
const envValue = process.env[envName];
if (envValue !== undefined && envValue !== "") return envValue;
return fallback;
}
function isTruthy(value) {
return ["1", "true", "yes", "on"].includes(String(value).toLowerCase());
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function formatLongDate(date) {
return new Intl.DateTimeFormat("en-US", {
timeZone: TIME_ZONE,
weekday: "long",
month: "long",
day: "numeric",
year: "numeric",
}).format(date);
}
function parseDateInput(value) {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
if (!match) {
throw new Error(`Invalid end date: ${value}. Use YYYY-MM-DD.`);
}
const [, year, month, day] = match;
return new Date(Date.UTC(Number(year), Number(month) - 1, Number(day), 12, 0, 0));
}
function buildBanner(now, endDate, reason) {
const startDate = formatLongDate(now);
const reopenDate = formatLongDate(endDate);
const normalizedReason = normalizeReason(reason ?? "");
return [
MARKER_START,
"# 🏖️ OSS Weekend",
"",
`**Issue tracker reopens ${reopenDate}.**`,
"",
`OSS weekend runs ${startDate} through ${reopenDate}. New issues and PRs from unapproved contributors are auto-closed during this time. Approved contributors can still open issues and PRs if something is genuinely urgent, but please keep that to pressing matters only. For support, join [Discord](${DISCORD_URL}).`,
...(normalizedReason ? ["", `> _Current focus: ${normalizedReason}_`] : []),
MARKER_END,
"",
"---",
"",
"",
].join("\n");
}
function upsertBanner(readme, now, endDate, reason) {
const banner = buildBanner(now, endDate, reason);
const bannerPattern = new RegExp(
`${escapeRegExp(MARKER_START)}[\\s\\S]*?${escapeRegExp(MARKER_END)}\\n\\n---\\n\\n?`,
"m",
);
if (bannerPattern.test(readme)) {
return readme.replace(bannerPattern, banner);
}
return `${banner}${readme}`;
}
function removeBanner(readme) {
const bannerPattern = new RegExp(
`^${escapeRegExp(MARKER_START)}[\\s\\S]*?${escapeRegExp(MARKER_END)}\\n\\n---\\n\\n?`,
"m",
);
return readme.replace(bannerPattern, "");
}
function parseReadmePaths(cliOptions) {
const readmeOption = getOption("readme", cliOptions, "OSS_WEEKEND_README_PATH", "");
if (!readmeOption) return DEFAULT_README_PATHS;
return readmeOption
.split(",")
.map((path) => path.trim())
.filter(Boolean);
}
function buildState(now, endDateInput, endDate, reason) {
const normalizedReason = normalizeReason(reason ?? "");
return JSON.stringify(
{
active: true,
mode: "weekend",
startsAt: now.toISOString(),
startsAtText: formatLongDate(now),
reopensOn: endDateInput,
reopensOnText: formatLongDate(endDate),
reason: normalizedReason,
discordUrl: DISCORD_URL,
},
null,
2,
);
}
async function readOptionalFile(path) {
try {
return await readFile(path, "utf8");
} catch (error) {
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
return null;
}
throw error;
}
}
function runCommand(command, args, options = {}) {
return execFileSync(command, args, { encoding: "utf8", ...options });
}
function quoteArg(arg) {
return /[^A-Za-z0-9_./:=@-]/.test(arg) ? JSON.stringify(arg) : arg;
}
function formatCommand(command, args) {
return [command, ...args].map(quoteArg).join(" ");
}
function hasStagedChanges(paths) {
try {
runCommand("git", ["diff", "--cached", "--quiet", "--", ...paths], { stdio: "ignore" });
return false;
} catch {
return true;
}
}
function runGitOperations(mode, paths, dryRun) {
const commitMessage = mode === "close" ? "docs: enable OSS weekend" : "docs: disable OSS weekend";
const addArgs = ["add", "--", ...paths];
const pushArgs = ["push"];
const commands = [formatCommand("git", addArgs)];
if (dryRun) {
commands.push(`git commit -m ${quoteArg(commitMessage)}`);
commands.push(formatCommand("git", pushArgs));
return {
commitMessage,
commands,
committed: false,
pushed: false,
stagedChanges: false,
};
}
runCommand("git", addArgs, { stdio: "inherit" });
if (!hasStagedChanges(paths)) {
return {
commitMessage,
commands,
committed: false,
pushed: false,
stagedChanges: false,
};
}
const commitArgs = ["commit", "-m", commitMessage];
commands.push(formatCommand("git", commitArgs));
runCommand("git", commitArgs, { stdio: "inherit" });
commands.push(formatCommand("git", pushArgs));
runCommand("git", pushArgs, { stdio: "inherit" });
return {
commitMessage,
commands,
committed: true,
pushed: true,
stagedChanges: true,
};
}
function printUsage() {
process.stdout.write(
[
"Usage:",
" node scripts/oss-weekend.mjs --mode=close --end-date=2026-03-23",
" node scripts/oss-weekend.mjs --mode=close --end-date=2026-03-23 --git",
" node scripts/oss-weekend.mjs --mode=open",
" node scripts/oss-weekend.mjs --mode=open --git",
"",
"Options:",
" --mode=close|open Required. close enables OSS weekend mode. open disables it.",
" --end-date=YYYY-MM-DD Required for --mode=close.",
" --readme=PATHS Optional comma-separated README paths. Defaults to README.md,packages/coding-agent/README.md.",
" --state=PATH Optional state file path. Defaults to .github/oss-weekend.json.",
" --reason=TEXT Optional reason shown in the README banner and weekend auto-close comments.",
" --git Stage only the OSS weekend files, commit, and push after updating them.",
" --dry-run Preview without editing files or running git operations.",
" --now=ISO Optional current timestamp override for testing.",
" --help Show this message.",
"",
].join("\n"),
);
}
async function main() {
const cliOptions = parseArgs(process.argv.slice(2));
if (isTruthy(cliOptions.help ?? "false")) {
printUsage();
return;
}
const mode = getOption("mode", cliOptions, "OSS_WEEKEND_MODE", "");
if (mode !== "close" && mode !== "open") {
throw new Error("--mode must be close or open.");
}
const dryRun = isTruthy(getOption("dry-run", cliOptions, "OSS_WEEKEND_DRY_RUN", "false"));
const runGit = isTruthy(getOption("git", cliOptions, "OSS_WEEKEND_GIT", "false"));
const nowInput = getOption("now", cliOptions, "OSS_WEEKEND_NOW", "");
const readmePaths = parseReadmePaths(cliOptions);
const statePath = getOption("state", cliOptions, "OSS_WEEKEND_STATE_PATH", DEFAULT_STATE_PATH);
const endDateInput = getOption("end-date", cliOptions, "OSS_WEEKEND_END_DATE", "");
const reason = normalizeReason(getOption("reason", cliOptions, "OSS_WEEKEND_REASON", ""));
const now = nowInput ? new Date(nowInput) : new Date();
if (Number.isNaN(now.getTime())) {
throw new Error(`Invalid date: ${nowInput}`);
}
if (mode === "close" && !endDateInput) {
throw new Error("--end-date is required when --mode=close.");
}
const endDate = mode === "close" ? parseDateInput(endDateInput) : null;
const readmeResults = [];
for (const readmePath of readmePaths) {
const currentReadme = await readFile(readmePath, "utf8");
const nextReadme = mode === "close" ? upsertBanner(currentReadme, now, endDate, reason) : removeBanner(currentReadme);
const changed = nextReadme !== currentReadme;
if (changed && !dryRun) {
await writeFile(readmePath, nextReadme, "utf8");
}
readmeResults.push({ path: readmePath, changed });
}
const currentState = await readOptionalFile(statePath);
const nextState = mode === "close" ? buildState(now, endDateInput, endDate, reason) : null;
const stateChanged = mode === "close" ? currentState !== nextState : currentState !== null;
if (!dryRun) {
if (mode === "close") {
await writeFile(statePath, `${nextState}\n`, "utf8");
} else {
await rm(statePath, { force: true });
}
}
const gitPaths = [...readmePaths, statePath];
const gitResult = runGit ? runGitOperations(mode, gitPaths, dryRun) : null;
const output = {
mode,
dry_run: dryRun ? "true" : "false",
weekend_active: mode === "close" ? "true" : "false",
readme_paths: readmeResults.map((result) => result.path).join(","),
readme_changed: readmeResults.some((result) => result.changed) ? "true" : "false",
readme_changed_paths: readmeResults
.filter((result) => result.changed)
.map((result) => result.path)
.join(","),
state_path: statePath,
state_changed: stateChanged ? "true" : "false",
git_enabled: runGit ? "true" : "false",
git_paths: gitPaths.join(","),
git_commit_message: gitResult?.commitMessage ?? "",
git_committed: gitResult?.committed ? "true" : "false",
git_pushed: gitResult?.pushed ? "true" : "false",
git_commands: gitResult ? gitResult.commands.join(" && ") : "",
end_date: endDate ? endDateInput : "",
end_date_text: endDate ? formatLongDate(endDate) : "",
reason: reason ?? "",
now_utc: now.toISOString(),
now_berlin: new Intl.DateTimeFormat("sv-SE", {
timeZone: TIME_ZONE,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hourCycle: "h23",
}).format(now),
};
process.stdout.write(`${JSON.stringify(output, null, 2)}\n`);
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
printUsage();
process.exit(1);
});