Skip to content

Commit fd67f05

Browse files
Copilotpelikhangithub-actions[bot]
authored
Add unassign-from-user safe output handler (#15219)
* Initial plan * Add unassign-from-user safe output handler implementation Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> * Fix unassign_from_user test - use allowed_repos instead of allowed-repos Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> * Add unassign-from-user to JSON schema and rebuild binary Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> * Complete unassign-from-user implementation - all tests passing Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> * Add unassign_from_user to config generation - complete feature Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> * Add unassign-from-user test to smoke-codex workflow - Add unassign-from-user to safe-outputs config with githubactionagent allowed - Add test instruction to unassign fictitious user githubactionagent from PR - Addresses PR review comment to test unassign functionality Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> * Add changeset [skip-ci] --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> Co-authored-by: Peli de Halleux <pelikhan@users.noreply.github.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent 51f851c commit fd67f05

15 files changed

Lines changed: 660 additions & 4 deletions

.changeset/patch-add-unassign-safe-output.md

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.github/workflows/smoke-codex.lock.yml

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.github/workflows/smoke-codex.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ safe-outputs:
5151
allowed: [smoke-codex]
5252
remove-labels:
5353
allowed: [smoke]
54+
unassign-from-user:
55+
allowed: [githubactionagent]
56+
max: 1
5457
hide-comment:
5558
messages:
5659
footer: "> 🔮 *The oracle has spoken through [{workflow_name}]({run_url})*"
@@ -89,3 +92,4 @@ Add a **very brief** comment (max 5-10 lines) to the current pull request with:
8992
If all tests pass:
9093
- Use the `add_labels` safe-output tool to add the label `smoke-codex` to the pull request
9194
- Use the `remove_labels` safe-output tool to remove the label `smoke` from the pull request
95+
- Use the `unassign_from_user` safe-output tool to unassign the user `githubactionagent` from the pull request (this is a fictitious user used for testing)

actions/setup/js/safe_output_handler_manager.cjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ const HANDLER_MAP = {
4343
add_reviewer: "./add_reviewer.cjs",
4444
assign_milestone: "./assign_milestone.cjs",
4545
assign_to_user: "./assign_to_user.cjs",
46+
unassign_from_user: "./unassign_from_user.cjs",
4647
create_code_scanning_alert: "./create_code_scanning_alert.cjs",
4748
autofix_code_scanning_alert: "./autofix_code_scanning_alert.cjs",
4849
dispatch_workflow: "./dispatch_workflow.cjs",

actions/setup/js/safe_output_unified_handler_manager.cjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ const HANDLER_MAP = {
5151
add_reviewer: "./add_reviewer.cjs",
5252
assign_milestone: "./assign_milestone.cjs",
5353
assign_to_user: "./assign_to_user.cjs",
54+
unassign_from_user: "./unassign_from_user.cjs",
5455
create_code_scanning_alert: "./create_code_scanning_alert.cjs",
5556
autofix_code_scanning_alert: "./autofix_code_scanning_alert.cjs",
5657
dispatch_workflow: "./dispatch_workflow.cjs",

actions/setup/js/safe_outputs_tools.json

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,35 @@
405405
"additionalProperties": false
406406
}
407407
},
408+
{
409+
"name": "unassign_from_user",
410+
"description": "Remove one or more assignees from an issue. Use this to unassign users when work is being reassigned or removed from their queue.",
411+
"inputSchema": {
412+
"type": "object",
413+
"properties": {
414+
"issue_number": {
415+
"type": ["number", "string"],
416+
"description": "Issue number to unassign users from. This is the numeric ID from the GitHub URL (e.g., 543 in github.com/owner/repo/issues/543). If omitted, uses the issue that triggered this workflow."
417+
},
418+
"assignees": {
419+
"type": "array",
420+
"items": {
421+
"type": "string"
422+
},
423+
"description": "GitHub usernames to unassign from the issue (e.g., ['octocat', 'mona'])."
424+
},
425+
"assignee": {
426+
"type": "string",
427+
"description": "Single GitHub username to unassign. Use 'assignees' array for multiple users."
428+
},
429+
"repo": {
430+
"type": "string",
431+
"description": "Target repository in 'owner/repo' format. If omitted, uses the current repository. Must be in allowed-repos list if specified."
432+
}
433+
},
434+
"additionalProperties": false
435+
}
436+
},
408437
{
409438
"name": "update_issue",
410439
"description": "Update an existing GitHub issue's status, title, or body. Use this to modify issue properties after creation. Only the fields you specify will be updated; other fields remain unchanged.",
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
// @ts-check
2+
/// <reference types="@actions/github-script" />
3+
4+
/**
5+
* @typedef {import('./types/handler-factory').HandlerFactoryFunction} HandlerFactoryFunction
6+
*/
7+
8+
const { processItems } = require("./safe_output_processor.cjs");
9+
const { getErrorMessage } = require("./error_helpers.cjs");
10+
const { resolveTargetRepoConfig, resolveAndValidateRepo } = require("./repo_helpers.cjs");
11+
12+
/** @type {string} Safe output type handled by this module */
13+
const HANDLER_TYPE = "unassign_from_user";
14+
15+
/**
16+
* Main handler factory for unassign_from_user
17+
* Returns a message handler function that processes individual unassign_from_user messages
18+
* @type {HandlerFactoryFunction}
19+
*/
20+
async function main(config = {}) {
21+
// Extract configuration
22+
const allowedAssignees = config.allowed || [];
23+
const maxCount = config.max || 10;
24+
25+
// Resolve target repository configuration
26+
const { defaultTargetRepo, allowedRepos } = resolveTargetRepoConfig(config);
27+
28+
core.info(`Unassign from user configuration: max=${maxCount}`);
29+
if (allowedAssignees.length > 0) {
30+
core.info(`Allowed assignees to unassign: ${allowedAssignees.join(", ")}`);
31+
}
32+
core.info(`Default target repository: ${defaultTargetRepo}`);
33+
if (allowedRepos.size > 0) {
34+
core.info(`Additional allowed repositories: ${Array.from(allowedRepos).join(", ")}`);
35+
}
36+
37+
// Track how many items we've processed for max limit
38+
let processedCount = 0;
39+
40+
/**
41+
* Message handler function that processes a single unassign_from_user message
42+
* @param {Object} message - The unassign_from_user message to process
43+
* @param {Object} resolvedTemporaryIds - Map of temporary IDs to {repo, number}
44+
* @returns {Promise<Object>} Result with success/error status
45+
*/
46+
return async function handleUnassignFromUser(message, resolvedTemporaryIds) {
47+
// Check if we've hit the max limit
48+
if (processedCount >= maxCount) {
49+
core.warning(`Skipping unassign_from_user: max count of ${maxCount} reached`);
50+
return {
51+
success: false,
52+
error: `Max count of ${maxCount} reached`,
53+
};
54+
}
55+
56+
processedCount++;
57+
58+
const unassignItem = message;
59+
60+
// Determine issue number
61+
let issueNumber;
62+
if (unassignItem.issue_number !== undefined) {
63+
issueNumber = parseInt(String(unassignItem.issue_number), 10);
64+
if (isNaN(issueNumber)) {
65+
core.warning(`Invalid issue_number: ${unassignItem.issue_number}`);
66+
return {
67+
success: false,
68+
error: `Invalid issue_number: ${unassignItem.issue_number}`,
69+
};
70+
}
71+
} else {
72+
// Use context issue if available
73+
const contextIssue = context.payload?.issue?.number;
74+
if (!contextIssue) {
75+
core.warning("No issue_number provided and not in issue context");
76+
return {
77+
success: false,
78+
error: "No issue number available",
79+
};
80+
}
81+
issueNumber = contextIssue;
82+
}
83+
84+
// Support both singular "assignee" and plural "assignees" for flexibility
85+
let requestedAssignees = [];
86+
if (unassignItem.assignees && Array.isArray(unassignItem.assignees)) {
87+
requestedAssignees = unassignItem.assignees;
88+
} else if (unassignItem.assignee) {
89+
requestedAssignees = [unassignItem.assignee];
90+
}
91+
92+
core.info(`Requested assignees to unassign: ${JSON.stringify(requestedAssignees)}`);
93+
94+
// Use shared helper to filter, sanitize, dedupe, and limit
95+
const uniqueAssignees = processItems(requestedAssignees, allowedAssignees, maxCount);
96+
97+
if (uniqueAssignees.length === 0) {
98+
core.info("No assignees to remove");
99+
return {
100+
success: true,
101+
issueNumber: issueNumber,
102+
assigneesRemoved: [],
103+
message: "No valid assignees found",
104+
};
105+
}
106+
107+
// Resolve and validate target repository
108+
const repoResult = resolveAndValidateRepo(unassignItem, defaultTargetRepo, allowedRepos, "issue");
109+
110+
if (!repoResult.success) {
111+
core.warning(`Repository validation failed: ${repoResult.error}`);
112+
return {
113+
success: false,
114+
error: repoResult.error,
115+
};
116+
}
117+
118+
const repoParts = repoResult.repoParts;
119+
const targetRepo = repoResult.repo;
120+
121+
core.info(`Unassigning ${uniqueAssignees.length} users from issue #${issueNumber} in ${targetRepo}: ${JSON.stringify(uniqueAssignees)}`);
122+
123+
try {
124+
// Remove assignees from the issue
125+
await github.rest.issues.removeAssignees({
126+
owner: repoParts.owner,
127+
repo: repoParts.repo,
128+
issue_number: issueNumber,
129+
assignees: uniqueAssignees,
130+
});
131+
132+
core.info(`Successfully unassigned ${uniqueAssignees.length} user(s) from issue #${issueNumber} in ${targetRepo}`);
133+
134+
return {
135+
success: true,
136+
issueNumber: issueNumber,
137+
repo: targetRepo,
138+
assigneesRemoved: uniqueAssignees,
139+
};
140+
} catch (error) {
141+
const errorMessage = getErrorMessage(error);
142+
core.error(`Failed to unassign users: ${errorMessage}`);
143+
return {
144+
success: false,
145+
error: errorMessage,
146+
};
147+
}
148+
};
149+
}
150+
151+
module.exports = { main };

0 commit comments

Comments
 (0)