Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions pkg/parser/schemas/main_workflow_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
{
Expand Down
60 changes: 60 additions & 0 deletions pkg/workflow/assign_to_user.go
Original file line number Diff line number Diff line change
@@ -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,
})
}
1 change: 1 addition & 0 deletions pkg/workflow/compiler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
18 changes: 18 additions & 0 deletions pkg/workflow/compiler_jobs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions pkg/workflow/imports.go
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down Expand Up @@ -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
}
Expand Down
188 changes: 188 additions & 0 deletions pkg/workflow/js/assign_to_user.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
// @ts-check
/// <reference types="@actions/github-script" />

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();
})();
19 changes: 19 additions & 0 deletions pkg/workflow/js/safe_outputs_tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
Loading
Loading