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} 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++; + + 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}`, + }; + } + } 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); + + 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 }; diff --git a/actions/setup/js/unassign_from_user.test.cjs b/actions/setup/js/unassign_from_user.test.cjs new file mode 100644 index 0000000000..47f65861b9 --- /dev/null +++ b/actions/setup/js/unassign_from_user.test.cjs @@ -0,0 +1,270 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +const mockCore = { + debug: vi.fn(), + info: vi.fn(), + warning: vi.fn(), + error: vi.fn(), + setFailed: vi.fn(), + setOutput: vi.fn(), + summary: { + addRaw: vi.fn().mockReturnThis(), + write: vi.fn().mockResolvedValue(), + }, +}; + +const mockContext = { + repo: { + owner: "test-owner", + repo: "test-repo", + }, + eventName: "issues", + payload: { + issue: { + number: 123, + }, + }, +}; + +const mockGithub = { + rest: { + issues: { + removeAssignees: vi.fn(), + }, + }, +}; + +global.core = mockCore; +global.context = mockContext; +global.github = mockGithub; + +describe("unassign_from_user (Handler Factory Architecture)", () => { + let handler; + + beforeEach(async () => { + vi.clearAllMocks(); + + const { main } = require("./unassign_from_user.cjs"); + handler = await main({ + max: 10, + allowed: ["user1", "user2", "user3"], + }); + }); + + it("should return a function from main()", async () => { + const { main } = require("./unassign_from_user.cjs"); + const result = await main({}); + expect(typeof result).toBe("function"); + }); + + it("should unassign users successfully", async () => { + mockGithub.rest.issues.removeAssignees.mockResolvedValue({}); + + const message = { + type: "unassign_from_user", + assignees: ["user1", "user2"], + }; + + const result = await handler(message, {}); + + expect(result.success).toBe(true); + expect(result.issueNumber).toBe(123); + expect(result.assigneesRemoved).toEqual(["user1", "user2"]); + expect(mockGithub.rest.issues.removeAssignees).toHaveBeenCalledWith({ + owner: "test-owner", + repo: "test-repo", + issue_number: 123, + assignees: ["user1", "user2"], + }); + }); + + it("should support singular assignee field", async () => { + mockGithub.rest.issues.removeAssignees.mockResolvedValue({}); + + const message = { + type: "unassign_from_user", + assignee: "user1", + }; + + const result = await handler(message, {}); + + expect(result.success).toBe(true); + expect(result.assigneesRemoved).toEqual(["user1"]); + expect(mockGithub.rest.issues.removeAssignees).toHaveBeenCalledWith({ + owner: "test-owner", + repo: "test-repo", + issue_number: 123, + assignees: ["user1"], + }); + }); + + it("should use explicit issue number from message", async () => { + mockGithub.rest.issues.removeAssignees.mockResolvedValue({}); + + const message = { + type: "unassign_from_user", + issue_number: 456, + assignees: ["user1"], + }; + + const result = await handler(message, {}); + + expect(result.success).toBe(true); + expect(result.issueNumber).toBe(456); + expect(mockGithub.rest.issues.removeAssignees).toHaveBeenCalledWith({ + owner: "test-owner", + repo: "test-repo", + issue_number: 456, + assignees: ["user1"], + }); + }); + + it("should filter by allowed assignees", async () => { + mockGithub.rest.issues.removeAssignees.mockResolvedValue({}); + + const message = { + type: "unassign_from_user", + assignees: ["user1", "user2", "unauthorized"], + }; + + const result = await handler(message, {}); + + expect(result.success).toBe(true); + expect(result.assigneesRemoved).toEqual(["user1", "user2"]); + expect(mockGithub.rest.issues.removeAssignees).toHaveBeenCalledWith({ + owner: "test-owner", + repo: "test-repo", + issue_number: 123, + assignees: ["user1", "user2"], + }); + }); + + it("should respect max count configuration", async () => { + const { main } = require("./unassign_from_user.cjs"); + const limitedHandler = await main({ max: 1, allowed: ["user1", "user2"] }); + + mockGithub.rest.issues.removeAssignees.mockResolvedValue({}); + + const message1 = { + type: "unassign_from_user", + assignees: ["user1"], + }; + + const message2 = { + type: "unassign_from_user", + assignees: ["user2"], + }; + + // First call should succeed + const result1 = await limitedHandler(message1, {}); + expect(result1.success).toBe(true); + + // Second call should fail + const result2 = await limitedHandler(message2, {}); + expect(result2.success).toBe(false); + expect(result2.error).toContain("Max count"); + }); + + it("should handle missing issue context", async () => { + global.context = { + repo: { + owner: "test-owner", + repo: "test-repo", + }, + eventName: "push", + payload: {}, + }; + + const message = { + type: "unassign_from_user", + assignees: ["user1"], + }; + + const result = await handler(message, {}); + + expect(result.success).toBe(false); + expect(result.error).toContain("No issue number available"); + expect(mockGithub.rest.issues.removeAssignees).not.toHaveBeenCalled(); + + // Restore context + global.context = mockContext; + }); + + it("should handle API errors gracefully", async () => { + const apiError = new Error("API error"); + mockGithub.rest.issues.removeAssignees.mockRejectedValue(apiError); + + const message = { + type: "unassign_from_user", + assignees: ["user1"], + }; + + const result = await handler(message, {}); + + expect(result.success).toBe(false); + expect(result.error).toContain("API error"); + }); + + it("should return success with empty array when no valid assignees", async () => { + const message = { + type: "unassign_from_user", + assignees: [], + }; + + const result = await handler(message, {}); + + expect(result.success).toBe(true); + expect(result.assigneesRemoved).toEqual([]); + expect(result.message).toContain("No valid assignees found"); + expect(mockGithub.rest.issues.removeAssignees).not.toHaveBeenCalled(); + }); + + it("should deduplicate assignees", async () => { + mockGithub.rest.issues.removeAssignees.mockResolvedValue({}); + + const message = { + type: "unassign_from_user", + assignees: ["user1", "user2", "user1", "user2"], + }; + + const result = await handler(message, {}); + + expect(result.success).toBe(true); + expect(result.assigneesRemoved).toEqual(["user1", "user2"]); + expect(mockGithub.rest.issues.removeAssignees).toHaveBeenCalledWith({ + owner: "test-owner", + repo: "test-repo", + issue_number: 123, + assignees: ["user1", "user2"], + }); + }); + + it("should support cross-repository unassignment", async () => { + const { main } = require("./unassign_from_user.cjs"); + const crossRepoHandler = await main({ + max: 10, + allowed: ["user1"], + allowed_repos: ["test-owner/other-repo"], + }); + + mockGithub.rest.issues.removeAssignees.mockResolvedValue({}); + + const message = { + type: "unassign_from_user", + issue_number: 789, + assignees: ["user1"], + repo: "test-owner/other-repo", + }; + + const result = await crossRepoHandler(message, {}); + + expect(result.success).toBe(true); + expect(result.repo).toBe("test-owner/other-repo"); + expect(mockGithub.rest.issues.removeAssignees).toHaveBeenCalledWith({ + owner: "test-owner", + repo: "other-repo", + issue_number: 789, + assignees: ["user1"], + }); + }); +}); diff --git a/docs/src/content/docs/reference/frontmatter-full.md b/docs/src/content/docs/reference/frontmatter-full.md index 8d049e52d5..dc127ac500 100644 --- a/docs/src/content/docs/reference/frontmatter-full.md +++ b/docs/src/content/docs/reference/frontmatter-full.md @@ -2927,6 +2927,48 @@ safe-outputs: # (optional) github-token: "${{ secrets.GITHUB_TOKEN }}" + # Enable AI agents to unassign users from issues or pull requests. Useful for + # reassigning work or removing users from issues. + # (optional) + # This field supports multiple formats (oneOf): + + # Option 1: Enable user unassignment with default configuration + unassign-from-user: null + + # Option 2: Configuration for removing assignees from issues in agentic workflow + # output + unassign-from-user: + # Optional list of allowed usernames. If specified, only these users can be + # unassigned. + # (optional) + allowed: [] + # Array of strings + + # Optional maximum number of unassignment operations (default: 1) + # (optional) + max: 1 + + # Target issue to unassign users from. Use 'triggering' (default) for the + # triggering issue, '*' to allow any issue, or a specific issue number. + # (optional) + target: null + + # Target repository in format 'owner/repo' for cross-repository user unassignment. + # Takes precedence over trial target repo settings. + # (optional) + target-repo: "example-value" + + # List of allowed repositories in format 'owner/repo' for cross-repository + # unassignment operations. Use with 'repo' field in tool calls. + # (optional) + allowed-repos: [] + # Array of strings + + # GitHub token to use for this specific output type. Overrides global github-token + # if specified. + # (optional) + github-token: "${{ secrets.GITHUB_TOKEN }}" + # Enable AI agents to create hierarchical relationships between issues using # GitHub's sub-issue (tasklist) feature. # (optional) diff --git a/pkg/cli/workflows/test-unassign-from-user.md b/pkg/cli/workflows/test-unassign-from-user.md new file mode 100644 index 0000000000..4d8a20c5a7 --- /dev/null +++ b/pkg/cli/workflows/test-unassign-from-user.md @@ -0,0 +1,40 @@ +--- +engine: copilot +name: test-unassign-from-user +on: + workflow_dispatch: +safe-outputs: + unassign-from-user: + max: 5 + allowed: ["testuser1", "testuser2"] + target: "*" +--- + +# Test unassign-from-user Safe Output + +This is a test workflow to validate the `unassign-from-user` safe output handler. + +## Test Cases + +1. **Basic unassignment**: Remove a single assignee from an issue +2. **Multiple assignees**: Remove multiple assignees at once +3. **Allowed list**: Verify that only allowed usernames can be unassigned +4. **Max limit**: Verify that the max configuration is respected +5. **Target repository**: Verify cross-repository unassignment support + +## Instructions + +When this workflow runs, test the following scenarios: + +- Use `unassign_from_user` tool to remove assignees from test issues +- Verify the configuration allows up to 5 unassignment operations +- Ensure only "testuser1" and "testuser2" can be unassigned +- Test that the tool works with both `assignee` (singular) and `assignees` (plural) fields + +## Expected Behavior + +The workflow should: +- Successfully unassign users from issues when they are in the allowed list +- Reject unassignment attempts for users not in the allowed list +- Respect the max count limit of 5 operations +- Support cross-repository unassignment when target-repo is configured diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index f310d9176e..6d2e823c1e 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -5236,6 +5236,53 @@ ], "description": "Enable AI agents to assign issues or pull requests to specific GitHub users based on workflow logic or expertise matching." }, + "unassign-from-user": { + "oneOf": [ + { + "type": "null", + "description": "Enable user unassignment with default configuration" + }, + { + "type": "object", + "description": "Configuration for removing assignees from issues in agentic workflow output", + "properties": { + "allowed": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Optional list of allowed usernames. If specified, only these users can be unassigned." + }, + "max": { + "type": "integer", + "description": "Optional maximum number of unassignment operations (default: 1)", + "minimum": 1 + }, + "target": { + "type": ["string", "number"], + "description": "Target issue to unassign users from. Use 'triggering' (default) for the triggering issue, '*' to allow any issue, or a specific issue number." + }, + "target-repo": { + "type": "string", + "description": "Target repository in format 'owner/repo' for cross-repository user unassignment. Takes precedence over trial target repo settings." + }, + "allowed-repos": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of allowed repositories in format 'owner/repo' for cross-repository unassignment operations. Use with 'repo' field in tool calls." + }, + "github-token": { + "$ref": "#/$defs/github_token", + "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." + } + }, + "additionalProperties": false + } + ], + "description": "Enable AI agents to unassign users from issues or pull requests. Useful for reassigning work or removing users from issues." + }, "link-sub-issue": { "oneOf": [ { diff --git a/pkg/workflow/compiler_types.go b/pkg/workflow/compiler_types.go index fc5570e9cd..3f97daca83 100644 --- a/pkg/workflow/compiler_types.go +++ b/pkg/workflow/compiler_types.go @@ -474,7 +474,8 @@ type SafeOutputsConfig struct { AddReviewer *AddReviewerConfig `yaml:"add-reviewer,omitempty"` AssignMilestone *AssignMilestoneConfig `yaml:"assign-milestone,omitempty"` AssignToAgent *AssignToAgentConfig `yaml:"assign-to-agent,omitempty"` - AssignToUser *AssignToUserConfig `yaml:"assign-to-user,omitempty"` // Assign users to issues + AssignToUser *AssignToUserConfig `yaml:"assign-to-user,omitempty"` // Assign users to issues + UnassignFromUser *UnassignFromUserConfig `yaml:"unassign-from-user,omitempty"` // Remove assignees from issues UpdateIssues *UpdateIssuesConfig `yaml:"update-issues,omitempty"` UpdatePullRequests *UpdatePullRequestsConfig `yaml:"update-pull-request,omitempty"` // Update GitHub pull request title/body PushToPullRequestBranch *PushToPullRequestBranchConfig `yaml:"push-to-pull-request-branch,omitempty"` diff --git a/pkg/workflow/safe_outputs_config.go b/pkg/workflow/safe_outputs_config.go index 13b8ac3fb6..e0bc9ac20f 100644 --- a/pkg/workflow/safe_outputs_config.go +++ b/pkg/workflow/safe_outputs_config.go @@ -172,6 +172,12 @@ func (c *Compiler) extractSafeOutputsConfig(frontmatter map[string]any) *SafeOut config.AssignToUser = assignToUserConfig } + // Handle unassign-from-user + unassignFromUserConfig := c.parseUnassignFromUserConfig(outputMap) + if unassignFromUserConfig != nil { + config.UnassignFromUser = unassignFromUserConfig + } + // Handle update-issue updateIssuesConfig := c.parseUpdateIssuesConfig(outputMap) if updateIssuesConfig != nil { diff --git a/pkg/workflow/safe_outputs_config_generation.go b/pkg/workflow/safe_outputs_config_generation.go index 061b75d10a..64ef6218fb 100644 --- a/pkg/workflow/safe_outputs_config_generation.go +++ b/pkg/workflow/safe_outputs_config_generation.go @@ -195,6 +195,13 @@ func generateSafeOutputsConfig(data *WorkflowData) string { data.SafeOutputs.AssignToUser.Allowed, ) } + if data.SafeOutputs.UnassignFromUser != nil { + safeOutputsConfig["unassign_from_user"] = generateMaxWithAllowedConfig( + data.SafeOutputs.UnassignFromUser.Max, + 1, // default max + data.SafeOutputs.UnassignFromUser.Allowed, + ) + } if data.SafeOutputs.UpdateIssues != nil { safeOutputsConfig["update_issue"] = generateMaxConfig( data.SafeOutputs.UpdateIssues.Max, @@ -619,6 +626,9 @@ func generateFilteredToolsJSON(data *WorkflowData, markdownPath string) (string, if data.SafeOutputs.AssignToUser != nil { enabledTools["assign_to_user"] = true } + if data.SafeOutputs.UnassignFromUser != nil { + enabledTools["unassign_from_user"] = true + } if data.SafeOutputs.UpdateIssues != nil { enabledTools["update_issue"] = true } @@ -848,7 +858,7 @@ func addRepoParameterIfNeeded(tool map[string]any, toolName string, safeOutputs targetRepoSlug = config.TargetRepoSlug } case "add_labels", "remove_labels", "hide_comment", "link_sub_issue", "mark_pull_request_as_ready_for_review", - "add_reviewer", "assign_milestone", "assign_to_agent", "assign_to_user": + "add_reviewer", "assign_milestone", "assign_to_agent", "assign_to_user", "unassign_from_user": // These use SafeOutputTargetConfig - check the appropriate config switch toolName { case "add_labels": @@ -896,6 +906,11 @@ func addRepoParameterIfNeeded(tool map[string]any, toolName string, safeOutputs hasAllowedRepos = len(config.AllowedRepos) > 0 targetRepoSlug = config.TargetRepoSlug } + case "unassign_from_user": + if config := safeOutputs.UnassignFromUser; config != nil { + hasAllowedRepos = len(config.AllowedRepos) > 0 + targetRepoSlug = config.TargetRepoSlug + } } } diff --git a/pkg/workflow/unassign_from_user.go b/pkg/workflow/unassign_from_user.go new file mode 100644 index 0000000000..2775971986 --- /dev/null +++ b/pkg/workflow/unassign_from_user.go @@ -0,0 +1,44 @@ +package workflow + +import ( + "github.com/github/gh-aw/pkg/logger" +) + +var unassignFromUserLog = logger.New("workflow:unassign_from_user") + +// UnassignFromUserConfig holds configuration for removing assignees from issues +type UnassignFromUserConfig struct { + BaseSafeOutputConfig `yaml:",inline"` + SafeOutputTargetConfig `yaml:",inline"` + Allowed []string `yaml:"allowed,omitempty"` // Optional list of allowed usernames. If omitted, any users can be unassigned. +} + +// parseUnassignFromUserConfig handles unassign-from-user configuration +func (c *Compiler) parseUnassignFromUserConfig(outputMap map[string]any) *UnassignFromUserConfig { + // Check if the key exists + if _, exists := outputMap["unassign-from-user"]; !exists { + return nil + } + + unassignFromUserLog.Print("Parsing unassign-from-user configuration") + + // Unmarshal into typed config struct + var config UnassignFromUserConfig + if err := unmarshalConfig(outputMap, "unassign-from-user", &config, unassignFromUserLog); err != nil { + unassignFromUserLog.Printf("Failed to unmarshal config: %v", err) + // For backward compatibility, use defaults + unassignFromUserLog.Print("Using default configuration") + config = UnassignFromUserConfig{ + BaseSafeOutputConfig: BaseSafeOutputConfig{Max: 1}, + } + } + + // Set default max if not specified + if config.Max == 0 { + config.Max = 1 + } + + unassignFromUserLog.Printf("Parsed configuration: allowed_count=%d, target=%s", len(config.Allowed), config.Target) + + return &config +}