Skip to content

Stale Assignment Cleanup #21

Stale Assignment Cleanup

Stale Assignment Cleanup #21

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}`);
}
}