Skip to content

Issue Assignment Bot #71

Issue Assignment Bot

Issue Assignment Bot #71

name: Issue Assignment Bot
on:
issue_comment:
types: [created]
schedule:
- cron: '0 */6 * * *'
permissions:
issues: write
contents: read
jobs:
handle-assignment:
if: github.event_name == 'issue_comment' && github.event.issue.pull_request == null
runs-on: ubuntu-latest
concurrency:
group: issue-assignment-${{ github.event.issue.number }}
cancel-in-progress: false
permissions:
issues: write
contents: read
steps:
- name: Handle assignment and unassignment requests
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const commentBody = context.payload.comment.body.trim();
const commentLower = commentBody.toLowerCase();
const issueNumber = context.payload.issue.number;
const commenter = context.payload.comment.user.login;
const owner = context.repo.owner;
const repo = context.repo.repo;
// ── Detect intent ────────────────────────────────────────────────
// /unassign @username (maintainer targeted unassign)
const maintainerUnassignMatch = commentBody.match(
/^\/unassign\s+@([A-Za-z0-9\-]+)/i
);
// /unassign (self-unassign, no username)
const selfUnassign =
!maintainerUnassignMatch &&
/^\/unassign\s*$/i.test(commentBody);
// /assign or natural-language assignment triggers
const assignmentTriggers = [
'/assign',
'assign me',
'i want to work on this',
'can i work on this',
'can i be assigned'
];
const isAssignRequest =
!maintainerUnassignMatch &&
!selfUnassign &&
assignmentTriggers.some(t => commentLower.includes(t));
if (!maintainerUnassignMatch && !selfUnassign && !isAssignRequest) {
core.info('Comment does not match any handled command. Skipping.');
return;
}
// ── Fetch current issue state ────────────────────────────────────
const { data: issue } = await github.rest.issues.get({
owner,
repo,
issue_number: issueNumber
});
const currentAssignees = (issue.assignees || []).map(a => a.login);
// ── Helper ───────────────────────────────────────────────────────
const postComment = body =>
github.rest.issues.createComment({ owner, repo, issue_number: issueNumber, body });
// ════════════════════════════════════════════════════════════════
// CASE 1 — Maintainer: /unassign @username
// ════════════════════════════════════════════════════════════════
if (maintainerUnassignMatch) {
const targetUser = maintainerUnassignMatch[1];
if (currentAssignees.length > 1) {
await github.rest.issues.removeAssignees({
owner, repo, issue_number: issueNumber,
assignees: currentAssignees
});
await postComment(
`All assignees have been removed from this issue. It is now open for anyone — comment \`/assign\` to claim it!`
);
} else {
await github.rest.issues.removeAssignees({
owner, repo, issue_number: issueNumber,
assignees: [targetUser]
});
await postComment(
`Hey @${targetUser}, you have been unassigned from this issue by a maintainer. It is now open for others to pick up — comment \`/assign\` to claim it! 👋`
);
}
return;
}
// ════════════════════════════════════════════════════════════════
// CASE 2 — Self-unassign: /unassign
// ════════════════════════════════════════════════════════════════
if (selfUnassign) {
if (!currentAssignees.includes(commenter)) {
await postComment(
`Hey @${commenter}, you are not currently assigned to this issue. 😅`
);
return;
}
await github.rest.issues.removeAssignees({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
assignees: [commenter]
});
await postComment(
`Hey @${commenter}, you have been unassigned from this issue. It is now open for anyone to pick up — comment \`/assign\` to claim it! 👋`
);
return;
}
// ════════════════════════════════════════════════════════════════
// CASE 3 — Assign request
// ════════════════════════════════════════════════════════════════
// Enforce single assignee: reject if anyone is already assigned
if (currentAssignees.length > 0) {
await postComment(
`Hey @${commenter}, this issue is already assigned to @${currentAssignees[0]}. Please pick another issue or wait for it to become available. You can browse open unassigned issues here: https://github.com/magic-peach/reframe/issues?q=is%3Aissue+is%3Aopen+no%3Aassignee`
);
return;
}
// Count open issues currently assigned to the commenter
let assignedCount = 0;
let page = 1;
while (true) {
const { data } = await github.rest.issues.listForRepo({
owner, repo, state: 'open', assignee: commenter, per_page: 100, page
});
assignedCount += data.filter(i => !i.pull_request).length;
if (data.length < 100) break;
page++;
}
if (assignedCount >= 5) {
await postComment(
`Hey @${commenter}, you already have 5 issues assigned to you. Please complete or unassign one before picking up more. 🙏`
);
return;
}
// Remove any stale assignees first, then set exactly this one person
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: [commenter]
});
await postComment(
`Hey @${commenter}, this issue has been assigned to you! 🎉 Please make progress within 5 days or it will be automatically unassigned due to inactivity.`
);
inactivity-check:
if: github.event_name == 'schedule'
runs-on: ubuntu-latest
steps:
- name: Check for inactive assigned issues
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const now = Date.now();
const DAY_MS = 24 * 60 * 60 * 1000;
// Fetch all open issues that have at least one assignee
let page = 1;
const assignedIssues = [];
while (true) {
const response = await github.rest.issues.listForRepo({
owner,
repo,
state: 'open',
per_page: 100,
page
});
const filtered = response.data.filter(
i => !i.pull_request && i.assignees && i.assignees.length > 0
);
assignedIssues.push(...filtered);
if (response.data.length < 100) break;
page++;
}
core.info(`Found ${assignedIssues.length} open assigned issues to check.`);
for (const issue of assignedIssues) {
const assignee = issue.assignees[0].login;
const issueNumber = issue.number;
const commentsResponse = await github.rest.issues.listComments({
owner,
repo,
issue_number: issueNumber,
per_page: 100
});
let lastActivityTimestamp = new Date(issue.updated_at).getTime();
if (commentsResponse.data.length > 0) {
const lastComment = commentsResponse.data[commentsResponse.data.length - 1];
const lastCommentTime = new Date(lastComment.created_at).getTime();
if (lastCommentTime > lastActivityTimestamp) {
lastActivityTimestamp = lastCommentTime;
}
}
const daysSinceActivity = (now - lastActivityTimestamp) / DAY_MS;
core.info(`Issue #${issueNumber} (@${assignee}): ${daysSinceActivity.toFixed(2)} days since last activity.`);
const botComments = commentsResponse.data.filter(
c => c.user.login === 'github-actions[bot]'
);
const commentBodies = botComments.map(c => c.body);
if (daysSinceActivity >= 5) {
await github.rest.issues.removeAssignees({
owner,
repo,
issue_number: issueNumber,
assignees: [assignee]
});
await github.rest.issues.createComment({
owner,
repo,
issue_number: issueNumber,
body: `This issue has been unassigned from @${assignee} due to 5 days of inactivity and is open for anyone to pick up — comment \`/assign\` to claim it!`
});
} else if (daysSinceActivity >= 4) {
const alreadyWarned = commentBodies.some(b =>
b.includes('final warning') && b.includes('4 days')
);
if (!alreadyWarned) {
await github.rest.issues.createComment({
owner,
repo,
issue_number: issueNumber,
body: `Hey @${assignee}, final warning! ⚠️ This issue has had no activity for 4 days and will be automatically unassigned in 24 hours if there is no update.`
});
}
} else if (daysSinceActivity >= 2) {
const alreadyWarned = commentBodies.some(b =>
b.includes('2 days') && b.includes('no activity')
);
if (!alreadyWarned) {
await github.rest.issues.createComment({
owner,
repo,
issue_number: issueNumber,
body: `Hey @${assignee}, just a friendly reminder that this issue has had no activity for 2 days. Please leave an update or let us know if you need help! ⏰`
});
}
}
}