Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions .github/scripts/bot-on-comment.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@
// bot-on-comment.js
//
// Handles issue comment events: reads the comment body, parses commands, and dispatches
// to the appropriate handler. Implemented commands: /assign (more may be added later).
// to the appropriate handler. Implemented commands: /assign, /unassign.
//
// /assign: see commands/assign.js (skill levels, assignment limits, required labels).
// /unassign: see commands/unassign.js (authorization, label reversion).

const { createLogger, buildBotContext } = require('./helpers');
const { handleAssign } = require('./commands/assign');
const { handleUnassign } = require('./commands/unassign');

let logger = createLogger('on-comment');

Expand All @@ -31,6 +33,10 @@ function parseComment(body) {
logger.log('parseComment: detected /assign');
return { commands: ['assign'] };
}
if (/^\s*\/unassign\s*$/i.test(body)) {
logger.log('parseComment: detected /unassign');
return { commands: ['unassign'] };
}
logger.log('parseComment: no known command', { body: body.substring(0, 80) });
return { commands: [] };
}
Expand Down Expand Up @@ -70,6 +76,8 @@ module.exports = async ({ github, context }) => {

if (command === 'assign') {
await handleAssign(botContext);
} else if (command === 'unassign') {
await handleUnassign(botContext);
} else {
logger.log('Unknown command:', command);
}
Expand All @@ -83,4 +91,4 @@ module.exports = async ({ github, context }) => {
});
throw error;
}
};
};
4 changes: 4 additions & 0 deletions .github/scripts/commands/assign-comments.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ function buildWelcomeComment(username, skillLevel) {
'',
'The issue description above has everything you need: implementation steps, contribution workflow, and links to guides. If anything is unclear, just ask — we\'re happy to help.',
'',
'If you realize you cannot complete this issue, simply comment `/unassign` to return it to the community pool.',
'',
'Good luck, and welcome aboard! 🚀',
].join('\n');
}
Expand All @@ -90,6 +92,8 @@ function buildWelcomeComment(username, skillLevel) {
'',
'If this task involves any design decisions or you\'d like early feedback, feel free to share your plan here before diving into the code.',
'',
'If you realize you cannot complete this issue, simply comment `/unassign` to return it to the pool.',
'',
'Good luck! 🚀',
].join('\n');
}
Expand Down
84 changes: 84 additions & 0 deletions .github/scripts/commands/unassign-comments.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// SPDX-License-Identifier: Apache-2.0
//
// commands/unassign-comments.js
//
// Comment builders for the /unassign command. Pure formatting functions
// separated from unassignment logic for readability.

const { MAINTAINER_TEAM, LABELS } = require('../helpers');

/**
* Builds the comment posted after a successful unassignment.
*
* @param {string} username - The GitHub username being unassigned.
* @returns {string} The formatted Markdown comment body.
*/
function buildSuccessfulUnassignComment(username) {
return [
`👋 Hi @${username}! You have been successfully unassigned from this issue.`,
'',
`The \`${LABELS.IN_PROGRESS}\` label has been removed, and it is now back to \`${LABELS.READY_FOR_DEV}\` for others to claim. Thanks for letting us know!`,
].join('\n');
}

/**
* Builds the comment posted when a user tries to unassign an issue they don't own.
*
* @param {string} requesterUsername - The GitHub username who commented /unassign.
* @param {string} currentAssignee - The GitHub username of the actual assignee.
* @returns {string} The formatted Markdown comment body.
*/
function buildNotAssignedToUserComment(requesterUsername, currentAssignee) {
const assigneeText = currentAssignee ? `@${currentAssignee}` : 'someone else';
return [
`⚠️ Hi @${requesterUsername}! You cannot unassign this issue because it is currently assigned to ${assigneeText}.`,
'',
'Only the current assignee can unassign themselves.',
].join('\n');
}

/**
* Builds the comment posted when the issue has no assignees.
*
* @param {string} requesterUsername - The GitHub username who commented /unassign.
* @returns {string} The formatted Markdown comment body.
*/
function buildNoAssigneeComment(requesterUsername) {
return [
`👋 Hi @${requesterUsername}! This issue doesn't currently have any assignees.`,
].join('\n');
}

/**
* Builds the comment posted when the issue is already closed.
*
* @param {string} requesterUsername - The GitHub username who commented /unassign.
* @returns {string} The formatted Markdown comment body.
*/
function buildIssueClosedComment(requesterUsername) {
return [
`👋 Hi @${requesterUsername}! This issue is already closed, so the \`/unassign\` command cannot be used here.`,
].join('\n');
}

/**
* Builds the comment posted when the unassign API call fails.
*
* @param {string} requesterUsername - The GitHub username who commented /unassign.
* @returns {string} The formatted Markdown comment body.
*/
function buildUnassignFailureComment(requesterUsername) {
return [
`⚠️ Hi @${requesterUsername}! I tried to unassign you, but encountered an unexpected error.`,
'',
`${MAINTAINER_TEAM} — could you please manually unassign @${requesterUsername}?`,
].join('\n');
}

