Skip to content
Merged
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
4 changes: 2 additions & 2 deletions .github/workflows/api-consumption-report.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

114 changes: 78 additions & 36 deletions actions/setup/js/create_pull_request.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,10 @@ const { createAuthenticatedGitHubClient } = require("./handler_auth.cjs");
const { buildWorkflowRunUrl } = require("./workflow_metadata_helpers.cjs");
const { checkFileProtection } = require("./manifest_file_helpers.cjs");
const { renderTemplateFromFile, buildProtectedFileList, encodePathSegments } = require("./messages_core.cjs");
const { COPILOT_REVIEWER_BOT, FAQ_CREATE_PR_PERMISSIONS_URL } = require("./constants.cjs");
const { COPILOT_REVIEWER_BOT, FAQ_CREATE_PR_PERMISSIONS_URL, MAX_ASSIGNEES } = require("./constants.cjs");
const { isStagedMode } = require("./safe_output_helpers.cjs");
const { withRetry, isTransientError } = require("./error_recovery.cjs");
const { tryEnforceArrayLimit } = require("./limit_enforcement_helpers.cjs");

/**
* @typedef {import('./types/handler-factory').HandlerFactoryFunction} HandlerFactoryFunction
Expand Down Expand Up @@ -77,6 +78,73 @@ function mergeFallbackIssueLabels(labels = []) {
return [...new Set([MANAGED_FALLBACK_ISSUE_LABEL, ...normalizedLabels])];
}

/**
* Sanitizes configured assignees for fallback issue creation.
* Filters invalid values, removes the special "copilot" username (not a valid GitHub user
* for issue assignment), and enforces the MAX_ASSIGNEES limit.
* Returns null (no assignees field) if the sanitized list is empty.
* @param {string[]} assignees - Raw assignees from config
* @returns {string[] | null} Sanitized assignees or null if none remain
*/
function sanitizeFallbackAssignees(assignees) {
if (!assignees || assignees.length === 0) {
return null;
}
const sanitized = assignees
.filter(a => typeof a === "string")
.map(a => a.trim())
.filter(a => a.length > 0 && a.toLowerCase() !== "copilot");

if (sanitized.length === 0) {
return null;
}

const limitResult = tryEnforceArrayLimit(sanitized, MAX_ASSIGNEES, "assignees");
if (!limitResult.success) {
core.warning(`Assignees limit exceeded for fallback issue: ${limitResult.error}. Using first ${MAX_ASSIGNEES}.`);
return sanitized.slice(0, MAX_ASSIGNEES);
}

return sanitized;
}

/**
* Creates a fallback GitHub issue, retrying without assignees if the API rejects them.
* This ensures fallback issue creation remains reliable even if an assignee username
* is invalid or the repository does not have that collaborator.
* @param {import('@octokit/rest').Octokit} githubClient - Authenticated GitHub client
* @param {{owner: string, repo: string}} repoParts - Repository owner and name
* @param {string} title - Issue title
* @param {string} body - Issue body
* @param {string[]} labels - Issue labels
* @param {string[] | null} assignees - Sanitized assignees (null = omit field)
* @returns {Promise<import('@octokit/rest').Octokit['rest']['issues']['create'] extends (...args: any[]) => Promise<infer R> ? R : never>}
*/
async function createFallbackIssue(githubClient, repoParts, title, body, labels, assignees) {
const payload = {
owner: repoParts.owner,
repo: repoParts.repo,
title,
body,
labels,
...(assignees && assignees.length > 0 && { assignees }),
};

try {
return await githubClient.rest.issues.create(payload);
} catch (error) {
const status = typeof error === "object" && error !== null && "status" in error ? error.status : undefined;
const message = getErrorMessage(error).toLowerCase();
const isAssigneeError = status === 422 && (message.includes("assignee") || message.includes("assignees") || message.includes("unprocessable"));
if (isAssigneeError && assignees && assignees.length > 0) {
core.warning(`Fallback issue creation failed due to assignee error, retrying without assignees: ${getErrorMessage(error)}`);
const { assignees: _removed, ...payloadWithoutAssignees } = payload;
return await githubClient.rest.issues.create(payloadWithoutAssignees);
}
throw error;
}
}

