Skip to content

Commit a79da4f

Browse files
gemini-cli-robotgemini-cli[bot]gundermanc
authored
Robust Scale-Safe Lifecycle Consolidation (google-gemini#26355)
Co-authored-by: gemini-cli[bot] <gemini-cli[bot]@users.noreply.github.com> Co-authored-by: Christian Gunderman <gundermanc@google.com>
1 parent 56809d7 commit a79da4f

8 files changed

Lines changed: 292 additions & 626 deletions
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
/**
8+
* Gemini Scheduled Lifecycle Manager Script
9+
* @param {object} param0
10+
* @param {import('@octokit/rest').Octokit} param0.github
11+
* @param {import('@actions/github/lib/context').Context} param0.context
12+
* @param {import('@actions/core')} param0.core
13+
*/
14+
module.exports = async ({ github, context, core }) => {
15+
const dryRun = process.env.DRY_RUN === 'true';
16+
const owner = context.repo.owner;
17+
const repo = context.repo.repo;
18+
19+
const STALE_LABEL = 'stale';
20+
const NEED_INFO_LABEL = 'status/need-information';
21+
const EXEMPT_LABELS = [
22+
'pinned',
23+
'security',
24+
'🔒 maintainer only',
25+
'help wanted',
26+
'🗓️ Public Roadmap',
27+
];
28+
29+
const STALE_DAYS = 60;
30+
const CLOSE_DAYS = 14;
31+
const NO_RESPONSE_DAYS = 14;
32+
33+
const now = new Date();
34+
const staleThreshold = new Date(
35+
now.getTime() - STALE_DAYS * 24 * 60 * 60 * 1000,
36+
);
37+
const closeThreshold = new Date(
38+
now.getTime() - CLOSE_DAYS * 24 * 60 * 60 * 1000,
39+
);
40+
const noResponseThreshold = new Date(
41+
now.getTime() - NO_RESPONSE_DAYS * 24 * 60 * 60 * 1000,
42+
);
43+
44+
async function processItems(query, callback) {
45+
core.info(`Searching: ${query}`);
46+
try {
47+
const response = await github.rest.search.issuesAndPullRequests({
48+
q: query,
49+
per_page: 100,
50+
sort: 'updated',
51+
order: 'asc',
52+
});
53+
const items = response.data.items;
54+
core.info(`Found ${items.length} items (batch limited).`);
55+
for (const item of items) {
56+
try {
57+
await callback(item);
58+
} catch (err) {
59+
core.error(`Error processing #${item.number}: ${err.message}`);
60+
}
61+
}
62+
} catch (err) {
63+
core.error(`Search failed: ${err.message}`);
64+
}
65+
}
66+
67+
// 1. Handle No-Response (status/need-information)
68+
// Removal: Check issues updated in the last 48h that have the label
69+
const twoDaysAgo = new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000);
70+
await processItems(
71+
`repo:${owner}/${repo} is:open label:"${NEED_INFO_LABEL}" updated:>${twoDaysAgo.toISOString()}`,
72+
async (item) => {
73+
const { data: comments } = await github.rest.issues.listComments({
74+
owner,
75+
repo,
76+
issue_number: item.number,
77+
sort: 'created',
78+
direction: 'desc',
79+
per_page: 5,
80+
});
81+
82+
// Check if the last comment is from a non-maintainer
83+
const lastComment = comments[0];
84+
if (
85+
lastComment &&
86+
!['OWNER', 'MEMBER', 'COLLABORATOR'].includes(
87+
lastComment.author_association,
88+
) &&
89+
lastComment.user?.type !== 'Bot'
90+
) {
91+
core.info(
92+
`Removing ${NEED_INFO_LABEL} from #${item.number} due to contributor response.`,
93+
);
94+
if (!dryRun) {
95+
await github.rest.issues
96+
.removeLabel({
97+
owner,
98+
repo,
99+
issue_number: item.number,
100+
name: NEED_INFO_LABEL,
101+
})
102+
.catch(() => {});
103+
}
104+
}
105+
},
106+
);
107+
108+
// Closure: Check issues with the label that haven't been updated in 14 days
109+
await processItems(
110+
`repo:${owner}/${repo} is:open label:"${NEED_INFO_LABEL}" updated:<${noResponseThreshold.toISOString()}`,
111+
async (item) => {
112+
core.info(
113+
`Closing #${item.number} due to no response for ${NO_RESPONSE_DAYS} days.`,
114+
);
115+
if (!dryRun) {
116+
await github.rest.issues.createComment({
117+
owner,
118+
repo,
119+
issue_number: item.number,
120+
body: `This item was marked as needing more information and has not received a response in ${NO_RESPONSE_DAYS} days. Closing it for now. If you still face this problem, feel free to reopen with more details. Thank you!`,
121+
});
122+
await github.rest.issues.update({
123+
owner,
124+
repo,
125+
issue_number: item.number,
126+
state: 'closed',
127+
});
128+
}
129+
},
130+
);
131+
132+
// 2. Handle Stale Mark (60 days inactivity, no stale label)
133+
const exemptQuery = EXEMPT_LABELS.map((l) => `-label:"${l}"`).join(' ');
134+
await processItems(
135+
`repo:${owner}/${repo} is:open -label:"${STALE_LABEL}" ${exemptQuery} updated:<${staleThreshold.toISOString()}`,
136+
async (item) => {
137+
core.info(`Marking #${item.number} as stale.`);
138+
if (!dryRun) {
139+
await github.rest.issues.addLabels({
140+
owner,
141+
repo,
142+
issue_number: item.number,
143+
labels: [STALE_LABEL],
144+
});
145+
await github.rest.issues.createComment({
146+
owner,
147+
repo,
148+
issue_number: item.number,
149+
body: `This item has been automatically marked as stale due to ${STALE_DAYS} days of inactivity. It will be closed in ${CLOSE_DAYS} days if no further activity occurs. Thank you!`,
150+
});
151+
}
152+
},
153+
);
154+
155+
// 3. Handle Stale Close (14 days with stale label)
156+
await processItems(
157+
`repo:${owner}/${repo} is:open label:"${STALE_LABEL}" updated:<${closeThreshold.toISOString()}`,
158+
async (item) => {
159+
core.info(`Closing stale item #${item.number}.`);
160+
if (!dryRun) {
161+
await github.rest.issues.createComment({
162+
owner,
163+
repo,
164+
issue_number: item.number,
165+
body: `This item has been closed due to ${CLOSE_DAYS} additional days of inactivity after being marked as stale. If you believe this is still relevant, feel free to comment or reopen. Thank you!`,
166+
});
167+
await github.rest.issues.update({
168+
owner,
169+
repo,
170+
issue_number: item.number,
171+
state: 'closed',
172+
});
173+
}
174+
},
175+
);
176+
177+
// 4. Handle PR Contribution Policy (Nudge at 7d, Close at 14d)
178+
const PR_NUDGE_DAYS = 7;
179+
const PR_CLOSE_DAYS = 14;
180+
const nudgeThreshold = new Date(
181+
now.getTime() - PR_NUDGE_DAYS * 24 * 60 * 60 * 1000,
182+
);
183+
const prCloseThreshold = new Date(
184+
now.getTime() - PR_CLOSE_DAYS * 24 * 60 * 60 * 1000,
185+
);
186+
187+
// Nudge
188+
await processItems(
189+
`repo:${owner}/${repo} is:open is:pr -label:"help wanted" -label:"🔒 maintainer only" -label:"status/pr-nudge-sent" created:${prCloseThreshold.toISOString()}..${nudgeThreshold.toISOString()}`,
190+
async (pr) => {
191+
if (
192+
['OWNER', 'MEMBER', 'COLLABORATOR'].includes(pr.author_association) ||
193+
pr.user?.type === 'Bot'
194+
)
195+
return;
196+
197+
core.info(`Nudging PR #${pr.number} for contribution policy.`);
198+
if (!dryRun) {
199+
await github.rest.issues.addLabels({
200+
owner,
201+
repo,
202+
issue_number: pr.number,
203+
labels: ['status/pr-nudge-sent'],
204+
});
205+
await github.rest.issues.createComment({
206+
owner,
207+
repo,
208+
issue_number: pr.number,
209+
body: "Hi there! Thank you for your interest in contributing to Gemini CLI. \n\nTo ensure we maintain high code quality and focus on our prioritized roadmap, we only guarantee review and consideration of pull requests for issues that are explicitly labeled as 'help wanted'. \n\nThis PR will be closed in 7 days if it remains without that designation. We encourage you to find and contribute to existing 'help wanted' issues in our backlog! Thank you for your understanding.",
210+
});
211+
}
212+
},
213+
);
214+
215+
// Close
216+
await processItems(
217+
`repo:${owner}/${repo} is:open is:pr -label:"help wanted" -label:"🔒 maintainer only" created:<${prCloseThreshold.toISOString()}`,
218+
async (pr) => {
219+
if (
220+
['OWNER', 'MEMBER', 'COLLABORATOR'].includes(pr.author_association) ||
221+
pr.user?.type === 'Bot'
222+
)
223+
return;
224+
225+
core.info(
226+
`Closing PR #${pr.number} per contribution policy (no 'help wanted').`,
227+
);
228+
if (!dryRun) {
229+
await github.rest.issues.createComment({
230+
owner,
231+
repo,
232+
issue_number: pr.number,
233+
body: "This pull request is being closed as it has been open for 14 days without a 'help wanted' designation. We encourage you to find and contribute to existing 'help wanted' issues in our backlog! Thank you for your understanding.",
234+
});
235+
await github.rest.pulls.update({
236+
owner,
237+
repo,
238+
pull_number: pr.number,
239+
state: 'closed',
240+
});
241+
}
242+
},
243+
);
244+
};
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
name: '🔄 Gemini Scheduled Lifecycle Manager'
2+
3+
on:
4+
schedule:
5+
- cron: '30 1 * * *' # Once a day
6+
workflow_dispatch:
7+
inputs:
8+
dry_run:
9+
description: 'Run in dry-run mode (no changes applied)'
10+
required: false
11+
default: false
12+
type: 'boolean'
13+
14+
concurrency:
15+
group: '${{ github.workflow }}'
16+
cancel-in-progress: true
17+
18+
permissions:
19+
issues: 'write'
20+
pull-requests: 'write'
21+
22+
jobs:
23+
manage-lifecycle:
24+
if: "github.repository == 'google-gemini/gemini-cli'"
25+
runs-on: 'ubuntu-latest'
26+
steps:
27+
- name: 'Generate GitHub App Token'
28+
id: 'generate_token'
29+
uses: 'actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349' # ratchet:actions/create-github-app-token@v2
30+
with:
31+
app-id: '${{ secrets.APP_ID }}'
32+
private-key: '${{ secrets.PRIVATE_KEY }}'
33+
34+
- name: 'Checkout repository'
35+
uses: 'actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683' # ratchet:actions/checkout@v4
36+
37+
- name: 'Lifecycle Management'
38+
uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea'
39+
env:
40+
DRY_RUN: '${{ inputs.dry_run }}'
41+
with:
42+
github-token: '${{ steps.generate_token.outputs.token }}'
43+
script: |
44+
const script = require('./.github/scripts/gemini-lifecycle-manager.cjs');
45+
await script({github, context, core});

