Unassign Inactive Issue Assignees #18
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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}`); |