diff --git a/.github/workflows/daily-assign-issue-to-user.lock.yml b/.github/workflows/daily-assign-issue-to-user.lock.yml index 506e19d8df..174be9c3e2 100644 --- a/.github/workflows/daily-assign-issue-to-user.lock.yml +++ b/.github/workflows/daily-assign-issue-to-user.lock.yml @@ -1059,7 +1059,7 @@ jobs: uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"max\":1,\"target\":\"*\"},\"missing_data\":{},\"missing_tool\":{}}" + GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"max\":1,\"target\":\"*\"},\"assign_to_user\":{\"max\":1,\"target\":\"*\"},\"missing_data\":{},\"missing_tool\":{}}" with: github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | diff --git a/.github/workflows/smoke-codex.lock.yml b/.github/workflows/smoke-codex.lock.yml index d4c0052c5d..c58ab085d4 100644 --- a/.github/workflows/smoke-codex.lock.yml +++ b/.github/workflows/smoke-codex.lock.yml @@ -369,6 +369,38 @@ jobs: }, "name": "remove_labels" }, + { + "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": { + "additionalProperties": false, + "properties": { + "assignee": { + "description": "Single GitHub username to unassign. Use 'assignees' array for multiple users.", + "type": "string" + }, + "assignees": { + "description": "GitHub usernames to unassign from the issue (e.g., ['octocat', 'mona']).", + "items": { + "type": "string" + }, + "type": "array" + }, + "issue_number": { + "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.", + "type": [ + "number", + "string" + ] + }, + "repo": { + "description": "Target repository in 'owner/repo' format. If omitted, uses the current repository. Must be in allowed-repos list if specified.", + "type": "string" + } + }, + "type": "object" + }, + "name": "unassign_from_user" + }, { "description": "Report that a tool or capability needed to complete the task is not available, or share any information you deem important about missing functionality or limitations. Use this when you cannot accomplish what was requested because the required functionality is missing or access is restricted.", "inputSchema": { @@ -1497,7 +1529,7 @@ jobs: uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"hide_older_comments\":true,\"max\":2},\"add_labels\":{\"allowed\":[\"smoke-codex\"]},\"create_issue\":{\"close_older_issues\":true,\"expires\":2,\"max\":1},\"hide_comment\":{\"max\":5},\"missing_data\":{},\"missing_tool\":{},\"remove_labels\":{\"allowed\":[\"smoke\"]}}" + GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"hide_older_comments\":true,\"max\":2},\"add_labels\":{\"allowed\":[\"smoke-codex\"]},\"create_issue\":{\"close_older_issues\":true,\"expires\":2,\"max\":1},\"hide_comment\":{\"max\":5},\"missing_data\":{},\"missing_tool\":{},\"remove_labels\":{\"allowed\":[\"smoke\"]},\"unassign_from_user\":{\"allowed\":[\"githubactionagent\"],\"max\":1}}" with: github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | diff --git a/pkg/workflow/compiler_safe_outputs_config.go b/pkg/workflow/compiler_safe_outputs_config.go index 777745c25e..66b3662510 100644 --- a/pkg/workflow/compiler_safe_outputs_config.go +++ b/pkg/workflow/compiler_safe_outputs_config.go @@ -533,6 +533,32 @@ var handlerRegistry = map[string]handlerBuilder{ } return builder.Build() }, + "assign_to_user": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.AssignToUser == nil { + return nil + } + c := cfg.AssignToUser + return newHandlerConfigBuilder(). + AddIfPositive("max", c.Max). + AddStringSlice("allowed", c.Allowed). + AddIfNotEmpty("target", c.Target). + AddIfNotEmpty("target-repo", c.TargetRepoSlug). + AddStringSlice("allowed_repos", c.AllowedRepos). + Build() + }, + "unassign_from_user": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.UnassignFromUser == nil { + return nil + } + c := cfg.UnassignFromUser + return newHandlerConfigBuilder(). + AddIfPositive("max", c.Max). + AddStringSlice("allowed", c.Allowed). + AddIfNotEmpty("target", c.Target). + AddIfNotEmpty("target-repo", c.TargetRepoSlug). + AddStringSlice("allowed_repos", c.AllowedRepos). + Build() + }, "create_project_status_update": func(cfg *SafeOutputsConfig) map[string]any { if cfg.CreateProjectStatusUpdates == nil { return nil diff --git a/pkg/workflow/compiler_safe_outputs_config_test.go b/pkg/workflow/compiler_safe_outputs_config_test.go index ab9edbf61d..7deef8ac5a 100644 --- a/pkg/workflow/compiler_safe_outputs_config_test.go +++ b/pkg/workflow/compiler_safe_outputs_config_test.go @@ -870,3 +870,154 @@ func TestCreatePullRequestBaseBranch(t *testing.T) { }) } } + +// TestHandlerConfigAssignToUser tests assign_to_user configuration +func TestHandlerConfigAssignToUser(t *testing.T) { + compiler := NewCompiler() + + workflowData := &WorkflowData{ + Name: "Test Workflow", + SafeOutputs: &SafeOutputsConfig{ + AssignToUser: &AssignToUserConfig{ + BaseSafeOutputConfig: BaseSafeOutputConfig{ + Max: 5, + }, + SafeOutputTargetConfig: SafeOutputTargetConfig{ + Target: "issues", + TargetRepoSlug: "org/target-repo", + AllowedRepos: []string{"org/repo1", "org/repo2"}, + }, + Allowed: []string{"user1", "user2", "copilot"}, + }, + }, + } + + var steps []string + compiler.addHandlerManagerConfigEnvVar(&steps, workflowData) + + // Extract and validate JSON + for _, step := range steps { + if strings.Contains(step, "GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG") { + parts := strings.Split(step, "GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: ") + if len(parts) == 2 { + jsonStr := strings.TrimSpace(parts[1]) + jsonStr = strings.Trim(jsonStr, "\"") + jsonStr = strings.ReplaceAll(jsonStr, "\\\"", "\"") + + var config map[string]map[string]any + err := json.Unmarshal([]byte(jsonStr), &config) + require.NoError(t, err, "Handler config JSON should be valid") + + assignConfig, ok := config["assign_to_user"] + require.True(t, ok, "Should have assign_to_user handler") + + // Check max + max, ok := assignConfig["max"] + require.True(t, ok, "Should have max field") + assert.InDelta(t, 5.0, max, 0.001, "Max should be 5") + + // Check allowed users + allowed, ok := assignConfig["allowed"] + require.True(t, ok, "Should have allowed field") + allowedSlice, ok := allowed.([]any) + require.True(t, ok, "Allowed should be an array") + assert.Len(t, allowedSlice, 3, "Should have 3 allowed users") + assert.Equal(t, "user1", allowedSlice[0]) + assert.Equal(t, "user2", allowedSlice[1]) + assert.Equal(t, "copilot", allowedSlice[2]) + + // Check target + target, ok := assignConfig["target"] + require.True(t, ok, "Should have target field") + assert.Equal(t, "issues", target) + + // Check target-repo + targetRepo, ok := assignConfig["target-repo"] + require.True(t, ok, "Should have target-repo field") + assert.Equal(t, "org/target-repo", targetRepo) + + // Check allowed_repos + allowedRepos, ok := assignConfig["allowed_repos"] + require.True(t, ok, "Should have allowed_repos field") + allowedReposSlice, ok := allowedRepos.([]any) + require.True(t, ok, "Allowed repos should be an array") + assert.Len(t, allowedReposSlice, 2, "Should have 2 allowed repos") + } + } + } +} + +// TestHandlerConfigUnassignFromUser tests unassign_from_user configuration +func TestHandlerConfigUnassignFromUser(t *testing.T) { + compiler := NewCompiler() + + workflowData := &WorkflowData{ + Name: "Test Workflow", + SafeOutputs: &SafeOutputsConfig{ + UnassignFromUser: &UnassignFromUserConfig{ + BaseSafeOutputConfig: BaseSafeOutputConfig{ + Max: 10, + }, + SafeOutputTargetConfig: SafeOutputTargetConfig{ + Target: "issues", + TargetRepoSlug: "org/target-repo", + AllowedRepos: []string{"org/repo1"}, + }, + Allowed: []string{"githubactionagent", "bot-user"}, + }, + }, + } + + var steps []string + compiler.addHandlerManagerConfigEnvVar(&steps, workflowData) + + // Extract and validate JSON + for _, step := range steps { + if strings.Contains(step, "GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG") { + parts := strings.Split(step, "GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: ") + if len(parts) == 2 { + jsonStr := strings.TrimSpace(parts[1]) + jsonStr = strings.Trim(jsonStr, "\"") + jsonStr = strings.ReplaceAll(jsonStr, "\\\"", "\"") + + var config map[string]map[string]any + err := json.Unmarshal([]byte(jsonStr), &config) + require.NoError(t, err, "Handler config JSON should be valid") + + unassignConfig, ok := config["unassign_from_user"] + require.True(t, ok, "Should have unassign_from_user handler") + + // Check max + max, ok := unassignConfig["max"] + require.True(t, ok, "Should have max field") + assert.InDelta(t, 10.0, max, 0.001, "Max should be 10") + + // Check allowed users + allowed, ok := unassignConfig["allowed"] + require.True(t, ok, "Should have allowed field") + allowedSlice, ok := allowed.([]any) + require.True(t, ok, "Allowed should be an array") + assert.Len(t, allowedSlice, 2, "Should have 2 allowed users") + assert.Equal(t, "githubactionagent", allowedSlice[0]) + assert.Equal(t, "bot-user", allowedSlice[1]) + + // Check target + target, ok := unassignConfig["target"] + require.True(t, ok, "Should have target field") + assert.Equal(t, "issues", target) + + // Check target-repo + targetRepo, ok := unassignConfig["target-repo"] + require.True(t, ok, "Should have target-repo field") + assert.Equal(t, "org/target-repo", targetRepo) + + // Check allowed_repos + allowedRepos, ok := unassignConfig["allowed_repos"] + require.True(t, ok, "Should have allowed_repos field") + allowedReposSlice, ok := allowedRepos.([]any) + require.True(t, ok, "Allowed repos should be an array") + assert.Len(t, allowedReposSlice, 1, "Should have 1 allowed repo") + } + } + } +} diff --git a/pkg/workflow/js/safe_outputs_tools.json b/pkg/workflow/js/safe_outputs_tools.json index 5f51146f2b..b35bb9867c 100644 --- a/pkg/workflow/js/safe_outputs_tools.json +++ b/pkg/workflow/js/safe_outputs_tools.json @@ -561,6 +561,38 @@ "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 title, body, labels, assignees, or milestone WITHOUT closing it. This tool is primarily for editing issue metadata and content. While it supports changing status between 'open' and 'closed', use close_issue instead when you want to close an issue with a closing comment. Body updates support replacing, appending to, prepending content, or updating a per-run \"island\" section.", diff --git a/pkg/workflow/safe_outputs_tools_test.go b/pkg/workflow/safe_outputs_tools_test.go index 41dd78be1c..c4fbcbc16c 100644 --- a/pkg/workflow/safe_outputs_tools_test.go +++ b/pkg/workflow/safe_outputs_tools_test.go @@ -326,6 +326,7 @@ func TestGetSafeOutputsToolsJSON(t *testing.T) { "assign_milestone", "assign_to_agent", "assign_to_user", + "unassign_from_user", "update_issue", "update_pull_request", "push_to_pull_request_branch",