From d65be4f1ee61d98120a851eba81ebe95de01d602 Mon Sep 17 00:00:00 2001 From: Russell Horton Date: Tue, 2 Dec 2025 10:41:26 -0800 Subject: [PATCH 1/4] Add assign-to-user safe output for assigning users to issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new safe output type `assign-to-user` that allows workflows to assign GitHub users to existing issues. This complements the existing `assign-to-agent` which only supports bot/copilot assignment. Features: - Assign any GitHub user to an issue by username - Optional `allowed` list to restrict assignable users - Cross-repo support via `target-repo` - Configurable `max` limit for assignments Usage: ```yaml safe-outputs: assign-to-user: max: 3 allowed: [user1, user2] ``` Agent output: ```json {"type": "assign_to_user", "issue_number": 123, "username": "octocat"} ``` 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- pkg/workflow/assign_to_user.go | 70 +++++++ pkg/workflow/compiler.go | 1 + pkg/workflow/compiler_jobs.go | 18 ++ pkg/workflow/imports.go | 5 + pkg/workflow/js/assign_to_user.cjs | 192 ++++++++++++++++++ pkg/workflow/js/safe_outputs_tools.json | 19 ++ pkg/workflow/js/types/safe-outputs.d.ts | 13 ++ pkg/workflow/safe_output_validation_config.go | 7 + pkg/workflow/safe_outputs.go | 84 ++++++++ pkg/workflow/scripts.go | 5 + pkg/workflow/tool_description_enhancer.go | 10 + 11 files changed, 424 insertions(+) create mode 100644 pkg/workflow/assign_to_user.go create mode 100644 pkg/workflow/js/assign_to_user.cjs diff --git a/pkg/workflow/assign_to_user.go b/pkg/workflow/assign_to_user.go new file mode 100644 index 00000000000..766d3ac7b96 --- /dev/null +++ b/pkg/workflow/assign_to_user.go @@ -0,0 +1,70 @@ +package workflow + +import ( + "fmt" +) + +// AssignToUserConfig holds configuration for assigning users to issues from agent output +type AssignToUserConfig struct { + BaseSafeOutputConfig `yaml:",inline"` + Target string `yaml:"target,omitempty"` // Target for user assignment: "triggering" (default) or "*" for any issue + TargetRepoSlug string `yaml:"target-repo,omitempty"` // Target repository in format "owner/repo" for cross-repository assignments + Allowed []string `yaml:"allowed,omitempty"` // List of allowed usernames that can be assigned +} + +// buildAssignToUserJob creates the assign_to_user job +func (c *Compiler) buildAssignToUserJob(data *WorkflowData, mainJobName string) (*Job, error) { + if data.SafeOutputs == nil || data.SafeOutputs.AssignToUser == nil { + return nil, fmt.Errorf("safe-outputs.assign-to-user configuration is required") + } + + maxCount := 1 + if data.SafeOutputs.AssignToUser.Max > 0 { + maxCount = data.SafeOutputs.AssignToUser.Max + } + + // Build custom environment variables specific to assign-to-user + var customEnvVars []string + + // Pass the max limit + customEnvVars = append(customEnvVars, fmt.Sprintf(" GH_AW_USER_MAX_COUNT: %d\n", maxCount)) + + // Pass allowed users if configured + if len(data.SafeOutputs.AssignToUser.Allowed) > 0 { + allowedJSON, err := toJSON(data.SafeOutputs.AssignToUser.Allowed) + if err == nil { + customEnvVars = append(customEnvVars, fmt.Sprintf(" GH_AW_ALLOWED_USERS: %s\n", singleQuote(allowedJSON))) + } + } + + // Add standard environment variables (metadata + staged/target repo) + customEnvVars = append(customEnvVars, c.buildStandardSafeOutputEnvVars(data, data.SafeOutputs.AssignToUser.TargetRepoSlug)...) + + // Get token from config for step-level github-token + var token string + if data.SafeOutputs.AssignToUser != nil { + token = data.SafeOutputs.AssignToUser.GitHubToken + } + + // Create outputs for the job + outputs := map[string]string{ + "assigned_users": "${{ steps.assign_to_user.outputs.assigned_users }}", + } + + // Use the shared builder function to create the job + // User assignment only requires issues:write permission + return c.buildSafeOutputJob(data, SafeOutputJobConfig{ + JobName: "assign_to_user", + StepName: "Assign to User", + StepID: "assign_to_user", + MainJobName: mainJobName, + CustomEnvVars: customEnvVars, + Script: getAssignToUserScript(), + Permissions: NewPermissionsIssuesWrite(), + Outputs: outputs, + Token: token, + UseAgentToken: false, // Regular user assignment doesn't need agent token + Condition: BuildSafeOutputType("assign_to_user"), + TargetRepoSlug: data.SafeOutputs.AssignToUser.TargetRepoSlug, + }) +} diff --git a/pkg/workflow/compiler.go b/pkg/workflow/compiler.go index 1408596b3cd..731e92cf98e 100644 --- a/pkg/workflow/compiler.go +++ b/pkg/workflow/compiler.go @@ -275,6 +275,7 @@ 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"` 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/compiler_jobs.go b/pkg/workflow/compiler_jobs.go index 29e925a95f4..647852c07f2 100644 --- a/pkg/workflow/compiler_jobs.go +++ b/pkg/workflow/compiler_jobs.go @@ -474,6 +474,24 @@ func (c *Compiler) buildSafeOutputsJobs(data *WorkflowData, jobName, markdownPat safeOutputJobNames = append(safeOutputJobNames, assignToAgentJob.Name) } + // Build assign_to_user job if output.assign-to-user is configured + if data.SafeOutputs.AssignToUser != nil { + assignToUserJob, err := c.buildAssignToUserJob(data, jobName) + if err != nil { + return fmt.Errorf("failed to build assign_to_user job: %w", err) + } + // Safe-output jobs should depend on agent job (always) AND detection job (if enabled) + if threatDetectionEnabled { + assignToUserJob.Needs = append(assignToUserJob.Needs, constants.DetectionJobName) + // Add detection success check to the job condition + assignToUserJob.If = AddDetectionSuccessCheck(assignToUserJob.If) + } + if err := c.jobManager.AddJob(assignToUserJob); err != nil { + return fmt.Errorf("failed to add assign_to_user job: %w", err) + } + safeOutputJobNames = append(safeOutputJobNames, assignToUserJob.Name) + } + // Build update_issue job if output.update-issue is configured if data.SafeOutputs.UpdateIssues != nil { updateIssueJob, err := c.buildCreateOutputUpdateIssueJob(data, jobName) diff --git a/pkg/workflow/imports.go b/pkg/workflow/imports.go index a872b8383ab..3c032f41736 100644 --- a/pkg/workflow/imports.go +++ b/pkg/workflow/imports.go @@ -450,6 +450,8 @@ func hasSafeOutputType(config *SafeOutputsConfig, key string) bool { return config.AssignMilestone != nil case "assign-to-agent": return config.AssignToAgent != nil + case "assign-to-user": + return config.AssignToUser != nil case "update-issue": return config.UpdateIssues != nil case "update-pull-request": @@ -528,6 +530,9 @@ func mergeSafeOutputConfig(result *SafeOutputsConfig, config map[string]any, c * if result.AssignToAgent == nil && importedConfig.AssignToAgent != nil { result.AssignToAgent = importedConfig.AssignToAgent } + if result.AssignToUser == nil && importedConfig.AssignToUser != nil { + result.AssignToUser = importedConfig.AssignToUser + } if result.UpdateIssues == nil && importedConfig.UpdateIssues != nil { result.UpdateIssues = importedConfig.UpdateIssues } diff --git a/pkg/workflow/js/assign_to_user.cjs b/pkg/workflow/js/assign_to_user.cjs new file mode 100644 index 00000000000..0fda4249b82 --- /dev/null +++ b/pkg/workflow/js/assign_to_user.cjs @@ -0,0 +1,192 @@ +// @ts-check +/// + +const { loadAgentOutput } = require("./load_agent_output.cjs"); +const { generateStagedPreview } = require("./staged_preview.cjs"); + +async function main() { + const result = loadAgentOutput(); + if (!result.success) { + return; + } + + const assignItems = result.items.filter(item => item.type === "assign_to_user"); + if (assignItems.length === 0) { + core.info("No assign_to_user items found in agent output"); + return; + } + + core.info(`Found ${assignItems.length} assign_to_user item(s)`); + + // Check if we're in staged mode + if (process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true") { + await generateStagedPreview({ + title: "Assign to User", + description: "The following user assignments would be made if staged mode was disabled:", + items: assignItems, + renderItem: item => { + let content = `**Issue:** #${item.issue_number}\n`; + content += `**User:** ${item.username}\n`; + content += "\n"; + return content; + }, + }); + return; + } + + // Get max count configuration + const maxCountEnv = process.env.GH_AW_USER_MAX_COUNT; + const maxCount = maxCountEnv ? parseInt(maxCountEnv, 10) : 1; + if (isNaN(maxCount) || maxCount < 1) { + core.setFailed(`Invalid max value: ${maxCountEnv}. Must be a positive integer`); + return; + } + core.info(`Max count: ${maxCount}`); + + // Get allowed users configuration + const allowedUsersEnv = process.env.GH_AW_ALLOWED_USERS; + let allowedUsers = null; + if (allowedUsersEnv) { + try { + allowedUsers = JSON.parse(allowedUsersEnv); + if (!Array.isArray(allowedUsers)) { + allowedUsers = null; + } + } catch { + core.warning(`Failed to parse allowed users: ${allowedUsersEnv}`); + } + } + if (allowedUsers) { + core.info(`Allowed users: ${allowedUsers.join(", ")}`); + } + + // Limit items to max count + const itemsToProcess = assignItems.slice(0, maxCount); + if (assignItems.length > maxCount) { + core.warning(`Found ${assignItems.length} user assignments, but max is ${maxCount}. Processing first ${maxCount}.`); + } + + // Get target repository configuration + const targetRepoEnv = process.env.GH_AW_TARGET_REPO?.trim(); + let targetOwner = context.repo.owner; + let targetRepo = context.repo.repo; + + if (targetRepoEnv) { + const parts = targetRepoEnv.split("/"); + if (parts.length === 2) { + targetOwner = parts[0]; + targetRepo = parts[1]; + core.info(`Using target repository: ${targetOwner}/${targetRepo}`); + } else { + core.warning(`Invalid target-repo format: ${targetRepoEnv}. Expected owner/repo. Using current repository.`); + } + } + + // Process each user assignment + const results = []; + for (const item of itemsToProcess) { + const issueNumber = typeof item.issue_number === "number" ? item.issue_number : parseInt(String(item.issue_number), 10); + const username = item.username?.trim(); + + if (isNaN(issueNumber) || issueNumber <= 0) { + core.error(`Invalid issue_number: ${item.issue_number}`); + results.push({ + issue_number: item.issue_number, + username: username || "unknown", + success: false, + error: "Invalid issue number", + }); + continue; + } + + if (!username) { + core.error(`Missing username for issue #${issueNumber}`); + results.push({ + issue_number: issueNumber, + username: "unknown", + success: false, + error: "Missing username", + }); + continue; + } + + // Check if user is in allowed list (if configured) + if (allowedUsers && !allowedUsers.includes(username)) { + core.warning(`User "${username}" is not in the allowed list`); + results.push({ + issue_number: issueNumber, + username: username, + success: false, + error: `User "${username}" is not in the allowed list`, + }); + continue; + } + + // Assign the user to the issue using the REST API + try { + core.info(`Assigning user "${username}" to issue #${issueNumber}...`); + + await github.rest.issues.addAssignees({ + owner: targetOwner, + repo: targetRepo, + issue_number: issueNumber, + assignees: [username], + }); + + core.info(`Successfully assigned user "${username}" to issue #${issueNumber}`); + results.push({ + issue_number: issueNumber, + username: username, + success: true, + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + core.error(`Failed to assign user "${username}" to issue #${issueNumber}: ${errorMessage}`); + results.push({ + issue_number: issueNumber, + username: username, + success: false, + error: errorMessage, + }); + } + } + + // Generate step summary + const successCount = results.filter(r => r.success).length; + const failureCount = results.filter(r => !r.success).length; + + let summaryContent = "## User Assignment\n\n"; + + if (successCount > 0) { + summaryContent += `Successfully assigned ${successCount} user(s):\n\n`; + for (const result of results.filter(r => r.success)) { + summaryContent += `- Issue #${result.issue_number} -> User: ${result.username}\n`; + } + summaryContent += "\n"; + } + + if (failureCount > 0) { + summaryContent += `Failed to assign ${failureCount} user(s):\n\n`; + for (const result of results.filter(r => !r.success)) { + summaryContent += `- Issue #${result.issue_number} -> User: ${result.username}: ${result.error}\n`; + } + } + + await core.summary.addRaw(summaryContent).write(); + + // Set outputs + const assignedUsers = results + .filter(r => r.success) + .map(r => `${r.issue_number}:${r.username}`) + .join("\n"); + core.setOutput("assigned_users", assignedUsers); + + // Fail if any assignments failed + if (failureCount > 0) { + core.setFailed(`Failed to assign ${failureCount} user(s)`); + } +} + +(async () => { + await main(); +})(); diff --git a/pkg/workflow/js/safe_outputs_tools.json b/pkg/workflow/js/safe_outputs_tools.json index 4d5ced058f2..89310a1d34c 100644 --- a/pkg/workflow/js/safe_outputs_tools.json +++ b/pkg/workflow/js/safe_outputs_tools.json @@ -332,6 +332,25 @@ "additionalProperties": false } }, + { + "name": "assign_to_user", + "description": "Assign a GitHub user to an issue. Use this to delegate issues to team members for review, implementation, or other follow-up actions.", + "inputSchema": { + "type": "object", + "required": ["issue_number", "username"], + "properties": { + "issue_number": { + "type": ["number", "string"], + "description": "Issue number to assign the user to." + }, + "username": { + "type": "string", + "description": "GitHub username to assign to the issue. The user must have access to the repository." + } + }, + "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/pkg/workflow/js/types/safe-outputs.d.ts b/pkg/workflow/js/types/safe-outputs.d.ts index 07c412f1153..4416b922a2d 100644 --- a/pkg/workflow/js/types/safe-outputs.d.ts +++ b/pkg/workflow/js/types/safe-outputs.d.ts @@ -243,6 +243,17 @@ interface AssignToAgentItem extends BaseSafeOutputItem { agent?: string; } +/** + * JSONL item for assigning a user to an issue + */ +interface AssignToUserItem extends BaseSafeOutputItem { + type: "assign_to_user"; + /** Issue number to assign user to */ + issue_number: number | string; + /** GitHub username to assign to the issue */ + username: string; +} + /** * JSONL item for updating a release */ @@ -298,6 +309,7 @@ type SafeOutputItem = | UploadAssetItem | AssignMilestoneItem | AssignToAgentItem + | AssignToUserItem | UpdateReleaseItem | NoOpItem | LinkSubIssueItem; @@ -331,6 +343,7 @@ export { UploadAssetItem, AssignMilestoneItem, AssignToAgentItem, + AssignToUserItem, UpdateReleaseItem, NoOpItem, LinkSubIssueItem, diff --git a/pkg/workflow/safe_output_validation_config.go b/pkg/workflow/safe_output_validation_config.go index 98969ac4708..8e1c290f77f 100644 --- a/pkg/workflow/safe_output_validation_config.go +++ b/pkg/workflow/safe_output_validation_config.go @@ -100,6 +100,13 @@ var ValidationConfig = map[string]TypeValidationConfig{ "agent": {Type: "string", Sanitize: true, MaxLength: 128}, }, }, + "assign_to_user": { + DefaultMax: 1, + Fields: map[string]FieldValidation{ + "issue_number": {Required: true, PositiveInteger: true}, + "username": {Required: true, Type: "string", Sanitize: true, MaxLength: MaxGitHubUsernameLength}, + }, + }, "update_issue": { DefaultMax: 1, CustomValidation: "requiresOneOf:status,title,body", diff --git a/pkg/workflow/safe_outputs.go b/pkg/workflow/safe_outputs.go index d40143aeb24..a950f6deabf 100644 --- a/pkg/workflow/safe_outputs.go +++ b/pkg/workflow/safe_outputs.go @@ -42,6 +42,7 @@ func HasSafeOutputsEnabled(safeOutputs *SafeOutputsConfig) bool { safeOutputs.AddReviewer != nil || safeOutputs.AssignMilestone != nil || safeOutputs.AssignToAgent != nil || + safeOutputs.AssignToUser != nil || safeOutputs.UpdateIssues != nil || safeOutputs.UpdatePullRequests != nil || safeOutputs.PushToPullRequestBranch != nil || @@ -354,6 +355,76 @@ func (c *Compiler) extractSafeOutputsConfig(frontmatter map[string]any) *SafeOut } } + // Handle assign-to-user + if assignToUser, exists := outputMap["assign-to-user"]; exists { + if userMap, ok := assignToUser.(map[string]any); ok { + userConfig := &AssignToUserConfig{} + + // Parse max (optional) + if maxCount, exists := userMap["max"]; exists { + // Handle different numeric types that YAML parsers might return + var maxCountInt int + var validMaxCount bool + switch v := maxCount.(type) { + case int: + maxCountInt = v + validMaxCount = true + case int64: + maxCountInt = int(v) + validMaxCount = true + case uint64: + maxCountInt = int(v) + validMaxCount = true + case float64: + maxCountInt = int(v) + validMaxCount = true + } + if validMaxCount { + userConfig.Max = maxCountInt + } + } + + // Parse github-token + if githubToken, exists := userMap["github-token"]; exists { + if githubTokenStr, ok := githubToken.(string); ok { + userConfig.GitHubToken = githubTokenStr + } + } + + // Parse target + if target, exists := userMap["target"]; exists { + if targetStr, ok := target.(string); ok { + userConfig.Target = targetStr + } + } + + // Parse target-repo + if targetRepo, exists := userMap["target-repo"]; exists { + if targetRepoStr, ok := targetRepo.(string); ok { + userConfig.TargetRepoSlug = targetRepoStr + } + } + + // Parse allowed users (optional) + if allowed, exists := userMap["allowed"]; exists { + if allowedArray, ok := allowed.([]any); ok { + var allowedStrings []string + for _, user := range allowedArray { + if userStr, ok := user.(string); ok { + allowedStrings = append(allowedStrings, userStr) + } + } + userConfig.Allowed = allowedStrings + } + } + + config.AssignToUser = userConfig + } else if assignToUser == nil { + // Handle null case: create empty config + config.AssignToUser = &AssignToUserConfig{} + } + } + // Handle update-issue updateIssuesConfig := c.parseUpdateIssuesConfig(outputMap) if updateIssuesConfig != nil { @@ -1026,6 +1097,16 @@ func generateSafeOutputsConfig(data *WorkflowData) string { } safeOutputsConfig["assign_to_agent"] = assignToAgentConfig } + if data.SafeOutputs.AssignToUser != nil { + assignToUserConfig := map[string]any{} + if data.SafeOutputs.AssignToUser.Max > 0 { + assignToUserConfig["max"] = data.SafeOutputs.AssignToUser.Max + } + if len(data.SafeOutputs.AssignToUser.Allowed) > 0 { + assignToUserConfig["allowed"] = data.SafeOutputs.AssignToUser.Allowed + } + safeOutputsConfig["assign_to_user"] = assignToUserConfig + } if data.SafeOutputs.UpdateIssues != nil { updateConfig := map[string]any{} // Always include max (use configured value or default) @@ -1228,6 +1309,9 @@ func generateFilteredToolsJSON(data *WorkflowData) (string, error) { if data.SafeOutputs.AssignToAgent != nil { enabledTools["assign_to_agent"] = true } + if data.SafeOutputs.AssignToUser != nil { + enabledTools["assign_to_user"] = true + } if data.SafeOutputs.UpdateIssues != nil { enabledTools["update_issue"] = true } diff --git a/pkg/workflow/scripts.go b/pkg/workflow/scripts.go index 7cb53dbdb2f..3bb3f761ef7 100644 --- a/pkg/workflow/scripts.go +++ b/pkg/workflow/scripts.go @@ -184,6 +184,11 @@ func getAssignToAgentScript() string { return DefaultScriptRegistry.Get("assign_to_agent") } +// getAssignToUserScript returns the bundled assign_to_user script +func getAssignToUserScript() string { + return DefaultScriptRegistry.Get("assign_to_user") +} + // getAssignCopilotToCreatedIssuesScript returns the bundled assign_copilot_to_created_issues script func getAssignCopilotToCreatedIssuesScript() string { return DefaultScriptRegistry.Get("assign_copilot_to_created_issues") diff --git a/pkg/workflow/tool_description_enhancer.go b/pkg/workflow/tool_description_enhancer.go index f54bbcd431b..380f1668e0c 100644 --- a/pkg/workflow/tool_description_enhancer.go +++ b/pkg/workflow/tool_description_enhancer.go @@ -252,6 +252,16 @@ func enhanceToolDescription(toolName, baseDescription string, safeOutputs *SafeO } } + case "assign_to_user": + if config := safeOutputs.AssignToUser; config != nil { + if config.Max > 0 { + constraints = append(constraints, fmt.Sprintf("Maximum %d issue(s) can be assigned to user.", config.Max)) + } + if len(config.Allowed) > 0 { + constraints = append(constraints, fmt.Sprintf("Only these users can be assigned: %s.", strings.Join(config.Allowed, ", "))) + } + } + case "noop": // noop has no configurable constraints } From fd4024a4400adbc5bcc3a3bb2f9b601af646c99d Mon Sep 17 00:00:00 2001 From: Russell Horton Date: Tue, 2 Dec 2025 12:05:14 -0800 Subject: [PATCH 2/4] Fix assign-to-user to use refactored safe output patterns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use SafeOutputTargetConfig embedded struct - Use BuildMaxCountEnvVar and BuildAllowedListEnvVar helpers - Use NewPermissionsContentsReadIssuesWrite for permissions - Update JS to parse comma-separated allowed users list - Use parseBaseSafeOutputConfig and ParseTargetConfig helpers 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- pkg/workflow/assign_to_user.go | 38 +++++++------------ pkg/workflow/js/assign_to_user.cjs | 14 +++---- pkg/workflow/safe_outputs.go | 60 +++--------------------------- 3 files changed, 25 insertions(+), 87 deletions(-) diff --git a/pkg/workflow/assign_to_user.go b/pkg/workflow/assign_to_user.go index 766d3ac7b96..fe1cd2a6858 100644 --- a/pkg/workflow/assign_to_user.go +++ b/pkg/workflow/assign_to_user.go @@ -6,10 +6,9 @@ import ( // AssignToUserConfig holds configuration for assigning users to issues from agent output type AssignToUserConfig struct { - BaseSafeOutputConfig `yaml:",inline"` - Target string `yaml:"target,omitempty"` // Target for user assignment: "triggering" (default) or "*" for any issue - TargetRepoSlug string `yaml:"target-repo,omitempty"` // Target repository in format "owner/repo" for cross-repository assignments - Allowed []string `yaml:"allowed,omitempty"` // List of allowed usernames that can be assigned + BaseSafeOutputConfig `yaml:",inline"` + SafeOutputTargetConfig `yaml:",inline"` + Allowed []string `yaml:"allowed,omitempty"` // List of allowed usernames that can be assigned } // buildAssignToUserJob creates the assign_to_user job @@ -18,33 +17,24 @@ func (c *Compiler) buildAssignToUserJob(data *WorkflowData, mainJobName string) return nil, fmt.Errorf("safe-outputs.assign-to-user configuration is required") } + cfg := data.SafeOutputs.AssignToUser + maxCount := 1 - if data.SafeOutputs.AssignToUser.Max > 0 { - maxCount = data.SafeOutputs.AssignToUser.Max + if cfg.Max > 0 { + maxCount = cfg.Max } // Build custom environment variables specific to assign-to-user var customEnvVars []string // Pass the max limit - customEnvVars = append(customEnvVars, fmt.Sprintf(" GH_AW_USER_MAX_COUNT: %d\n", maxCount)) + customEnvVars = append(customEnvVars, BuildMaxCountEnvVar("GH_AW_USER_MAX_COUNT", maxCount)...) // Pass allowed users if configured - if len(data.SafeOutputs.AssignToUser.Allowed) > 0 { - allowedJSON, err := toJSON(data.SafeOutputs.AssignToUser.Allowed) - if err == nil { - customEnvVars = append(customEnvVars, fmt.Sprintf(" GH_AW_ALLOWED_USERS: %s\n", singleQuote(allowedJSON))) - } - } + customEnvVars = append(customEnvVars, BuildAllowedListEnvVar("GH_AW_ALLOWED_USERS", cfg.Allowed)...) // Add standard environment variables (metadata + staged/target repo) - customEnvVars = append(customEnvVars, c.buildStandardSafeOutputEnvVars(data, data.SafeOutputs.AssignToUser.TargetRepoSlug)...) - - // Get token from config for step-level github-token - var token string - if data.SafeOutputs.AssignToUser != nil { - token = data.SafeOutputs.AssignToUser.GitHubToken - } + customEnvVars = append(customEnvVars, c.buildStandardSafeOutputEnvVars(data, cfg.TargetRepoSlug)...) // Create outputs for the job outputs := map[string]string{ @@ -52,7 +42,7 @@ func (c *Compiler) buildAssignToUserJob(data *WorkflowData, mainJobName string) } // Use the shared builder function to create the job - // User assignment only requires issues:write permission + // User assignment requires contents:read and issues:write permissions return c.buildSafeOutputJob(data, SafeOutputJobConfig{ JobName: "assign_to_user", StepName: "Assign to User", @@ -60,11 +50,11 @@ func (c *Compiler) buildAssignToUserJob(data *WorkflowData, mainJobName string) MainJobName: mainJobName, CustomEnvVars: customEnvVars, Script: getAssignToUserScript(), - Permissions: NewPermissionsIssuesWrite(), + Permissions: NewPermissionsContentsReadIssuesWrite(), Outputs: outputs, - Token: token, + Token: cfg.GitHubToken, UseAgentToken: false, // Regular user assignment doesn't need agent token Condition: BuildSafeOutputType("assign_to_user"), - TargetRepoSlug: data.SafeOutputs.AssignToUser.TargetRepoSlug, + TargetRepoSlug: cfg.TargetRepoSlug, }) } diff --git a/pkg/workflow/js/assign_to_user.cjs b/pkg/workflow/js/assign_to_user.cjs index 0fda4249b82..d494a7a07f3 100644 --- a/pkg/workflow/js/assign_to_user.cjs +++ b/pkg/workflow/js/assign_to_user.cjs @@ -43,17 +43,13 @@ async function main() { } core.info(`Max count: ${maxCount}`); - // Get allowed users configuration - const allowedUsersEnv = process.env.GH_AW_ALLOWED_USERS; + // Get allowed users configuration (comma-separated list) + const allowedUsersEnv = process.env.GH_AW_ALLOWED_USERS?.trim(); let allowedUsers = null; if (allowedUsersEnv) { - try { - allowedUsers = JSON.parse(allowedUsersEnv); - if (!Array.isArray(allowedUsers)) { - allowedUsers = null; - } - } catch { - core.warning(`Failed to parse allowed users: ${allowedUsersEnv}`); + allowedUsers = allowedUsersEnv.split(",").map(u => u.trim()).filter(u => u); + if (allowedUsers.length === 0) { + allowedUsers = null; // Empty string means "allow all" } } if (allowedUsers) { diff --git a/pkg/workflow/safe_outputs.go b/pkg/workflow/safe_outputs.go index 54b0faf76cd..89e2f797791 100644 --- a/pkg/workflow/safe_outputs.go +++ b/pkg/workflow/safe_outputs.go @@ -229,63 +229,15 @@ func (c *Compiler) extractSafeOutputsConfig(frontmatter map[string]any) *SafeOut if userMap, ok := assignToUser.(map[string]any); ok { userConfig := &AssignToUserConfig{} - // Parse max (optional) - if maxCount, exists := userMap["max"]; exists { - // Handle different numeric types that YAML parsers might return - var maxCountInt int - var validMaxCount bool - switch v := maxCount.(type) { - case int: - maxCountInt = v - validMaxCount = true - case int64: - maxCountInt = int(v) - validMaxCount = true - case uint64: - maxCountInt = int(v) - validMaxCount = true - case float64: - maxCountInt = int(v) - validMaxCount = true - } - if validMaxCount { - userConfig.Max = maxCountInt - } - } - - // Parse github-token - if githubToken, exists := userMap["github-token"]; exists { - if githubTokenStr, ok := githubToken.(string); ok { - userConfig.GitHubToken = githubTokenStr - } - } - - // Parse target - if target, exists := userMap["target"]; exists { - if targetStr, ok := target.(string); ok { - userConfig.Target = targetStr - } - } + // Parse target config (target, target-repo) - validation errors are handled gracefully + targetConfig, _ := ParseTargetConfig(userMap) + userConfig.SafeOutputTargetConfig = targetConfig - // Parse target-repo - if targetRepo, exists := userMap["target-repo"]; exists { - if targetRepoStr, ok := targetRepo.(string); ok { - userConfig.TargetRepoSlug = targetRepoStr - } - } + // Parse common base fields (github-token, max) + c.parseBaseSafeOutputConfig(userMap, &userConfig.BaseSafeOutputConfig, 0) // Parse allowed users (optional) - if allowed, exists := userMap["allowed"]; exists { - if allowedArray, ok := allowed.([]any); ok { - var allowedStrings []string - for _, user := range allowedArray { - if userStr, ok := user.(string); ok { - allowedStrings = append(allowedStrings, userStr) - } - } - userConfig.Allowed = allowedStrings - } - } + userConfig.Allowed = ParseStringArrayFromConfig(userMap, "allowed") config.AssignToUser = userConfig } else if assignToUser == nil { From d4ada681c6df3558a42655c03f2c28b9db247bd4 Mon Sep 17 00:00:00 2001 From: Russell Horton Date: Tue, 2 Dec 2025 12:14:33 -0800 Subject: [PATCH 3/4] Add assign-to-user to JSON schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- pkg/parser/schemas/main_workflow_schema.json | 40 ++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 3143d98a147..ad3589cd449 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -3472,6 +3472,46 @@ } ] }, + "assign-to-user": { + "oneOf": [ + { + "type": "null", + "description": "Enable user assignment with default configuration" + }, + { + "type": "object", + "description": "Configuration for assigning GitHub users to issues from agentic workflow output", + "properties": { + "allowed": { + "type": "array", + "description": "List of allowed usernames that can be assigned. If not specified, any user can be assigned.", + "items": { + "type": "string" + }, + "minItems": 1 + }, + "max": { + "type": "integer", + "description": "Optional maximum number of user assignments (default: 1)", + "minimum": 1 + }, + "target": { + "type": "string", + "description": "Target for user assignment: 'triggering' (default) or '*' for any issue" + }, + "target-repo": { + "type": "string", + "description": "Target repository in format 'owner/repo' for cross-repository user assignment. Takes precedence over trial target repo settings." + }, + "github-token": { + "$ref": "#/$defs/github_token", + "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." + } + }, + "additionalProperties": false + } + ] + }, "link-sub-issue": { "oneOf": [ { From 490c3381f0ca94d13ba322da6db636cd7fe6ac17 Mon Sep 17 00:00:00 2001 From: Russell Horton Date: Tue, 2 Dec 2025 12:39:33 -0800 Subject: [PATCH 4/4] Fix assign_to_user script embedding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add go:embed directive and registry registration for assign_to_user.cjs 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- pkg/workflow/scripts.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pkg/workflow/scripts.go b/pkg/workflow/scripts.go index 3bb3f761ef7..3f3ca39df48 100644 --- a/pkg/workflow/scripts.go +++ b/pkg/workflow/scripts.go @@ -39,6 +39,9 @@ var assignMilestoneScriptSource string //go:embed js/assign_to_agent.cjs var assignToAgentScriptSource string +//go:embed js/assign_to_user.cjs +var assignToUserScriptSource string + //go:embed js/assign_copilot_to_created_issues.cjs var assignCopilotToCreatedIssuesScriptSource string @@ -116,6 +119,7 @@ func init() { DefaultScriptRegistry.Register("add_reviewer", addReviewerScriptSource) DefaultScriptRegistry.Register("assign_milestone", assignMilestoneScriptSource) DefaultScriptRegistry.Register("assign_to_agent", assignToAgentScriptSource) + DefaultScriptRegistry.Register("assign_to_user", assignToUserScriptSource) DefaultScriptRegistry.Register("assign_copilot_to_created_issues", assignCopilotToCreatedIssuesScriptSource) DefaultScriptRegistry.Register("link_sub_issue", linkSubIssueScriptSource) DefaultScriptRegistry.Register("create_discussion", createDiscussionScriptSource)