diff --git a/.changeset/patch-add-unassign-safe-output.md b/.changeset/patch-add-unassign-safe-output.md
new file mode 100644
index 0000000000..4f61d398d6
--- /dev/null
+++ b/.changeset/patch-add-unassign-safe-output.md
@@ -0,0 +1,5 @@
+---
+"gh-aw": patch
+---
+
+Add the new `unassign-from-user` safe-output handler and supporting schema, tooling, and tests.
diff --git a/.github/workflows/smoke-codex.lock.yml b/.github/workflows/smoke-codex.lock.yml
index b905e27bb2..f23240c6c2 100644
--- a/.github/workflows/smoke-codex.lock.yml
+++ b/.github/workflows/smoke-codex.lock.yml
@@ -26,7 +26,7 @@
# - shared/gh.md
# - shared/reporting.md
#
-# frontmatter-hash: d14eb1ef5a6b53f5e4b7776732d82a9ed8cf879ec0961523225d34b5dec423a0
+# frontmatter-hash: aba13da089edf9a0eb1467c6f2263a22a1b81e717fe0482fd8ddd72d3f592069
name: "Smoke Codex"
"on":
@@ -256,7 +256,7 @@ jobs:
mkdir -p /tmp/gh-aw/safeoutputs
mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs
cat > /opt/gh-aw/safeoutputs/config.json << 'GH_AW_SAFE_OUTPUTS_CONFIG_EOF'
- {"add_comment":{"max":2},"add_labels":{"allowed":["smoke-codex"],"max":3},"create_issue":{"expires":2,"max":1},"hide_comment":{"max":5},"missing_data":{},"missing_tool":{},"noop":{"max":1},"remove_labels":{"allowed":["smoke"],"max":3}}
+ {"add_comment":{"max":2},"add_labels":{"allowed":["smoke-codex"],"max":3},"create_issue":{"expires":2,"max":1},"hide_comment":{"max":5},"missing_data":{},"missing_tool":{},"noop":{"max":1},"remove_labels":{"allowed":["smoke"],"max":3},"unassign_from_user":{"allowed":["githubactionagent"],"max":1}}
GH_AW_SAFE_OUTPUTS_CONFIG_EOF
cat > /opt/gh-aw/safeoutputs/tools.json << 'GH_AW_SAFE_OUTPUTS_TOOLS_EOF'
[
diff --git a/.github/workflows/smoke-codex.md b/.github/workflows/smoke-codex.md
index 863d179ade..7c41deae11 100644
--- a/.github/workflows/smoke-codex.md
+++ b/.github/workflows/smoke-codex.md
@@ -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})*"
@@ -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)
diff --git a/actions/setup/js/safe_output_handler_manager.cjs b/actions/setup/js/safe_output_handler_manager.cjs
index de615d7f8d..720b73f332 100644
--- a/actions/setup/js/safe_output_handler_manager.cjs
+++ b/actions/setup/js/safe_output_handler_manager.cjs
@@ -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",
diff --git a/actions/setup/js/safe_output_unified_handler_manager.cjs b/actions/setup/js/safe_output_unified_handler_manager.cjs
index 1ac542fb41..cce0ea8934 100644
--- a/actions/setup/js/safe_output_unified_handler_manager.cjs
+++ b/actions/setup/js/safe_output_unified_handler_manager.cjs
@@ -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",
diff --git a/actions/setup/js/safe_outputs_tools.json b/actions/setup/js/safe_outputs_tools.json
index b396d04dde..9485637335 100644
--- a/actions/setup/js/safe_outputs_tools.json
+++ b/actions/setup/js/safe_outputs_tools.json
@@ -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.",
diff --git a/actions/setup/js/unassign_from_user.cjs b/actions/setup/js/unassign_from_user.cjs
new file mode 100644
index 0000000000..02eb7be45e
--- /dev/null
+++ b/actions/setup/js/unassign_from_user.cjs
@@ -0,0 +1,151 @@
+// @ts-check
+///
+
+/**
+ * @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;
+
+ // 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