module.exports = {
buildSuccessfulUnassignComment,
buildNotAssignedToUserComment,
buildNoAssigneeComment,
buildIssueClosedComment,
buildUnassignFailureComment,
};
105 changes: 105 additions & 0 deletions .github/scripts/commands/unassign.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
// SPDX-License-Identifier: Apache-2.0
//
// commands/unassign.js
//
// /unassign command: allows a currently assigned contributor to unassign themselves.
// Enforces authorization (only assignees can unassign themselves) and reverts
// status labels back to the community pool.

const {
LABELS,
ISSUE_STATE,
getLogger,
hasLabel,
addLabels,
removeLabel,
removeAssignees,
postComment,
} = require('../helpers');

const {
buildSuccessfulUnassignComment,
buildNotAssignedToUserComment,
buildNoAssigneeComment,
buildIssueClosedComment,
buildUnassignFailureComment,
} = require('./unassign-comments');

// Delegate to the active logger set by the dispatcher.
const logger = {
log: (...args) => getLogger().log(...args),
error: (...args) => getLogger().error(...args),
};

/**
* Main handler for the /unassign command. Runs the following gates in order:
*
* 1. Is the issue already closed? -> issue-closed comment.
* 2. Does the issue have no assignees? -> no-assignee comment.
* 3. Is the commenter not the current assignee? -> unauthorized comment.
*
* On success: removes the user as an assignee, reverts the "in progress"
* label to "ready for dev", and posts an acknowledgment comment.
*
* @param {{ github: object, owner: string, repo: string, number: number,
* issue: object, comment: { user: { login: string } } }} botContext
* @returns {Promise<void>}
*/
async function handleUnassign(botContext) {
const requesterUsername = botContext.comment.user.login;
const issue = botContext.issue;

// GATE 1: Issue is closed
if (issue.state === ISSUE_STATE.CLOSED) {
logger.log('Exit: issue is closed');
await postComment(botContext, buildIssueClosedComment(requesterUsername));
return;
}

const assignees = issue.assignees || [];

// GATE 2: No one is assigned at all
if (assignees.length === 0) {
logger.log('Exit: issue has no assignees');
await postComment(botContext, buildNoAssigneeComment(requesterUsername));
return;
}

// GATE 3: Authorization check (case-insensitive)
const isAssigned = assignees.some(
(a) => (a?.login || '').toLowerCase() === requesterUsername.toLowerCase()
);
if (!isAssigned) {
logger.log(`Exit: @${requesterUsername} is not assigned to this issue`);
const currentAssignee = assignees[0]?.login; // Grab the actual assignee for the message
await postComment(botContext, buildNotAssignedToUserComment(requesterUsername, currentAssignee));
return;
}

// ACTION 1: Remove the assignee
logger.log(`Unassigning @${requesterUsername}`);
const removeResult = await removeAssignees(botContext, [requesterUsername]);
if (!removeResult.success) {
await postComment(botContext, buildUnassignFailureComment(requesterUsername));
return;
}

// ACTION 2: Label Swapping (Mirroring assign.js style - no stale checks)
logger.log(`Swapping labels: removing ${LABELS.IN_PROGRESS}, adding ${LABELS.READY_FOR_DEV}`);

const removeLabelResult = await removeLabel(botContext, LABELS.IN_PROGRESS);
if (!removeLabelResult.success) {
logger.error(`Failed to remove ${LABELS.IN_PROGRESS}: ${removeLabelResult.error}`);
}

const addLabelResult = await addLabels(botContext, [LABELS.READY_FOR_DEV]);
if (!addLabelResult.success) {
logger.error(`Failed to add ${LABELS.READY_FOR_DEV}: ${addLabelResult.error}`);
}

// ACTION 3: Post success acknowledgment
await postComment(botContext, buildSuccessfulUnassignComment(requesterUsername));
logger.log(`Successfully unassigned @${requesterUsername} and reverted labels`);
}

module.exports = { handleUnassign };
33 changes: 33 additions & 0 deletions .github/scripts/helpers/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,38 @@ async function addAssignees(botContext, assignees) {
}
}

/**
* Safely removes assignees from an issue or PR.
* @param {object} botContext - Bot context (github, owner, repo, number).
* @param {string[]} assignees - Array of usernames to remove.
* @returns {Promise<{success: boolean, error?: string}>} - Result object.
*/
async function removeAssignees(botContext, assignees) {
if (!Array.isArray(assignees)) {
return { success: false, error: 'assignees must be an array' };
}

try {
for (let i = 0; i < assignees.length; i++) {
requireSafeUsername(assignees[i], `assignees[${i}]`);
}

await botContext.github.rest.issues.removeAssignees({
owner: botContext.owner,
repo: botContext.repo,
issue_number: botContext.number,
assignees,
});

getLogger().log(`Removed assignees: ${assignees.join(', ')}`);
return { success: true };
} catch (error) {
getLogger().error(`Could not remove assignees "${assignees.join(', ')}": ${error.message}`);
return { success: false, error: error.message };
}
}


