Stale Assignment Cleanup #21
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: Stale Assignment Cleanup | |
| on: | |
| schedule: | |
| - cron: "0 0 * * *" # every day at 00:00 UTC | |
| workflow_dispatch: | |
| permissions: | |
| issues: write | |
| contents: read | |
| jobs: | |
| cleanup: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Auto-handle stale assignments (promote or unassign) | |
| uses: actions/github-script@v7 | |
| with: | |
| github-token: ${{ secrets.GITHUB_TOKEN }} | |
| script: | | |
| const { github, context } = require("@actions/github"); | |
| const owner = context.repo.owner; | |
| const repo = context.repo.repo; | |
| const now = Date.now(); | |
| const warningLimitMs = 2 * 24 * 60 * 60 * 1000; | |
| const unassignLimitMs = 3 * 24 * 60 * 60 * 1000; | |
| const issues = await github.paginate(github.rest.issues.listForRepo, { | |
| owner, | |
| repo, | |
| state: "open", | |
| assignee: "*", | |
| per_page: 100 | |
| }); | |
| const QUEUE_MARKER = "<!-- learnhub-queue-state"; | |
| const WARNING_MARKER = "<!-- stale-assignment-warning -->"; | |
| function parseQueueState(body) { | |
| const state = { | |
| primary: "", | |
| primary_assigned_at: "", | |
| backup: "", | |
| backup_approach: "", | |
| last_progress_at: "" | |
| }; | |
| if (!body) return state; | |
| const lines = String(body).split("\n"); | |
| for (const line of lines) { | |
| const m = line.match(/^\s*([a-z_]+):\s*(.*)\s*$/i); | |
| if (m) { | |
| const key = m[1].trim(); | |
| const val = m[2].trim(); | |
| if (Object.prototype.hasOwnProperty.call(state, key)) { | |
| state[key] = val; | |
| } | |
| } | |
| } | |
| return state; | |
| } | |
| function buildQueueBody(state) { | |
| return ( | |
| `${QUEUE_MARKER}\n` + | |
| `primary: ${state.primary || ""}\n` + | |
| `primary_assigned_at: ${state.primary_assigned_at || ""}\n` + | |
| `backup: ${state.backup || ""}\n` + | |
| `backup_approach: ${state.backup_approach || ""}\n` + | |
| `last_progress_at: ${state.last_progress_at || ""}\n` + | |
| `-->` | |
| ); | |
| } | |
| for (const issue of issues) { | |
| if (issue.pull_request) continue; | |
| const issueNumber = issue.number; | |
| const assignees = (issue.assignees || []).map(a => a.login); | |
| if (assignees.length === 0) continue; | |
| const labels = (issue.labels || []).map(l => String(l.name || "").toLowerCase()); | |
| if (labels.includes("pinned-assignment")) { | |
| console.log(`Skipping #${issueNumber} because it has pinned-assignment.`); | |
| continue; | |
| } | |
| const assignee = assignees[0]; | |
| const events = await github.paginate(github.rest.issues.listEvents, { | |
| owner, | |
| repo, | |
| issue_number: issueNumber, | |
| per_page: 100 | |
| }); | |
| const latestAssignedEvent = events | |
| .filter( | |
| event => | |
| event.event === "assigned" && | |
| event.assignee && | |
| event.assignee.login === assignee | |
| ) | |
| .pop(); | |
| if (!latestAssignedEvent) { | |
| console.log( | |
| `Skipping #${issueNumber} because no matching assignment event was found.` | |
| ); | |
| continue; | |
| } | |
| const assignedAtMs = new Date(latestAssignedEvent.created_at).getTime(); | |
| const elapsedMs = now - assignedAtMs; | |
| const hasLinkedPR = events.some( | |
| event => | |
| event.event === "cross-referenced" && | |
| event.source && | |
| event.source.issue && | |
| event.source.issue.pull_request | |
| ); | |
| if (hasLinkedPR) { | |
| console.log(`Skipping #${issueNumber} because it already has a linked PR.`); | |
| continue; | |
| } | |
| const comments = await github.paginate(github.rest.issues.listComments, { | |
| owner, | |
| repo, | |
| issue_number: issueNumber, | |
| per_page: 100 | |
| }); | |
| const hasWarning = comments.some(comment => | |
| String(comment.body || "").includes(WARNING_MARKER) | |
| ); | |
| const queueComment = comments.find(c => | |
| String(c.body || "").includes(QUEUE_MARKER) | |
| ); | |
| const queueState = queueComment | |
| ? parseQueueState(queueComment.body || "") | |
| : { | |
| primary: "", | |
| primary_assigned_at: "", | |
| backup: "", | |
| backup_approach: "", | |
| last_progress_at: "" | |
| }; | |
| // ≥ 3 days: promote backup or unassign | |
| if (elapsedMs >= unassignLimitMs) { | |
| const primary = queueState.primary || assignee; | |
| const backup = queueState.backup; | |
| if (backup) { | |
| const currentAssignees = (issue.assignees || []).map(a => a.login); | |
| if (currentAssignees.length > 0) { | |
| await github.rest.issues.removeAssignees({ | |
| owner, | |
| repo, | |
| issue_number: issueNumber, | |
| assignees: currentAssignees | |
| }); | |
| } | |
| await github.rest.issues.addAssignees({ | |
| owner, | |
| repo, | |
| issue_number: issueNumber, | |
| assignees: [backup] | |
| }); | |
| const promotionTimeIso = new Date().toISOString(); | |
| queueState.primary = backup; | |
| queueState.primary_assigned_at = promotionTimeIso; | |
| queueState.backup = ""; | |
| queueState.backup_approach = ""; | |
| queueState.last_progress_at = promotionTimeIso; | |
| const queueBody = buildQueueBody(queueState); | |
| if (queueComment) { | |
| await github.rest.issues.updateComment({ | |
| owner, | |
| repo, | |
| comment_id: queueComment.id, | |
| body: queueBody | |
| }); | |
| } else { | |
| await github.rest.issues.createComment({ | |
| owner, | |
| repo, | |
| issue_number: issueNumber, | |
| body: queueBody | |
| }); | |
| } | |
| try { | |
| await github.rest.issues.addLabels({ | |
| owner, | |
| repo, | |
| issue_number: issueNumber, | |
| labels: ["auto-promoted"] | |
| }); | |
| } catch (e) {} | |
| const promotionBody = | |
| `⏳ The primary claimant (@${primary}) did not show enough progress in 3 days. ` + | |
| `@${backup} is now the new priority assignee for this issue.\n\n` + | |
| `Please:\n` + | |
| `- Star the repository\n` + | |
| `- Fork the repository\n` + | |
| `- Understand the issue clearly\n` + | |
| `- Share meaningful progress as soon as you can.\n`; | |
| await github.rest.issues.createComment({ | |
| owner, | |
| repo, | |
| issue_number: issueNumber, | |
| body: promotionBody | |
| }); | |
| console.log( | |
| `Promoted @${backup} on issue #${issueNumber} because primary @${primary} was stale.` | |
| ); | |
| } else { | |
| await github.rest.issues.removeAssignees({ | |
| owner, | |
| repo, | |
| issue_number: issueNumber, | |
| assignees: [assignee] | |
| }); | |
| try { | |
| await github.rest.issues.removeLabel({ | |
| owner, | |
| repo, | |
| issue_number: issueNumber, | |
| name: "assigned" | |
| }); | |
| } catch (e) {} | |
| try { | |
| await github.rest.issues.addLabels({ | |
| owner, | |
| repo, | |
| issue_number: issueNumber, | |
| labels: ["available"] | |
| }); | |
| } catch (e) {} | |
| const unassignBody = | |
| `@${assignee} this issue has been unassigned automatically after 3 days of inactivity ` + | |
| `with no linked PR and no backup claimant. It is now open for other contributors.\n\n` + | |
| `If you still want to work on it, comment again with \`/assign\` and share your approach.\n`; | |
| await github.rest.issues.createComment({ | |
| owner, | |
| repo, | |
| issue_number: issueNumber, | |
| body: unassignBody | |
| }); | |
| console.log( | |
| `Unassigned @${assignee} from issue #${issueNumber} (no backup claimant).` | |
| ); | |
| } | |
| continue; | |
| } | |
| // ≥ 2 days, no warning yet: send reminder | |
| if (elapsedMs >= warningLimitMs && !hasWarning) { | |
| const warningBody = | |
| `${WARNING_MARKER}\n` + | |
| `@${assignee} friendly reminder: this issue has been assigned for 2 days and no linked PR was detected yet. ` + | |
| `If there is no meaningful progress within 3 days, it may be promoted to a backup claimant or unassigned automatically.\n`; | |
| await github.rest.issues.createComment({ | |
| owner, | |
| repo, | |
| issue_number: issueNumber, | |
| body: warningBody | |
| }); | |
| console.log(`Warning sent to @${assignee} on issue #${issueNumber}`); | |
| } | |
| } |