.github/workflows/gemini-scheduled-issue-triage.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,15 +63,15 @@ jobs:
6363
6464
echo '🔍 Finding issues missing area labels...'
6565
NO_AREA_ISSUES="$(gh issue list --repo "${GITHUB_REPOSITORY}" \
66-
--search 'is:open is:issue -label:area/core -label:area/agent -label:area/enterprise -label:area/non-interactive -label:area/security -label:area/platform -label:area/extensions -label:area/documentation -label:area/unknown' --limit 100 --json number,title,body)"
66+
--search 'is:open is:issue -label:status/bot-triaged -label:area/core -label:area/agent -label:area/enterprise -label:area/non-interactive -label:area/security -label:area/platform -label:area/extensions -label:area/documentation -label:area/unknown' --limit 100 --json number,title,body)"
6767
6868
echo '🔍 Finding issues missing kind labels...'
6969
NO_KIND_ISSUES="$(gh issue list --repo "${GITHUB_REPOSITORY}" \
70-
--search 'is:open is:issue -label:kind/bug -label:kind/enhancement -label:kind/customer-issue -label:kind/question' --limit 100 --json number,title,body)"
70+
--search 'is:open is:issue -label:status/bot-triaged -label:kind/bug -label:kind/enhancement -label:kind/customer-issue -label:kind/question' --limit 100 --json number,title,body)"
7171
7272
echo '🏷️ Finding issues missing priority labels...'
7373
NO_PRIORITY_ISSUES="$(gh issue list --repo "${GITHUB_REPOSITORY}" \
74-
--search 'is:open is:issue -label:priority/p0 -label:priority/p1 -label:priority/p2 -label:priority/p3 -label:priority/unknown' --limit 100 --json number,title,body)"
74+
--search 'is:open is:issue -label:status/bot-triaged -label:priority/p0 -label:priority/p1 -label:priority/p2 -label:priority/p3 -label:priority/unknown' --limit 100 --json number,title,body)"
7575
7676
echo '🔄 Merging and deduplicating issues...'
7777
ISSUES="$(echo "${NO_AREA_ISSUES}" "${NO_KIND_ISSUES}" "${NO_PRIORITY_ISSUES}" | jq -c -s 'add | unique_by(.number)')"

0 commit comments

Comments
 (0)