/**
* Safely posts a comment on an issue or PR.
* @param {object} botContext - Bot context (github, owner, repo, number).
Expand Down Expand Up @@ -437,6 +469,7 @@ module.exports = {
addLabels,
removeLabel,
addAssignees,
removeAssignees,
postComment,
hasLabel,
postOrUpdateComment,
Expand Down
20 changes: 17 additions & 3 deletions .github/scripts/tests/test-assign-bot.js
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,8 @@ You've been assigned this **Good First Issue**, and the **Good First Issue Suppo

The issue description above has everything you need: implementation steps, contribution workflow, and links to guides. If anything is unclear, just ask — we're happy to help.

If you realize you cannot complete this issue, simply comment \`/unassign\` to return it to the community pool.

Good luck, and welcome aboard! 🚀`,
],
},
Expand Down Expand Up @@ -211,6 +213,8 @@ Good luck, and welcome aboard! 🚀`,

If this task involves any design decisions or you'd like early feedback, feel free to share your plan here before diving into the code.

If you realize you cannot complete this issue, simply comment \`/unassign\` to return it to the pool.

Good luck! 🚀`,
],
},
Expand Down Expand Up @@ -244,6 +248,8 @@ Good luck! 🚀`,

If this task involves any design decisions or you'd like early feedback, feel free to share your plan here before diving into the code.

If you realize you cannot complete this issue, simply comment \`/unassign\` to return it to the pool.

Good luck! 🚀`,
],
},
Expand Down Expand Up @@ -277,6 +283,8 @@ Good luck! 🚀`,

If this task involves any design decisions or you'd like early feedback, feel free to share your plan here before diving into the code.

If you realize you cannot complete this issue, simply comment \`/unassign\` to return it to the pool.

Good luck! 🚀`,
],
},
Expand Down Expand Up @@ -311,7 +319,7 @@ Good luck! 🚀`,
},
expectedAssignee: 'bypass-user-1',
expectedComments: [
`👋 Hi @bypass-user-1, thanks for continuing to contribute to the Hiero C++ SDK! You've been assigned this **Beginner** issue. 🙌\n\nIf this task involves any design decisions or you'd like early feedback, feel free to share your plan here before diving into the code.\n\nGood luck! 🚀`,
`👋 Hi @bypass-user-1, thanks for continuing to contribute to the Hiero C++ SDK! You've been assigned this **Beginner** issue. 🙌\n\nIf this task involves any design decisions or you'd like early feedback, feel free to share your plan here before diving into the code.\n\nIf you realize you cannot complete this issue, simply comment \`/unassign\` to return it to the pool.\n\nGood luck! 🚀`,
],
},

Expand Down Expand Up @@ -340,7 +348,7 @@ Good luck! 🚀`,
},
expectedAssignee: 'bypass-user-2',
expectedComments: [
`👋 Hi @bypass-user-2, thanks for continuing to contribute to the Hiero C++ SDK! You've been assigned this **Beginner** issue. 🙌\n\nIf this task involves any design decisions or you'd like early feedback, feel free to share your plan here before diving into the code.\n\nGood luck! 🚀`,
`👋 Hi @bypass-user-2, thanks for continuing to contribute to the Hiero C++ SDK! You've been assigned this **Beginner** issue. 🙌\n\nIf this task involves any design decisions or you'd like early feedback, feel free to share your plan here before diving into the code.\n\nIf you realize you cannot complete this issue, simply comment \`/unassign\` to return it to the pool.\n\nGood luck! 🚀`,
],
},

Expand Down Expand Up @@ -749,6 +757,8 @@ You've been assigned this **Good First Issue**, and the **Good First Issue Suppo

The issue description above has everything you need: implementation steps, contribution workflow, and links to guides. If anything is unclear, just ask — we're happy to help.

If you realize you cannot complete this issue, simply comment \`/unassign\` to return it to the community pool.

Good luck, and welcome aboard! 🚀`,
],
},
Expand Down Expand Up @@ -787,6 +797,8 @@ You've been assigned this **Good First Issue**, and the **Good First Issue Suppo

The issue description above has everything you need: implementation steps, contribution workflow, and links to guides. If anything is unclear, just ask — we're happy to help.

If you realize you cannot complete this issue, simply comment \`/unassign\` to return it to the community pool.

Good luck, and welcome aboard! 🚀`,
],
},
Expand Down Expand Up @@ -926,6 +938,8 @@ You've been assigned this **Good First Issue**, and the **Good First Issue Suppo

The issue description above has everything you need: implementation steps, contribution workflow, and links to guides. If anything is unclear, just ask — we're happy to help.

If you realize you cannot complete this issue, simply comment \`/unassign\` to return it to the community pool.

Good luck, and welcome aboard! 🚀`,
`⚠️ @partially-lucky has been successfully assigned to this issue, but I encountered an error updating the labels.

Expand Down Expand Up @@ -1048,4 +1062,4 @@ async function runTest(scenario, index) {
return results.passed;
}

runTestSuite('BOT-ASSIGN-ON-COMMENT TEST SUITE', scenarios, runTest);
runTestSuite('BOT-ASSIGN-ON-COMMENT TEST SUITE', scenarios, runTest);
Loading
Loading