diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 3143d98a14..ad3589cd44 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": [ { diff --git a/pkg/workflow/assign_to_user.go b/pkg/workflow/assign_to_user.go new file mode 100644 index 0000000000..fe1cd2a685 --- /dev/null +++ b/pkg/workflow/assign_to_user.go @@ -0,0 +1,60 @@ +package workflow + +import ( + "fmt" +) + +// AssignToUserConfig holds configuration for assigning users to issues from agent output +type AssignToUserConfig struct { + 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 +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") + } + + cfg := data.SafeOutputs.AssignToUser + + maxCount := 1 + 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, BuildMaxCountEnvVar("GH_AW_USER_MAX_COUNT", maxCount)...) + + // Pass allowed users if configured + customEnvVars = append(customEnvVars, BuildAllowedListEnvVar("GH_AW_ALLOWED_USERS", cfg.Allowed)...) + + // Add standard environment variables (metadata + staged/target repo) + customEnvVars = append(customEnvVars, c.buildStandardSafeOutputEnvVars(data, cfg.TargetRepoSlug)...) + + // 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 requires contents:read and issues:write permissions + return c.buildSafeOutputJob(data, SafeOutputJobConfig{ + JobName: "assign_to_user", + StepName: "Assign to User", + StepID: "assign_to_user", + MainJobName: mainJobName, + CustomEnvVars: customEnvVars, + Script: getAssignToUserScript(), + Permissions: NewPermissionsContentsReadIssuesWrite(), + Outputs: outputs, + Token: cfg.GitHubToken, + UseAgentToken: false, // Regular user assignment doesn't need agent token + Condition: BuildSafeOutputType("assign_to_user"), + TargetRepoSlug: cfg.TargetRepoSlug, + }) +} diff --git a/pkg/workflow/compiler.go b/pkg/workflow/compiler.go index 1408596b3c..731e92cf98 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 29e925a95f..647852c07f 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 a872b8383a..3c032f4173 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 0000000000..d494a7a07f --- /dev/null +++ b/pkg/workflow/js/assign_to_user.cjs @@ -0,0 +1,188 @@ +// @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 (comma-separated list) + const allowedUsersEnv = process.env.GH_AW_ALLOWED_USERS?.trim(); + let allowedUsers = null; + if (allowedUsersEnv) { + allowedUsers = allowedUsersEnv.split(",").map(u => u.trim()).filter(u => u); + if (allowedUsers.length === 0) { + allowedUsers = null; // Empty string means "allow all" + } + } + 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 4d5ced058f..89310a1d34 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 07c412f115..4416b922a2 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 98969ac470..8e1c290f77 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 f0faacef7a..89e2f79779 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 || @@ -223,6 +224,28 @@ 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 target config (target, target-repo) - validation errors are handled gracefully + targetConfig, _ := ParseTargetConfig(userMap) + userConfig.SafeOutputTargetConfig = targetConfig + + // Parse common base fields (github-token, max) + c.parseBaseSafeOutputConfig(userMap, &userConfig.BaseSafeOutputConfig, 0) + + // Parse allowed users (optional) + userConfig.Allowed = ParseStringArrayFromConfig(userMap, "allowed") + + 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 { @@ -895,6 +918,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) @@ -1097,6 +1130,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 7cb53dbdb2..3f3ca39df4 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) @@ -184,6 +188,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 f54bbcd431..380f1668e0 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 }