/**
* Maximum limits for pull request parameters to prevent resource exhaustion.
* These limits align with GitHub's API constraints and security best practices.
Expand Down Expand Up @@ -146,6 +214,7 @@ async function main(config = {}) {
const titlePrefix = config.title_prefix || "";
const envLabels = config.labels ? (Array.isArray(config.labels) ? config.labels : config.labels.split(",")).map(label => String(label).trim()).filter(label => label) : [];
const configReviewers = config.reviewers ? (Array.isArray(config.reviewers) ? config.reviewers : config.reviewers.split(",")).map(r => String(r).trim()).filter(r => r) : [];
const configAssignees = sanitizeFallbackAssignees(config.assignees ? (Array.isArray(config.assignees) ? config.assignees : config.assignees.split(",")).map(a => String(a).trim()).filter(a => a) : []);
const draftDefault = parseBoolTemplatable(config.draft, true);
const ifNoChanges = config.if_no_changes || "warn";
const allowEmpty = parseBoolTemplatable(config.allow_empty, false);
Expand Down Expand Up @@ -199,6 +268,9 @@ async function main(config = {}) {
if (configReviewers.length > 0) {
core.info(`Configured reviewers: ${configReviewers.join(", ")}`);
}
if (configAssignees && configAssignees.length > 0) {
core.info(`Configured assignees (for fallback issues): ${configAssignees.join(", ")}`);
}
if (titlePrefix) {
core.info(`Title prefix: ${titlePrefix}`);
}
Expand Down Expand Up @@ -797,13 +869,7 @@ gh pr create --title '${title}' --base ${baseBranch} --head ${branchName} --repo
\`\`\``;

try {
const { data: issue } = await githubClient.rest.issues.create({
owner: repoParts.owner,
repo: repoParts.repo,
title: title,
body: fallbackBody,
labels: mergeFallbackIssueLabels(labels),
});
const { data: issue } = await createFallbackIssue(githubClient, repoParts, title, fallbackBody, mergeFallbackIssueLabels(labels), configAssignees);

core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`);
await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue");
Expand Down Expand Up @@ -1043,13 +1109,7 @@ gh pr create --title '${title}' --base ${baseBranch} --head ${branchName} --repo
${patchPreview}`;

try {
const { data: issue } = await githubClient.rest.issues.create({
owner: repoParts.owner,
repo: repoParts.repo,
title: title,
body: fallbackBody,
labels: mergeFallbackIssueLabels(labels),
});
const { data: issue } = await createFallbackIssue(githubClient, repoParts, title, fallbackBody, mergeFallbackIssueLabels(labels), configAssignees);

core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`);

Expand Down Expand Up @@ -1221,13 +1281,7 @@ ${patchPreview}`;
}

try {
const { data: issue } = await githubClient.rest.issues.create({
owner: repoParts.owner,
repo: repoParts.repo,
title: title,
body: fallbackBody,
labels: mergeFallbackIssueLabels(labels),
});
const { data: issue } = await createFallbackIssue(githubClient, repoParts, title, fallbackBody, mergeFallbackIssueLabels(labels), configAssignees);

core.info(`Created protected-file-protection review issue #${issue.number}: ${issue.html_url}`);

Expand Down Expand Up @@ -1421,13 +1475,7 @@ ${patchPreview}`;
});

try {
const { data: issue } = await githubClient.rest.issues.create({
owner: repoParts.owner,
repo: repoParts.repo,
title: title,
body: fallbackBody,
labels: mergeFallbackIssueLabels(labels),
});
const { data: issue } = await createFallbackIssue(githubClient, repoParts, title, fallbackBody, mergeFallbackIssueLabels(labels), configAssignees);

core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`);

Expand Down Expand Up @@ -1492,13 +1540,7 @@ gh pr create --title "${title}" --base ${baseBranch} --head ${branchName} --repo
${patchPreview}`;

try {
const { data: issue } = await githubClient.rest.issues.create({
owner: repoParts.owner,
repo: repoParts.repo,
title: title,
body: fallbackBody,
labels: mergeFallbackIssueLabels(labels),
});
const { data: issue } = await createFallbackIssue(githubClient, repoParts, title, fallbackBody, mergeFallbackIssueLabels(labels), configAssignees);

core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`);

Expand Down
2 changes: 2 additions & 0 deletions actions/setup/js/types/safe-outputs-config.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ interface AddCommentConfig extends SafeOutputConfig {
interface CreatePullRequestConfig extends SafeOutputConfig {
"title-prefix"?: string;
labels?: string[];
reviewers?: string | string[];
assignees?: string | string[];
draft?: boolean;
"if-no-changes"?: string;
footer?: boolean;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ safe-outputs:
title-prefix: "[ai] " # prefix for titles
labels: [automation] # labels to attach
reviewers: [user1, copilot] # reviewers (use 'copilot' for bot)
assignees: [user1] # assignees for fallback issues (including protected-files and PR creation failure fallbacks)
draft: true # create as draft — enforced as policy (default: true)
max: 3 # max PRs per run (default: 1)
expires: 14 # auto-close after 14 days (same-repo only)
Expand Down
1 change: 1 addition & 0 deletions docs/src/content/docs/reference/safe-outputs.md
Original file line number Diff line number Diff line change
Expand Up @@ -689,6 +689,7 @@ safe-outputs:
title-prefix: "[ai] "
labels: [automation]
reviewers: [user1, copilot]
assignees: [user1] # assignees for fallback issues created when PR creation cannot proceed (including protected-files fallback)
protected-files: fallback-to-issue # create review issue if protected files modified, git commands (`checkout`, `branch`, `switch`, `add`, `rm`, `commit`, `merge`) are automatically enabled.
```

Expand Down
16 changes: 16 additions & 0 deletions pkg/parser/schemas/main_workflow_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -5807,6 +5807,22 @@
],
"description": "Optional reviewer(s) to assign to the pull request. Accepts either a single string or an array of usernames. Use 'copilot' to request a code review from GitHub Copilot."
},
"assignees": {
"oneOf": [
{
"type": "string",
"description": "Single username to assign to a fallback issue created when pull request creation cannot proceed, including protected-files fallback-to-issue and pull request creation or push failures."
},
{
"type": "array",
"description": "List of usernames to assign to a fallback issue created when pull request creation cannot proceed, including protected-files fallback-to-issue and pull request creation or push failures.",
"items": {
"type": "string"
}
}
],
"description": "Optional assignee(s) for a fallback issue created when pull request creation cannot proceed, including protected-files fallback-to-issue and pull request creation or push failures. Accepts either a single string or an array of usernames."
},
"draft": {
"allOf": [
{
Expand Down
1 change: 1 addition & 0 deletions pkg/workflow/compiler_safe_outputs_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,7 @@ var handlerRegistry = map[string]handlerBuilder{
AddIfNotEmpty("title_prefix", c.TitlePrefix).
AddStringSlice("labels", c.Labels).
AddStringSlice("reviewers", c.Reviewers).
AddStringSlice("assignees", c.Assignees).
AddTemplatableBool("draft", c.Draft).
Comment on lines 509 to 513
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

assignees was added to the emitted handler config, but there is no unit test asserting it is present/serialized correctly (there is one for reviewers in pkg/workflow/compiler_safe_outputs_config_test.go). Please add a similar test for assignees to prevent regressions.

Copilot uses AI. Check for mistakes.
AddIfNotEmpty("if_no_changes", c.IfNoChanges).
AddTemplatableBool("allow_empty", c.AllowEmpty).
Expand Down
45 changes: 45 additions & 0 deletions pkg/workflow/compiler_safe_outputs_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -908,6 +908,51 @@ func TestHandlerConfigReviewers(t *testing.T) {
}
}

// TestHandlerConfigAssignees tests assignees configuration in create_pull_request
func TestHandlerConfigAssignees(t *testing.T) {
compiler := NewCompiler()

workflowData := &WorkflowData{
Name: "Test Workflow",
SafeOutputs: &SafeOutputsConfig{
CreatePullRequests: &CreatePullRequestsConfig{
Assignees: []string{"user1", "user2"},
},
},
}

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")

prConfig, ok := config["create_pull_request"]
require.True(t, ok, "Should have create_pull_request handler")

assignees, ok := prConfig["assignees"]
require.True(t, ok, "Should have assignees field")

assigneeSlice, ok := assignees.([]any)
require.True(t, ok, "Assignees should be an array")
assert.Len(t, assigneeSlice, 2, "Should have 2 assignees")
assert.Equal(t, "user1", assigneeSlice[0])
assert.Equal(t, "user2", assigneeSlice[1])
}
}
}
}

// TestHandlerConfigBooleanFields tests boolean field configuration
func TestHandlerConfigBooleanFields(t *testing.T) {
tests := []struct {
Expand Down
9 changes: 9 additions & 0 deletions pkg/workflow/create_pull_request.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ type CreatePullRequestsConfig struct {
Labels []string `yaml:"labels,omitempty"`
AllowedLabels []string `yaml:"allowed-labels,omitempty"` // Optional list of allowed labels. If omitted, any labels are allowed (including creating new ones).
Reviewers []string `yaml:"reviewers,omitempty"` // List of users/bots to assign as reviewers to the pull request
Assignees []string `yaml:"assignees,omitempty"` // List of users to assign to any fallback issue created by create-pull-request
Draft *string `yaml:"draft,omitempty"` // Pointer to distinguish between unset (nil), literal bool, and expression values
IfNoChanges string `yaml:"if-no-changes,omitempty"` // Behavior when no changes to push: "warn" (default), "error", or "ignore"
AllowEmpty *string `yaml:"allow-empty,omitempty"` // Allow creating PR without patch file or with empty patch (useful for preparing feature branches)
Expand Down Expand Up @@ -63,6 +64,14 @@ func (c *Compiler) parsePullRequestsConfig(outputMap map[string]any) *CreatePull
createPRLog.Printf("Converted single reviewer string to array before unmarshaling")
}
}
// Pre-process the assignees field to convert single string to array BEFORE unmarshaling
if assignees, exists := configData["assignees"]; exists {
if assigneeStr, ok := assignees.(string); ok {
// Convert single string to array
configData["assignees"] = []string{assigneeStr}
createPRLog.Printf("Converted single assignee string to array before unmarshaling")
}
}
Comment on lines +67 to +74
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are tests covering single-string coercion for reviewers, but I couldn't find an analogous test for the new assignees single-string coercion. Please add a regression test ensuring assignees: "user" unmarshals into []string{"user"} (similar to the existing reviewers single-string coverage).

Copilot uses AI. Check for mistakes.
}

// Pre-process the expires field (convert to hours before unmarshaling)
Expand Down
Loading
Loading