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
5 changes: 5 additions & 0 deletions .changeset/patch-add-unassign-safe-output.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions .github/workflows/smoke-codex.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions .github/workflows/smoke-codex.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ safe-outputs:
allowed: [smoke-codex]
remove-labels:
allowed: [smoke]
unassign-from-user:
allowed: [githubactionagent]
max: 1
hide-comment:
messages:
footer: "> 🔮 *The oracle has spoken through [{workflow_name}]({run_url})*"
Expand Down Expand Up @@ -89,3 +92,4 @@ Add a **very brief** comment (max 5-10 lines) to the current pull request with:
If all tests pass:
- Use the `add_labels` safe-output tool to add the label `smoke-codex` to the pull request
- Use the `remove_labels` safe-output tool to remove the label `smoke` from the pull request
- 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)
1 change: 1 addition & 0 deletions actions/setup/js/safe_output_handler_manager.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ const HANDLER_MAP = {
add_reviewer: "./add_reviewer.cjs",
assign_milestone: "./assign_milestone.cjs",
assign_to_user: "./assign_to_user.cjs",
unassign_from_user: "./unassign_from_user.cjs",
create_code_scanning_alert: "./create_code_scanning_alert.cjs",
autofix_code_scanning_alert: "./autofix_code_scanning_alert.cjs",
dispatch_workflow: "./dispatch_workflow.cjs",
Expand Down
1 change: 1 addition & 0 deletions actions/setup/js/safe_output_unified_handler_manager.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ const HANDLER_MAP = {
add_reviewer: "./add_reviewer.cjs",
assign_milestone: "./assign_milestone.cjs",
assign_to_user: "./assign_to_user.cjs",
unassign_from_user: "./unassign_from_user.cjs",
create_code_scanning_alert: "./create_code_scanning_alert.cjs",
autofix_code_scanning_alert: "./autofix_code_scanning_alert.cjs",
dispatch_workflow: "./dispatch_workflow.cjs",
Expand Down
29 changes: 29 additions & 0 deletions actions/setup/js/safe_outputs_tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,35 @@
"additionalProperties": false
}
},
{
"name": "unassign_from_user",
"description": "Remove one or more assignees from an issue. Use this to unassign users when work is being reassigned or removed from their queue.",
"inputSchema": {
"type": "object",
"properties": {
"issue_number": {
"type": ["number", "string"],
"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."
},
"assignees": {
"type": "array",
"items": {
"type": "string"
},
"description": "GitHub usernames to unassign from the issue (e.g., ['octocat', 'mona'])."
},
"assignee": {
"type": "string",
"description": "Single GitHub username to unassign. Use 'assignees' array for multiple users."
},
"repo": {
"type": "string",
"description": "Target repository in 'owner/repo' format. If omitted, uses the current repository. Must be in allowed-repos list if specified."
}
},
"additionalProperties": false
}
},
{
"name": "update_issue",
"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.",
Expand Down
151 changes: 151 additions & 0 deletions actions/setup/js/unassign_from_user.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
// @ts-check
/// <reference types="@actions/github-script" />

/**
* @typedef {import('./types/handler-factory').HandlerFactoryFunction} HandlerFactoryFunction
*/

const { processItems } = require("./safe_output_processor.cjs");
const { getErrorMessage } = require("./error_helpers.cjs");
const { resolveTargetRepoConfig, resolveAndValidateRepo } = require("./repo_helpers.cjs");

/** @type {string} Safe output type handled by this module */
const HANDLER_TYPE = "unassign_from_user";

/**
* Main handler factory for unassign_from_user
* Returns a message handler function that processes individual unassign_from_user messages
* @type {HandlerFactoryFunction}
*/
async function main(config = {}) {
// Extract configuration
const allowedAssignees = config.allowed || [];
const maxCount = config.max || 10;
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The handler defaults max to 10, but the PR description, schema, and Go-side defaulting indicate the default should be 1 operation. This mismatch can unintentionally allow more unassign operations when users enable the safe output with null/empty config. Align the default here with the documented default (and prefer nullish-coalescing so an explicitly provided value is respected as-is).

Suggested change
const maxCount = config.max || 10;
const maxCount = config.max ?? 1;

Copilot uses AI. Check for mistakes.

// Resolve target repository configuration
const { defaultTargetRepo, allowedRepos } = resolveTargetRepoConfig(config);

core.info(`Unassign from user configuration: max=${maxCount}`);
if (allowedAssignees.length > 0) {
core.info(`Allowed assignees to unassign: ${allowedAssignees.join(", ")}`);
}
core.info(`Default target repository: ${defaultTargetRepo}`);
if (allowedRepos.size > 0) {
core.info(`Additional allowed repositories: ${Array.from(allowedRepos).join(", ")}`);
}

// Track how many items we've processed for max limit
let processedCount = 0;

/**
* Message handler function that processes a single unassign_from_user message
* @param {Object} message - The unassign_from_user message to process
* @param {Object} resolvedTemporaryIds - Map of temporary IDs to {repo, number}
* @returns {Promise<Object>} Result with success/error status
*/
return async function handleUnassignFromUser(message, resolvedTemporaryIds) {
// Check if we've hit the max limit
if (processedCount >= maxCount) {
core.warning(`Skipping unassign_from_user: max count of ${maxCount} reached`);
return {
success: false,
error: `Max count of ${maxCount} reached`,
};
}

processedCount++;
Comment on lines +48 to +56
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two related issues with maxCount: (1) processedCount++ happens before validating message/repo/assignees and before performing any API call, so invalid inputs (or no-op cases) still consume the run's operation budget; this can block subsequent valid operations. (2) Passing maxCount into processItems(...) couples "max operations per run" to "max assignees per single request", which can incorrectly truncate the assignee list when max is small (e.g., max: 1 would only ever remove a single assignee per call). Increment the counter only when you actually perform an unassign API call, and use a separate per-message limit (or no limit) for assignee list processing.

Copilot uses AI. Check for mistakes.

const unassignItem = message;

// Determine issue number
let issueNumber;
if (unassignItem.issue_number !== undefined) {
issueNumber = parseInt(String(unassignItem.issue_number), 10);
if (isNaN(issueNumber)) {
core.warning(`Invalid issue_number: ${unassignItem.issue_number}`);
return {
success: false,
error: `Invalid issue_number: ${unassignItem.issue_number}`,
};
}
Comment on lines +63 to +70
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parseInt will accept partially-numeric strings (e.g., '123abc') and non-integer numbers (e.g., 123.9) and silently coerce them to 123, which can target the wrong issue. Since issue_number must be an integer, validate that the parsed value is an integer and that the original input is strictly an integer representation (or use Number(...) + Number.isInteger(...) with appropriate string checks).

Suggested change
issueNumber = parseInt(String(unassignItem.issue_number), 10);
if (isNaN(issueNumber)) {
core.warning(`Invalid issue_number: ${unassignItem.issue_number}`);
return {
success: false,
error: `Invalid issue_number: ${unassignItem.issue_number}`,
};
}
const rawIssueNumber = String(unassignItem.issue_number).trim();
// Require a strict non-negative integer representation for issue numbers
if (!/^\d+$/.test(rawIssueNumber)) {
core.warning(`Invalid issue_number: ${unassignItem.issue_number}`);
return {
success: false,
error: `Invalid issue_number: ${unassignItem.issue_number}`,
};
}
issueNumber = Number(rawIssueNumber);
if (!Number.isInteger(issueNumber) || issueNumber <= 0) {
core.warning(`Invalid issue_number: ${unassignItem.issue_number}`);
return {
success: false,
error: `Invalid issue_number: ${unassignItem.issue_number}`,
};
}

Copilot uses AI. Check for mistakes.
} else {
// Use context issue if available
const contextIssue = context.payload?.issue?.number;
if (!contextIssue) {
core.warning("No issue_number provided and not in issue context");
return {
success: false,
error: "No issue number available",
};
}
issueNumber = contextIssue;
}

// Support both singular "assignee" and plural "assignees" for flexibility
let requestedAssignees = [];
if (unassignItem.assignees && Array.isArray(unassignItem.assignees)) {
requestedAssignees = unassignItem.assignees;
} else if (unassignItem.assignee) {
requestedAssignees = [unassignItem.assignee];
}

core.info(`Requested assignees to unassign: ${JSON.stringify(requestedAssignees)}`);

// Use shared helper to filter, sanitize, dedupe, and limit
const uniqueAssignees = processItems(requestedAssignees, allowedAssignees, maxCount);
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two related issues with maxCount: (1) processedCount++ happens before validating message/repo/assignees and before performing any API call, so invalid inputs (or no-op cases) still consume the run's operation budget; this can block subsequent valid operations. (2) Passing maxCount into processItems(...) couples "max operations per run" to "max assignees per single request", which can incorrectly truncate the assignee list when max is small (e.g., max: 1 would only ever remove a single assignee per call). Increment the counter only when you actually perform an unassign API call, and use a separate per-message limit (or no limit) for assignee list processing.

Copilot uses AI. Check for mistakes.

if (uniqueAssignees.length === 0) {
core.info("No assignees to remove");
return {
success: true,
issueNumber: issueNumber,
assigneesRemoved: [],
message: "No valid assignees found",
};
}

// Resolve and validate target repository
const repoResult = resolveAndValidateRepo(unassignItem, defaultTargetRepo, allowedRepos, "issue");

if (!repoResult.success) {
core.warning(`Repository validation failed: ${repoResult.error}`);
return {
success: false,
error: repoResult.error,
};
}

const repoParts = repoResult.repoParts;
const targetRepo = repoResult.repo;

core.info(`Unassigning ${uniqueAssignees.length} users from issue #${issueNumber} in ${targetRepo}: ${JSON.stringify(uniqueAssignees)}`);

try {
// Remove assignees from the issue
await github.rest.issues.removeAssignees({
owner: repoParts.owner,
repo: repoParts.repo,
issue_number: issueNumber,
assignees: uniqueAssignees,
});

core.info(`Successfully unassigned ${uniqueAssignees.length} user(s) from issue #${issueNumber} in ${targetRepo}`);

return {
success: true,
issueNumber: issueNumber,
repo: targetRepo,
assigneesRemoved: uniqueAssignees,
};
} catch (error) {
const errorMessage = getErrorMessage(error);
core.error(`Failed to unassign users: ${errorMessage}`);
return {
success: false,
error: errorMessage,
};
}
};
}

module.exports = { main };
Loading