Skip to content

Commit b9fca15

Browse files
committed
feat: add issue assignee workflow
Signed-off-by: Kartik Angiras <[email protected]>
1 parent 1e2afbb commit b9fca15

File tree

2 files changed

+354
-1
lines changed

2 files changed

+354
-1
lines changed
Lines changed: 315 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,315 @@
1+
name: 'Unassign Inactive Issue Assignees'
2+
3+
# This workflow runs daily and scans every open "help wanted" issue that has
4+
# one or more assignees. For each assignee it checks whether they have a
5+
# non-draft pull request (open and ready for review, or already merged) that
6+
# is linked to the issue. Draft PRs are intentionally excluded so that
7+
# contributors cannot reset the check by opening a no-op PR. If no
8+
# qualifying PR is found within 7 days of assignment the assignee is
9+
# automatically removed and a friendly comment is posted so that other
10+
# contributors can pick up the work.
11+
# Maintainers, org members, and collaborators (anyone with write access or
12+
# above) are always exempted and will never be auto-unassigned.
13+
14+
on:
15+
schedule:
16+
- cron: '0 9 * * *' # Every day at 09:00 UTC
17+
workflow_dispatch:
18+
inputs:
19+
dry_run:
20+
description: 'Run in dry-run mode (no changes will be applied)'
21+
required: false
22+
default: false
23+
type: 'boolean'
24+
25+
concurrency:
26+
group: '${{ github.workflow }}'
27+
cancel-in-progress: true
28+
29+
defaults:
30+
run:
31+
shell: 'bash'
32+
33+
jobs:
34+
unassign-inactive-assignees:
35+
if: "github.repository == 'google-gemini/gemini-cli'"
36+
runs-on: 'ubuntu-latest'
37+
permissions:
38+
issues: 'write'
39+
40+
steps:
41+
- name: 'Generate GitHub App Token'
42+
id: 'generate_token'
43+
uses: 'actions/create-github-app-token@v2'
44+
with:
45+
app-id: '${{ secrets.APP_ID }}'
46+
private-key: '${{ secrets.PRIVATE_KEY }}'
47+
48+
- name: 'Unassign inactive assignees'
49+
uses: 'actions/github-script@v7'
50+
env:
51+
DRY_RUN: '${{ inputs.dry_run }}'
52+
with:
53+
github-token: '${{ steps.generate_token.outputs.token }}'
54+
script: |
55+
const dryRun = process.env.DRY_RUN === 'true';
56+
if (dryRun) {
57+
core.info('DRY RUN MODE ENABLED: No changes will be applied.');
58+
}
59+
60+
const owner = context.repo.owner;
61+
const repo = context.repo.repo;
62+
const GRACE_PERIOD_DAYS = 7;
63+
const now = new Date();
64+
65+
let maintainerLogins = new Set();
66+
const teams = ['gemini-cli-maintainers', 'gemini-cli-askmode-approvers', 'gemini-cli-docs'];
67+
68+
for (const team_slug of teams) {
69+
try {
70+
const members = await github.paginate(github.rest.teams.listMembersInOrg, {
71+
org: owner,
72+
team_slug,
73+
});
74+
for (const m of members) maintainerLogins.add(m.login.toLowerCase());
75+
core.info(`Fetched ${members.length} members from team ${team_slug}.`);
76+
} catch (e) {
77+
core.warning(`Could not fetch team ${team_slug}: ${e.message}`);
78+
}
79+
}
80+
81+
const isGooglerCache = new Map();
82+
const isGoogler = async (login) => {
83+
if (isGooglerCache.has(login)) return isGooglerCache.get(login);
84+
try {
85+
for (const org of ['googlers', 'google']) {
86+
try {
87+
await github.rest.orgs.checkMembershipForUser({ org, username: login });
88+
isGooglerCache.set(login, true);
89+
return true;
90+
} catch (e) {
91+
if (e.status !== 404) throw e;
92+
}
93+
}
94+
} catch (e) {
95+
core.warning(`Could not check org membership for ${login}: ${e.message}`);
96+
}
97+
isGooglerCache.set(login, false);
98+
return false;
99+
};
100+
101+
const permissionCache = new Map();
102+
const isPrivilegedUser = async (login) => {
103+
if (maintainerLogins.has(login.toLowerCase())) return true;
104+
105+
if (permissionCache.has(login)) return permissionCache.get(login);
106+
107+
try {
108+
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
109+
owner,
110+
repo,
111+
username: login,
112+
});
113+
const privileged = ['admin', 'maintain', 'write', 'triage'].includes(data.permission);
114+
permissionCache.set(login, privileged);
115+
if (privileged) {
116+
core.info(` @${login} is a repo collaborator (${data.permission}) — exempt.`);
117+
return true;
118+
}
119+
} catch (e) {
120+
if (e.status !== 404) {
121+
core.warning(`Could not check permission for ${login}: ${e.message}`);
122+
}
123+
}
124+
125+
const googler = await isGoogler(login);
126+
permissionCache.set(login, googler);
127+
return googler;
128+
};
129+
130+
core.info('Fetching open "help wanted" issues with assignees...');
131+
132+
const issues = await github.paginate(github.rest.issues.listForRepo, {
133+
owner,
134+
repo,
135+
state: 'open',
136+
labels: 'help wanted',
137+
per_page: 100,
138+
});
139+
140+
const assignedIssues = issues.filter(
141+
(issue) => !issue.pull_request && issue.assignees && issue.assignees.length > 0
142+
);
143+
144+
core.info(`Found ${assignedIssues.length} assigned "help wanted" issues.`);
145+
146+
let totalUnassigned = 0;
147+
148+
let timelineEvents = [];
149+
try {
150+
timelineEvents = await github.paginate(github.rest.issues.listEventsForTimeline, {
151+
owner,
152+
repo,
153+
issue_number: issue.number,
154+
per_page: 100,
155+
mediaType: { previews: ['mockingbird'] },
156+
});
157+
} catch (err) {
158+
core.warning(`Could not fetch timeline for issue #${issue.number}: ${err.message}`);
159+
continue;
160+
}
161+
162+
const assignedAtMap = new Map();
163+
164+
for (const event of timelineEvents) {
165+
if (event.event === 'assigned' && event.assignee) {
166+
const login = event.assignee.login.toLowerCase();
167+
const at = new Date(event.created_at);
168+
assignedAtMap.set(login, at);
169+
} else if (event.event === 'unassigned' && event.assignee) {
170+
assignedAtMap.delete(event.assignee.login.toLowerCase());
171+
}
172+
}
173+
174+
const linkedPRAuthorSet = new Set();
175+
const seenPRKeys = new Set();
176+
177+
for (const event of timelineEvents) {
178+
if (
179+
event.event !== 'cross-referenced' ||
180+
!event.source ||
181+
event.source.type !== 'pull_request' ||
182+
!event.source.issue ||
183+
!event.source.issue.user ||
184+
!event.source.issue.number ||
185+
!event.source.issue.repository
186+
) continue;
187+
188+
const prOwner = event.source.issue.repository.owner.login;
189+
const prRepo = event.source.issue.repository.name;
190+
const prNumber = event.source.issue.number;
191+
const prAuthor = event.source.issue.user.login.toLowerCase();
192+
const prKey = `${prOwner}/${prRepo}#${prNumber}`;
193+
194+
if (seenPRKeys.has(prKey)) continue;
195+
seenPRKeys.add(prKey);
196+
197+
try {
198+
const { data: pr } = await github.rest.pulls.get({
199+
owner: prOwner,
200+
repo: prRepo,
201+
pull_number: prNumber,
202+
});
203+
204+
const isReady = (pr.state === 'open' && !pr.draft) ||
205+
(pr.state === 'closed' && pr.merged_at !== null);
206+
207+
core.info(
208+
` PR ${prKey} by @${prAuthor}: ` +
209+
`state=${pr.state}, draft=${pr.draft}, merged=${!!pr.merged_at} → ` +
210+
(isReady ? 'qualifies' : 'does NOT qualify (draft or closed without merge)')
211+
);
212+
213+
if (isReady) linkedPRAuthorSet.add(prAuthor);
214+
} catch (err) {
215+
core.warning(`Could not fetch PR ${prKey}: ${err.message}`);
216+
}
217+
}
218+
219+
const assigneesToRemove = [];
220+
221+
for (const assignee of issue.assignees) {
222+
const login = assignee.login.toLowerCase();
223+
224+
if (await isPrivilegedUser(assignee.login)) {
225+
core.info(` @${assignee.login}: privileged user — skipping.`);
226+
continue;
227+
}
228+
229+
const assignedAt = assignedAtMap.get(login);
230+
231+
if (!assignedAt) {
232+
core.warning(
233+
`No 'assigned' event found for @${login} on issue #${issue.number}; ` +
234+
`falling back to issue creation date (${issue.created_at}).`
235+
);
236+
assignedAtMap.set(login, new Date(issue.created_at));
237+
}
238+
const resolvedAssignedAt = assignedAtMap.get(login);
239+
240+
const daysSinceAssignment = (now - resolvedAssignedAt) / (1000 * 60 * 60 * 24);
241+
242+
core.info(
243+
` @${login}: assigned ${daysSinceAssignment.toFixed(1)} day(s) ago, ` +
244+
`ready-for-review PR: ${linkedPRAuthorSet.has(login) ? 'yes' : 'no'}`
245+
);
246+
247+
if (daysSinceAssignment < GRACE_PERIOD_DAYS) {
248+
core.info(` → within grace period, skipping.`);
249+
continue;
250+
}
251+
252+
if (linkedPRAuthorSet.has(login)) {
253+
core.info(` → ready-for-review PR found, keeping assignment.`);
254+
continue;
255+
}
256+
257+
core.info(` → no ready-for-review PR after ${GRACE_PERIOD_DAYS} days, will unassign.`);
258+
assigneesToRemove.push(assignee.login);
259+
}
260+
261+
if (assigneesToRemove.length === 0) {
262+
continue;
263+
}
264+
265+
if (!dryRun) {
266+
try {
267+
await github.rest.issues.removeAssignees({
268+
owner,
269+
repo,
270+
issue_number: issue.number,
271+
assignees: assigneesToRemove,
272+
});
273+
} catch (err) {
274+
core.warning(
275+
`Failed to unassign ${assigneesToRemove.join(', ')} from issue #${issue.number}: ${err.message}`
276+
);
277+
continue;
278+
}
279+
280+
const mentionList = assigneesToRemove.map((l) => `@${l}`).join(', ');
281+
const commentBody =
282+
`👋 ${mentionList} — it has been more than ${GRACE_PERIOD_DAYS} days since ` +
283+
`you were assigned to this issue and we could not find a pull request ` +
284+
`ready for review.\n\n` +
285+
`To keep the backlog moving and ensure issues stay accessible to all ` +
286+
`contributors, we require a PR that is open and ready for review (not a ` +
287+
`draft) within ${GRACE_PERIOD_DAYS} days of assignment.\n\n` +
288+
`We are automatically unassigning you so that other contributors can pick ` +
289+
`this up. If you are still actively working on this, please:\n` +
290+
`1. Re-assign yourself by commenting \`/assign\`.\n` +
291+
`2. Open a PR (not a draft) linked to this issue (e.g. \`Fixes #${issue.number}\`) ` +
292+
`within ${GRACE_PERIOD_DAYS} days so the automation knows real progress is being made.\n\n` +
293+
`Thank you for your contribution — we hope to see a PR from you soon! 🙏`;
294+
295+
try {
296+
await github.rest.issues.createComment({
297+
owner,
298+
repo,
299+
issue_number: issue.number,
300+
body: commentBody,
301+
});
302+
} catch (err) {
303+
core.warning(
304+
`Failed to post comment on issue #${issue.number}: ${err.message}`
305+
);
306+
}
307+
}
308+
309+
totalUnassigned += assigneesToRemove.length;
310+
core.info(
311+
` ${dryRun ? '[DRY RUN] Would have unassigned' : 'Unassigned'}: ${assigneesToRemove.join(', ')}`
312+
);
313+
}
314+
315+
core.info(`\nDone. Total assignees ${dryRun ? 'that would be' : ''} unassigned: ${totalUnassigned}`);

