Skip to content

Commit a76c298

Browse files
authored
feat(workflow): add stale pull request closer with linked-issue enforcement (google-gemini#17449)
1 parent 80e1fa1 commit a76c298

File tree

1 file changed

+205
-0
lines changed

1 file changed

+205
-0
lines changed
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
name: 'Gemini Scheduled Stale PR Closer'
2+
3+
on:
4+
schedule:
5+
- cron: '0 2 * * *' # Every day at 2 AM UTC
6+
pull_request:
7+
types: ['opened', 'edited']
8+
workflow_dispatch:
9+
inputs:
10+
dry_run:
11+
description: 'Run in dry-run mode'
12+
required: false
13+
default: false
14+
type: 'boolean'
15+
16+
jobs:
17+
close-stale-prs:
18+
if: "github.repository == 'google-gemini/gemini-cli'"
19+
runs-on: 'ubuntu-latest'
20+
permissions:
21+
pull-requests: 'write'
22+
issues: 'write'
23+
steps:
24+
- name: 'Generate GitHub App Token'
25+
id: 'generate_token'
26+
uses: 'actions/create-github-app-token@v1'
27+
with:
28+
app-id: '${{ secrets.APP_ID }}'
29+
private-key: '${{ secrets.PRIVATE_KEY }}'
30+
owner: '${{ github.repository_owner }}'
31+
repositories: 'gemini-cli'
32+
33+
- name: 'Process Stale PRs'
34+
uses: 'actions/github-script@v7'
35+
env:
36+
DRY_RUN: '${{ inputs.dry_run }}'
37+
with:
38+
github-token: '${{ steps.generate_token.outputs.token }}'
39+
script: |
40+
const dryRun = process.env.DRY_RUN === 'true';
41+
const thirtyDaysAgo = new Date();
42+
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
43+
44+
// 1. Fetch maintainers for verification
45+
let maintainerLogins = new Set();
46+
try {
47+
const members = await github.paginate(github.rest.teams.listMembersInOrg, {
48+
org: context.repo.owner,
49+
team_slug: 'gemini-cli-maintainers'
50+
});
51+
maintainerLogins = new Set(members.map(m => m.login));
52+
} catch (e) {
53+
core.warning('Failed to fetch team members');
54+
}
55+
56+
const isMaintainer = (login, assoc) => {
57+
if (maintainerLogins.size > 0) return maintainerLogins.has(login);
58+
return ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(assoc);
59+
};
60+
61+
// 2. Determine which PRs to check
62+
let prs = [];
63+
if (context.eventName === 'pull_request') {
64+
const { data: pr } = await github.rest.pulls.get({
65+
owner: context.repo.owner,
66+
repo: context.repo.repo,
67+
pull_number: context.payload.pull_request.number
68+
});
69+
prs = [pr];
70+
} else {
71+
prs = await github.paginate(github.rest.pulls.list, {
72+
owner: context.repo.owner,
73+
repo: context.repo.repo,
74+
state: 'open',
75+
per_page: 100
76+
});
77+
}
78+
79+
for (const pr of prs) {
80+
const maintainerPr = isMaintainer(pr.user.login, pr.author_association);
81+
const isBot = pr.user.type === 'Bot' || pr.user.login.endsWith('[bot]');
82+
83+
// Detection Logic for Linked Issues
84+
// Check 1: Official GitHub "Closing Issue" link (GraphQL)
85+
const linkedIssueQuery = `query($owner:String!, $repo:String!, $number:Int!) {
86+
repository(owner:$owner, name:$repo) {
87+
pullRequest(number:$number) {
88+
closingIssuesReferences(first: 1) { totalCount }
89+
}
90+
}
91+
}`;
92+
93+
let hasClosingLink = false;
94+
try {
95+
const res = await github.graphql(linkedIssueQuery, {
96+
owner: context.repo.owner, repo: context.repo.repo, number: pr.number
97+
});
98+
hasClosingLink = res.repository.pullRequest.closingIssuesReferences.totalCount > 0;
99+
} catch (e) {}
100+
101+
// Check 2: Regex for mentions (e.g., "Related to #123", "Part of #123", "#123")
102+
// We check for # followed by numbers or direct URLs to issues.
103+
const body = pr.body || '';
104+
const mentionRegex = /(?:#|https:\/\/github\.com\/[^\/]+\/[^\/]+\/issues\/)(\d+)/i;
105+
const hasMentionLink = mentionRegex.test(body);
106+
107+
const hasLinkedIssue = hasClosingLink || hasMentionLink;
108+
109+
// Logic for Closed PRs (Auto-Reopen)
110+
if (pr.state === 'closed' && context.eventName === 'pull_request' && context.payload.action === 'edited') {
111+
if (hasLinkedIssue) {
112+
core.info(`PR #${pr.number} now has a linked issue. Reopening.`);
113+
if (!dryRun) {
114+
await github.rest.pulls.update({
115+
owner: context.repo.owner,
116+
repo: context.repo.repo,
117+
pull_number: pr.number,
118+
state: 'open'
119+
});
120+
await github.rest.issues.createComment({
121+
owner: context.repo.owner,
122+
repo: context.repo.repo,
123+
issue_number: pr.number,
124+
body: "Thank you for linking an issue! This pull request has been automatically reopened."
125+
});
126+
}
127+
}
128+
continue;
129+
}
130+
131+
// Logic for Open PRs (Immediate Closure)
132+
if (pr.state === 'open' && !maintainerPr && !hasLinkedIssue && !isBot) {
133+
core.info(`PR #${pr.number} is missing a linked issue. Closing.`);
134+
if (!dryRun) {
135+
await github.rest.issues.createComment({
136+
owner: context.repo.owner,
137+
repo: context.repo.repo,
138+
issue_number: pr.number,
139+
body: "Hi there! Thank you for your contribution to Gemini CLI. \n\nTo improve our contribution process and better track changes, we now require all pull requests to be associated with an existing issue, as announced in our [recent discussion](https://github.com/google-gemini/gemini-cli/discussions/16706) and as detailed in our [CONTRIBUTING.md](https://github.com/google-gemini/gemini-cli/blob/main/CONTRIBUTING.md#1-link-to-an-existing-issue).\n\nThis pull request is being closed because it is not currently linked to an issue. **Once you have updated the description of this PR to link an issue (e.g., by adding `Fixes #123` or `Related to #123`), it will be automatically reopened.**\n\n**How to link an issue:**\nAdd a keyword followed by the issue number (e.g., `Fixes #123`) in the description of your pull request. For more details on supported keywords and how linking works, please refer to the [GitHub Documentation on linking pull requests to issues](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue).\n\nThank you for your understanding and for being a part of our community!"
140+
});
141+
await github.rest.pulls.update({
142+
owner: context.repo.owner,
143+
repo: context.repo.repo,
144+
pull_number: pr.number,
145+
state: 'closed'
146+
});
147+
}
148+
continue;
149+
}
150+
151+
// Staleness check (Scheduled runs only)
152+
if (pr.state === 'open' && context.eventName !== 'pull_request') {
153+
const labels = pr.labels.map(l => l.name.toLowerCase());
154+
if (labels.includes('help wanted') || labels.includes('🔒 maintainer only')) continue;
155+
156+
let lastActivity = new Date(0);
157+
try {
158+
const reviews = await github.paginate(github.rest.pulls.listReviews, {
159+
owner: context.repo.owner,
160+
repo: context.repo.repo,
161+
pull_number: pr.number
162+
});
163+
for (const r of reviews) {
164+
if (isMaintainer(r.user.login, r.author_association)) {
165+
const d = new Date(r.submitted_at || r.updated_at);
166+
if (d > lastActivity) lastActivity = d;
167+
}
168+
}
169+
const comments = await github.paginate(github.rest.issues.listComments, {
170+
owner: context.repo.owner,
171+
repo: context.repo.repo,
172+
issue_number: pr.number
173+
});
174+
for (const c of comments) {
175+
if (isMaintainer(c.user.login, c.author_association)) {
176+
const d = new Date(c.updated_at);
177+
if (d > lastActivity) lastActivity = d;
178+
}
179+
}
180+
} catch (e) {}
181+
182+
if (maintainerPr) {
183+
const d = new Date(pr.created_at);
184+
if (d > lastActivity) lastActivity = d;
185+
}
186+
187+
if (lastActivity < thirtyDaysAgo) {
188+
core.info(`PR #${pr.number} is stale.`);
189+
if (!dryRun) {
190+
await github.rest.issues.createComment({
191+
owner: context.repo.owner,
192+
repo: context.repo.repo,
193+
issue_number: pr.number,
194+
body: "Hi there! Thank you for your contribution to Gemini CLI. We really appreciate the time and effort you've put into this pull request.\n\nTo keep our backlog manageable and ensure we're focusing on current priorities, we are closing pull requests that haven't seen maintainer activity for 30 days. Currently, the team is prioritizing work associated with **🔒 maintainer only** or **help wanted** issues.\n\nIf you believe this change is still critical, please feel free to comment with updated details. Otherwise, we encourage contributors to focus on open issues labeled as **help wanted**. Thank you for your understanding!"
195+
});
196+
await github.rest.pulls.update({
197+
owner: context.repo.owner,
198+
repo: context.repo.repo,
199+
pull_number: pr.number,
200+
state: 'closed'
201+
});
202+
}
203+
}
204+
}
205+
}

0 commit comments

Comments
 (0)