name: 'Unassign Inactive Issue Assignees' # This workflow runs daily and scans every open "help wanted" issue that has # one or more assignees. For each assignee it checks whether they have a # non-draft pull request (open and ready for review, or already merged) that # is linked to the issue. Draft PRs are intentionally excluded so that # contributors cannot reset the check by opening a no-op PR. If no # qualifying PR is found within 7 days of assignment the assignee is # automatically removed and a friendly comment is posted so that other # contributors can pick up the work. # Maintainers, org members, and collaborators (anyone with write access or # above) are always exempted and will never be auto-unassigned. on: schedule: - cron: '0 9 * * *' # Every day at 09:00 UTC workflow_dispatch: inputs: dry_run: description: 'Run in dry-run mode (no changes will be applied)' required: false default: false type: 'boolean' concurrency: group: '${{ github.workflow }}' cancel-in-progress: true defaults: run: shell: 'bash' jobs: unassign-inactive-assignees: if: "github.repository == 'google-gemini/gemini-cli'" runs-on: 'ubuntu-latest' permissions: issues: 'write' steps: - name: 'Generate GitHub App Token' id: 'generate_token' uses: 'actions/create-github-app-token@v2' with: app-id: '${{ secrets.APP_ID }}' private-key: '${{ secrets.PRIVATE_KEY }}' - name: 'Unassign inactive assignees' uses: 'actions/github-script@v7' env: DRY_RUN: '${{ inputs.dry_run }}' with: github-token: '${{ steps.generate_token.outputs.token }}' script: | const dryRun = process.env.DRY_RUN === 'true'; if (dryRun) { core.info('DRY RUN MODE ENABLED: No changes will be applied.'); } const owner = context.repo.owner; const repo = context.repo.repo; const GRACE_PERIOD_DAYS = 7; const now = new Date(); let maintainerLogins = new Set(); const teams = ['gemini-cli-maintainers', 'gemini-cli-askmode-approvers', 'gemini-cli-docs']; for (const team_slug of teams) { try { const members = await github.paginate(github.rest.teams.listMembersInOrg, { org: owner, team_slug, }); for (const m of members) maintainerLogins.add(m.login.toLowerCase()); core.info(`Fetched ${members.length} members from team ${team_slug}.`); } catch (e) { core.warning(`Could not fetch team ${team_slug}: ${e.message}`); } } const isGooglerCache = new Map(); const isGoogler = async (login) => { if (isGooglerCache.has(login)) return isGooglerCache.get(login); try { for (const org of ['googlers', 'google']) { try { await github.rest.orgs.checkMembershipForUser({ org, username: login }); isGooglerCache.set(login, true); return true; } catch (e) { if (e.status !== 404) throw e; } } } catch (e) { core.warning(`Could not check org membership for ${login}: ${e.message}`); } isGooglerCache.set(login, false); return false; }; const permissionCache = new Map(); const isPrivilegedUser = async (login) => { if (maintainerLogins.has(login.toLowerCase())) return true; if (permissionCache.has(login)) return permissionCache.get(login); try { const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ owner, repo, username: login, }); const privileged = ['admin', 'maintain', 'write', 'triage'].includes(data.permission); permissionCache.set(login, privileged); if (privileged) { core.info(` @${login} is a repo collaborator (${data.permission}) — exempt.`); return true; } } catch (e) { if (e.status !== 404) { core.warning(`Could not check permission for ${login}: ${e.message}`); } } const googler = await isGoogler(login); permissionCache.set(login, googler); return googler; }; core.info('Fetching open "help wanted" issues with assignees...'); const issues = await github.paginate(github.rest.issues.listForRepo, { owner, repo, state: 'open', labels: 'help wanted', per_page: 100, }); const assignedIssues = issues.filter( (issue) => !issue.pull_request && issue.assignees && issue.assignees.length > 0 ); core.info(`Found ${assignedIssues.length} assigned "help wanted" issues.`); let totalUnassigned = 0; let timelineEvents = []; try { timelineEvents = await github.paginate(github.rest.issues.listEventsForTimeline, { owner, repo, issue_number: issue.number, per_page: 100, mediaType: { previews: ['mockingbird'] }, }); } catch (err) { core.warning(`Could not fetch timeline for issue #${issue.number}: ${err.message}`); continue; } const assignedAtMap = new Map(); for (const event of timelineEvents) { if (event.event === 'assigned' && event.assignee) { const login = event.assignee.login.toLowerCase(); const at = new Date(event.created_at); assignedAtMap.set(login, at); } else if (event.event === 'unassigned' && event.assignee) { assignedAtMap.delete(event.assignee.login.toLowerCase()); } } const linkedPRAuthorSet = new Set(); const seenPRKeys = new Set(); for (const event of timelineEvents) { if ( event.event !== 'cross-referenced' || !event.source || event.source.type !== 'pull_request' || !event.source.issue || !event.source.issue.user || !event.source.issue.number || !event.source.issue.repository ) continue; const prOwner = event.source.issue.repository.owner.login; const prRepo = event.source.issue.repository.name; const prNumber = event.source.issue.number; const prAuthor = event.source.issue.user.login.toLowerCase(); const prKey = `${prOwner}/${prRepo}#${prNumber}`; if (seenPRKeys.has(prKey)) continue; seenPRKeys.add(prKey); try { const { data: pr } = await github.rest.pulls.get({ owner: prOwner, repo: prRepo, pull_number: prNumber, }); const isReady = (pr.state === 'open' && !pr.draft) || (pr.state === 'closed' && pr.merged_at !== null); core.info( ` PR ${prKey} by @${prAuthor}: ` + `state=${pr.state}, draft=${pr.draft}, merged=${!!pr.merged_at} → ` + (isReady ? 'qualifies' : 'does NOT qualify (draft or closed without merge)') ); if (isReady) linkedPRAuthorSet.add(prAuthor); } catch (err) { core.warning(`Could not fetch PR ${prKey}: ${err.message}`); } } const assigneesToRemove = []; for (const assignee of issue.assignees) { const login = assignee.login.toLowerCase(); if (await isPrivilegedUser(assignee.login)) { core.info(` @${assignee.login}: privileged user — skipping.`); continue; } const assignedAt = assignedAtMap.get(login); if (!assignedAt) { core.warning( `No 'assigned' event found for @${login} on issue #${issue.number}; ` + `falling back to issue creation date (${issue.created_at}).` ); assignedAtMap.set(login, new Date(issue.created_at)); } const resolvedAssignedAt = assignedAtMap.get(login); const daysSinceAssignment = (now - resolvedAssignedAt) / (1000 * 60 * 60 * 24); core.info( ` @${login}: assigned ${daysSinceAssignment.toFixed(1)} day(s) ago, ` + `ready-for-review PR: ${linkedPRAuthorSet.has(login) ? 'yes' : 'no'}` ); if (daysSinceAssignment < GRACE_PERIOD_DAYS) { core.info(` → within grace period, skipping.`); continue; } if (linkedPRAuthorSet.has(login)) { core.info(` → ready-for-review PR found, keeping assignment.`); continue; } core.info(` → no ready-for-review PR after ${GRACE_PERIOD_DAYS} days, will unassign.`); assigneesToRemove.push(assignee.login); } if (assigneesToRemove.length === 0) { continue; } if (!dryRun) { try { await github.rest.issues.removeAssignees({ owner, repo, issue_number: issue.number, assignees: assigneesToRemove, }); } catch (err) { core.warning( `Failed to unassign ${assigneesToRemove.join(', ')} from issue #${issue.number}: ${err.message}` ); continue; } const mentionList = assigneesToRemove.map((l) => `@${l}`).join(', '); const commentBody = `👋 ${mentionList} — it has been more than ${GRACE_PERIOD_DAYS} days since ` + `you were assigned to this issue and we could not find a pull request ` + `ready for review.\n\n` + `To keep the backlog moving and ensure issues stay accessible to all ` + `contributors, we require a PR that is open and ready for review (not a ` + `draft) within ${GRACE_PERIOD_DAYS} days of assignment.\n\n` + `We are automatically unassigning you so that other contributors can pick ` + `this up. If you are still actively working on this, please:\n` + `1. Re-assign yourself by commenting \`/assign\`.\n` + `2. Open a PR (not a draft) linked to this issue (e.g. \`Fixes #${issue.number}\`) ` + `within ${GRACE_PERIOD_DAYS} days so the automation knows real progress is being made.\n\n` + `Thank you for your contribution — we hope to see a PR from you soon! 🙏`; try { await github.rest.issues.createComment({ owner, repo, issue_number: issue.number, body: commentBody, }); } catch (err) { core.warning( `Failed to post comment on issue #${issue.number}: ${err.message}` ); } } totalUnassigned += assigneesToRemove.length; core.info( ` ${dryRun ? '[DRY RUN] Would have unassigned' : 'Unassigned'}: ${assigneesToRemove.join(', ')}` ); } core.info(`\nDone. Total assignees ${dryRun ? 'that would be' : ''} unassigned: ${totalUnassigned}`);