docs/issue-and-pr-automation.md

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,45 @@ process.
113113
ensure every issue is eventually categorized, even if the initial triage
114114
fails.
115115

116-
### 5. Release automation
116+
### 5. Automatic unassignment of inactive contributors: `Unassign Inactive Issue Assignees`
117+
118+
To keep the list of open `help wanted` issues accessible to all contributors,
119+
this workflow automatically removes **external contributors** who have not
120+
opened a linked pull request within **7 days** of being assigned. Maintainers,
121+
org members, and repo collaborators with write access or above are always exempt
122+
and will never be auto-unassigned.
123+
124+
- **Workflow File**: `.github/workflows/unassign-inactive-assignees.yml`
125+
- **When it runs**: Every day at 09:00 UTC, and can be triggered manually with
126+
an optional `dry_run` mode.
127+
- **What it does**:
128+
1. Finds every open issue labeled `help wanted` that has at least one
129+
assignee.
130+
2. Identifies privileged users (team members, repo collaborators with write+
131+
access, maintainers) and skips them entirely.
132+
3. For each remaining (external) assignee it reads the issue's timeline to
133+
determine:
134+
- The exact date they were assigned (using `assigned` timeline events).
135+
- Whether they have opened a PR that is already linked/cross-referenced to
136+
the issue.
137+
4. Each cross-referenced PR is fetched to verify it is **ready for review**:
138+
open and non-draft, or already merged. Draft PRs do not count.
139+
5. If an assignee has been assigned for **more than 7 days** and no qualifying
140+
PR is found, they are automatically unassigned and a comment is posted
141+
explaining the reason and how to re-claim the issue.
142+
6. Assignees who have a non-draft, open or merged PR linked to the issue are
143+
**never** unassigned by this workflow.
144+
- **What you should do**:
145+
- **Open a real PR, not a draft**: Within 7 days of being assigned, open a PR
146+
that is ready for review and include `Fixes #<issue-number>` in the
147+
description. Draft PRs do not satisfy the requirement and will not prevent
148+
auto-unassignment.
149+
- **Re-assign if unassigned by mistake**: Comment `/assign` on the issue to
150+
take it back so the bot won't remove you again.
151+
- **Unassign yourself** if you can no longer work on the issue by commenting
152+
`/unassign`, so other contributors can pick it up right away.
153+
154+
### 6. Release automation
117155

118156
This workflow handles the process of packaging and publishing new versions of
119157
the Gemini CLI.

0 commit comments

Comments
 (0)