Skip to content

Commit 53f5443

Browse files
authored
chore(automation): enforce 'help wanted' label permissions and update guidelines (#16707)
1 parent 409f9c8 commit 53f5443

File tree

4 files changed

+405
-4
lines changed

4 files changed

+405
-4
lines changed
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
/* eslint-disable */
8+
/* global require, console, process */
9+
10+
/**
11+
* Script to backfill a process change notification comment to all open PRs
12+
* not created by members of the 'gemini-cli-maintainers' team.
13+
*
14+
* Skip PRs that are already associated with an issue.
15+
*/
16+
17+
const { execFileSync } = require('child_process');
18+
19+
const isDryRun = process.argv.includes('--dry-run');
20+
const REPO = 'google-gemini/gemini-cli';
21+
const ORG = 'google-gemini';
22+
const TEAM_SLUG = 'gemini-cli-maintainers';
23+
const DISCUSSION_URL =
24+
'https://github.com/google-gemini/gemini-cli/discussions/16706';
25+
26+
/**
27+
* Executes a GitHub CLI command safely using an argument array.
28+
*/
29+
function runGh(args, options = {}) {
30+
const { silent = false } = options;
31+
try {
32+
return execFileSync('gh', args, {
33+
encoding: 'utf8',
34+
maxBuffer: 10 * 1024 * 1024,
35+
stdio: ['ignore', 'pipe', 'pipe'],
36+
}).trim();
37+
} catch (error) {
38+
if (!silent) {
39+
const stderr = error.stderr ? ` Stderr: ${error.stderr.trim()}` : '';
40+
console.error(
41+
`❌ Error running gh ${args.join(' ')}: ${error.message}${stderr}`,
42+
);
43+
}
44+
return null;
45+
}
46+
}
47+
48+
/**
49+
* Checks if a user is a member of the maintainers team.
50+
*/
51+
const membershipCache = new Map();
52+
function isMaintainer(username) {
53+
if (membershipCache.has(username)) return membershipCache.get(username);
54+
55+
// GitHub returns 404 if user is not a member.
56+
// We use silent: true to avoid logging 404s as errors.
57+
const result = runGh(
58+
['api', `orgs/${ORG}/teams/${TEAM_SLUG}/memberships/${username}`],
59+
{ silent: true },
60+
);
61+
62+
const isMember = result !== null;
63+
membershipCache.set(username, isMember);
64+
return isMember;
65+
}
66+
67+
async function main() {
68+
console.log('🔐 GitHub CLI security check...');
69+
if (runGh(['auth', 'status']) === null) {
70+
console.error('❌ GitHub CLI (gh) is not authenticated.');
71+
process.exit(1);
72+
}
73+
74+
if (isDryRun) {
75+
console.log('🧪 DRY RUN MODE ENABLED\n');
76+
}
77+
78+
console.log(`📥 Fetching open PRs from ${REPO}...`);
79+
// Fetch number, author, and closingIssuesReferences to check if linked to an issue
80+
const prsJson = runGh([
81+
'pr',
82+
'list',
83+
'--repo',
84+
REPO,
85+
'--state',
86+
'open',
87+
'--limit',
88+
'1000',
89+
'--json',
90+
'number,author,closingIssuesReferences',
91+
]);
92+
93+
if (prsJson === null) process.exit(1);
94+
const prs = JSON.parse(prsJson);
95+
96+
console.log(`📊 Found ${prs.length} open PRs. Filtering...`);
97+
98+
let targetPrs = [];
99+
for (const pr of prs) {
100+
const author = pr.author.login;
101+
const issueCount = pr.closingIssuesReferences
102+
? pr.closingIssuesReferences.length
103+
: 0;
104+
105+
if (issueCount > 0) {
106+
// Skip if already linked to an issue
107+
continue;
108+
}
109+
110+
if (!isMaintainer(author)) {
111+
targetPrs.push(pr);
112+
}
113+
}
114+
115+
console.log(
116+
`✅ Found ${targetPrs.length} PRs from non-maintainers without associated issues.`,
117+
);
118+
119+
const commentBody =
120+
"\nHi @{AUTHOR}, thank you so much for your contribution to Gemini CLI! We really appreciate the time and effort you've put into this.\n\nWe're making some updates to our contribution process to improve how we track and review changes. Please take a moment to review our recent discussion post: [Improving Our Contribution Process & Introducing New Guidelines](${DISCUSSION_URL}).\n\nKey Update: Starting **January 26, 2026**, the Gemini CLI project will require all pull requests to be associated with an existing issue. Any pull requests not linked to an issue by that date will be automatically closed.\n\nThank you for your understanding and for being a part of our community!\n ".trim();
121+
122+
let successCount = 0;
123+
let skipCount = 0;
124+
let failCount = 0;
125+
126+
for (const pr of targetPrs) {
127+
const prNumber = String(pr.number);
128+
const author = pr.author.login;
129+
130+
// Check if we already commented (idempotency)
131+
// We use silent: true here because view might fail if PR is deleted mid-run
132+
const existingComments = runGh(
133+
[
134+
'pr',
135+
'view',
136+
prNumber,
137+
'--repo',
138+
REPO,
139+
'--json',
140+
'comments',
141+
'--jq',
142+
`.comments[].body | contains("${DISCUSSION_URL}")`,
143+
],
144+
{ silent: true },
145+
);
146+
147+
if (existingComments && existingComments.includes('true')) {
148+
console.log(
149+
`⏭️ PR #${prNumber} already has the notification. Skipping.`,
150+
);
151+
skipCount++;
152+
continue;
153+
}
154+
155+
if (isDryRun) {
156+
console.log(`[DRY RUN] Would notify @${author} on PR #${prNumber}`);
157+
successCount++;
158+
} else {
159+
console.log(`💬 Notifying @${author} on PR #${prNumber}...`);
160+
const personalizedComment = commentBody.replace('{AUTHOR}', author);
161+
const result = runGh([
162+
'pr',
163+
'comment',
164+
prNumber,
165+
'--repo',
166+
REPO,
167+
'--body',
168+
personalizedComment,
169+
]);
170+
171+
if (result !== null) {
172+
successCount++;
173+
} else {
174+
failCount++;
175+
}
176+
}
177+
}
178+
179+
console.log(`\n📊 Summary:`);
180+
console.log(` - Notified: ${successCount}`);
181+
console.log(` - Skipped: ${skipCount}`);
182+
console.log(` - Failed: ${failCount}`);
183+
184+
if (failCount > 0) process.exit(1);
185+
}
186+
187+
main().catch((e) => {
188+
console.error(e);
189+
process.exit(1);
190+
});
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
name: '🏷️ Enforce Restricted Label Permissions'
2+
3+
on:
4+
issues:
5+
types:
6+
- 'labeled'
7+
- 'unlabeled'
8+
9+
jobs:
10+
enforce-label:
11+
# Run this job only when restricted labels are changed
12+
if: |-
13+
${{ (github.event.label.name == 'help wanted' || github.event.label.name == 'status/need-triage') &&
14+
(github.repository == 'google-gemini/gemini-cli' || github.repository == 'google-gemini/maintainers-gemini-cli') }}
15+
runs-on: 'ubuntu-latest'
16+
permissions:
17+
issues: 'write'
18+
steps:
19+
- name: 'Generate GitHub App Token'
20+
id: 'generate_token'
21+
env:
22+
APP_ID: '${{ secrets.APP_ID }}'
23+
if: |-
24+
${{ env.APP_ID != '' }}
25+
uses: 'actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b' # ratchet:actions/create-github-app-token@v2
26+
with:
27+
app-id: '${{ secrets.APP_ID }}'
28+
private-key: '${{ secrets.PRIVATE_KEY }}'
29+
30+
- name: 'Check if user is in the maintainers team'
31+
uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea'
32+
with:
33+
github-token: '${{ steps.generate_token.outputs.token || secrets.GITHUB_TOKEN }}'
34+
script: |-
35+
const org = context.repo.owner;
36+
const username = context.payload.sender.login;
37+
const team_slug = 'gemini-cli-maintainers';
38+
const action = context.payload.action; // 'labeled' or 'unlabeled'
39+
const labelName = context.payload.label.name;
40+
41+
// Skip if the change was made by a bot to avoid infinite loops
42+
if (username === 'github-actions[bot]') {
43+
core.info('Change made by a bot. Skipping.');
44+
return;
45+
}
46+
47+
try {
48+
// This will succeed with a 204 status if the user is a member,
49+
// and fail with a 404 error if they are not.
50+
await github.rest.teams.getMembershipForUserInOrg ({
51+
org,
52+
team_slug,
53+
username,
54+
});
55+
core.info(`${username} is a member of the ${team_slug} team. No action needed.`);
56+
} catch (error) {
57+
// If the error is not 404, rethrow it to fail the action
58+
if (error.status !== 404) {
59+
throw error;
60+
}
61+
62+
core.info(`${username} is not a member. Reverting '${action}' action for '${labelName}' label.`);
63+
64+
if (action === 'labeled') {
65+
// 1. Remove the label if added by a non-maintainer
66+
await github.rest.issues.removeLabel ({
67+
owner: org,
68+
repo: context.repo.repo,
69+
issue_number: context.issue.number,
70+
name: labelName
71+
});
72+
73+
// 2. Post a polite comment
74+
const comment = `
75+
Hi @${username}, thank you for your interest in helping triage issues!
76+
77+
The \`${labelName}\` label is reserved for project maintainers to apply. This helps us ensure that an issue is ready and properly vetted for community contribution.
78+
79+
A maintainer will review this issue soon. Please see our [CONTRIBUTING.md](https://github.com/google-gemini/gemini-cli/blob/main/CONTRIBUTING.md) for more details on our labeling process.
80+
`.trim().replace(/^[ ]+/gm, '');
81+
82+
await github.rest.issues.createComment ({
83+
owner: org,
84+
repo: context.repo.repo,
85+
issue_number: context.issue.number,
86+
body: comment
87+
});
88+
} else if (action === 'unlabeled') {
89+
// 1. Add the label back if removed by a non-maintainer
90+
await github.rest.issues.addLabels ({
91+
owner: org,
92+
repo: context.repo.repo,
93+
issue_number: context.issue.number,
94+
labels: [labelName]
95+
});
96+
97+
// 2. Post a polite comment
98+
const comment = `
99+
Hi @${username}, it looks like the \`${labelName}\` label was removed.
100+
101+
This label is managed by project maintainers. We've added it back to ensure the issue remains visible to potential contributors until a maintainer decides otherwise.
102+
103+
Thank you for your understanding!
104+
`.trim().replace(/^[ ]+/gm, '');
105+
106+
await github.rest.issues.createComment ({
107+
owner: org,
108+
repo: context.repo.repo,
109+
issue_number: context.issue.number,
110+
body: comment
111+
});
112+
}
113+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
name: '🏷️ PR Contribution Guidelines Notifier'
2+
3+
on:
4+
pull_request:
5+
types:
6+
- 'opened'
7+
8+
jobs:
9+
notify-process-change:
10+
runs-on: 'ubuntu-latest'
11+
if: |-
12+
github.repository == 'google-gemini/gemini-cli' || github.repository == 'google-gemini/maintainers-gemini-cli'
13+
permissions:
14+
pull-requests: 'write'
15+
steps:
16+
- name: 'Generate GitHub App Token'
17+
id: 'generate_token'
18+
env:
19+
APP_ID: '${{ secrets.APP_ID }}'
20+
if: |-
21+
${{ env.APP_ID != '' }}
22+
uses: 'actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b' # ratchet:actions/create-github-app-token@v2
23+
with:
24+
app-id: '${{ secrets.APP_ID }}'
25+
private-key: '${{ secrets.PRIVATE_KEY }}'
26+
27+
- name: 'Check membership and post comment'
28+
uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea'
29+
with:
30+
github-token: '${{ steps.generate_token.outputs.token || secrets.GITHUB_TOKEN }}'
31+
script: |-
32+
const org = context.repo.owner;
33+
const repo = context.repo.repo;
34+
const username = context.payload.pull_request.user.login;
35+
const pr_number = context.payload.pull_request.number;
36+
const team_slug = 'gemini-cli-maintainers';
37+
38+
// 1. Check if the PR author is a maintainer
39+
try {
40+
await github.rest.teams.getMembershipForUserInOrg({
41+
org,
42+
team_slug,
43+
username,
44+
});
45+
core.info(`${username} is a maintainer. No notification needed.`);
46+
return;
47+
} catch (error) {
48+
if (error.status !== 404) throw error;
49+
}
50+
51+
// 2. Check if the PR is already associated with an issue
52+
const query = `
53+
query($owner:String!, $repo:String!, $number:Int!) {
54+
repository(owner:$owner, name:$repo) {
55+
pullRequest(number:$number) {
56+
closingIssuesReferences(first: 1) {
57+
totalCount
58+
}
59+
}
60+
}
61+
}
62+
`;
63+
const variables = { owner: org, repo: repo, number: pr_number };
64+
const result = await github.graphql(query, variables);
65+
const issueCount = result.repository.pullRequest.closingIssuesReferences.totalCount;
66+
67+
if (issueCount > 0) {
68+
core.info(`PR #${pr_number} is already associated with an issue. No notification needed.`);
69+
return;
70+
}
71+
72+
// 3. Post the notification comment
73+
core.info(`${username} is not a maintainer and PR #${pr_number} has no linked issue. Posting notification.`);
74+
75+
const comment = `
76+
Hi @${username}, thank you so much for your contribution to Gemini CLI! We really appreciate the time and effort you've put into this.
77+
78+
We're making some updates to our contribution process to improve how we track and review changes. Please take a moment to review our recent discussion post: [Improving Our Contribution Process & Introducing New Guidelines](https://github.com/google-gemini/gemini-cli/discussions/16706).
79+
80+
Key Update: Starting **January 26, 2026**, the Gemini CLI project will require all pull requests to be associated with an existing issue. Any pull requests not linked to an issue by that date will be automatically closed.
81+
82+
Thank you for your understanding and for being a part of our community!
83+
`.trim().replace(/^[ ]+/gm, '');
84+
85+
await github.rest.issues.createComment({
86+
owner: org,
87+
repo: repo,
88+
issue_number: pr_number,
89+
body: comment
90+
});

0 commit comments

Comments
 (0)