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
38 changes: 29 additions & 9 deletions actions/setup/js/create_pull_request.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,19 @@ function isBaseBranchAllowed(baseBranch, allowedBaseBranches) {
return false;
}

/**
* Parse config values that may be arrays or comma-separated strings.
* @param {string[]|string|undefined} value
* @returns {string[]}
*/
function parseStringListConfig(value) {
if (!value) {
return [];
}
const raw = Array.isArray(value) ? value : String(value).split(",");
return raw.map(item => String(item).trim()).filter(Boolean);
}

/**
* Merges the required fallback label with any workflow-configured labels,
* deduplicating and filtering empty values.
Expand Down Expand Up @@ -323,10 +336,11 @@ async function handleRemoteBranchCollision(branchName, preserveBranchName) {
async function main(config = {}) {
// Extract configuration
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 configTeamReviewers = config.team_reviewers ? (Array.isArray(config.team_reviewers) ? config.team_reviewers : config.team_reviewers.split(",")).map(r => String(r).trim()).filter(r => r) : [];
const rawAssignees = config.assignees ? (Array.isArray(config.assignees) ? config.assignees : config.assignees.split(",")).map(a => String(a).trim()).filter(a => a) : [];
const envLabels = parseStringListConfig(config.labels);
const configFallbackLabels = parseStringListConfig(config.fallback_labels);
const configReviewers = parseStringListConfig(config.reviewers);
const configTeamReviewers = parseStringListConfig(config.team_reviewers);
const rawAssignees = parseStringListConfig(config.assignees);
const hasCopilotInAssignees = rawAssignees.some(a => a.toLowerCase() === "copilot");
const configAssignees = sanitizeFallbackAssignees(rawAssignees);
const draftDefault = parseBoolTemplatable(config.draft, true);
Expand Down Expand Up @@ -445,6 +459,9 @@ async function main(config = {}) {
if (envLabels.length > 0) {
core.info(`Default labels: ${envLabels.join(", ")}`);
}
if (configFallbackLabels.length > 0) {
core.info(`Configured fallback issue labels: ${configFallbackLabels.join(", ")}`);
}
if (configReviewers.length > 0) {
core.info(`Configured reviewers: ${configReviewers.join(", ")}`);
}
Expand Down Expand Up @@ -955,6 +972,9 @@ async function main(config = {}) {
.filter(label => !!label)
.map(label => String(label).trim())
.filter(label => label);
// Use explicitly configured fallback labels when present; otherwise preserve
// existing behavior by reusing pull request labels for fallback issues.
const effectiveFallbackLabels = configFallbackLabels.length > 0 ? configFallbackLabels : labels;

// Configuration enforces draft as a policy, not a fallback (consistent with autoMerge/allowEmpty)
const draft = draftDefault;
Expand Down Expand Up @@ -1076,7 +1096,7 @@ gh pr create --title '${title}' --base ${baseBranch} --head ${branchName} --repo
\`\`\``;

try {
const { data: issue } = await createFallbackIssue(githubClient, repoParts, title, fallbackBody, mergeFallbackIssueLabels(labels), configAssignees);
const { data: issue } = await createFallbackIssue(githubClient, repoParts, title, fallbackBody, mergeFallbackIssueLabels(effectiveFallbackLabels), configAssignees);

core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`);
await assignCopilotToFallbackIssueIfEnabled(repoParts.owner, repoParts.repo, issue.number);
Expand Down Expand Up @@ -1310,7 +1330,7 @@ gh pr create --title '${title}' --base ${baseBranch} --head ${branchName} --repo
${patchPreview}`;

try {
const { data: issue } = await createFallbackIssue(githubClient, repoParts, title, fallbackBody, mergeFallbackIssueLabels(labels), configAssignees);
const { data: issue } = await createFallbackIssue(githubClient, repoParts, title, fallbackBody, mergeFallbackIssueLabels(effectiveFallbackLabels), configAssignees);

core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`);
await assignCopilotToFallbackIssueIfEnabled(repoParts.owner, repoParts.repo, issue.number);
Expand Down Expand Up @@ -1465,7 +1485,7 @@ ${patchPreview}`;
}

try {
const { data: issue } = await createFallbackIssue(githubClient, repoParts, title, fallbackBody, mergeFallbackIssueLabels(labels), configAssignees);
const { data: issue } = await createFallbackIssue(githubClient, repoParts, title, fallbackBody, mergeFallbackIssueLabels(effectiveFallbackLabels), configAssignees);

core.info(`Created protected-file-protection review issue #${issue.number}: ${issue.html_url}`);
await assignCopilotToFallbackIssueIfEnabled(repoParts.owner, repoParts.repo, issue.number);
Expand Down Expand Up @@ -1665,7 +1685,7 @@ ${patchPreview}`;
});

try {
const { data: issue } = await createFallbackIssue(githubClient, repoParts, title, fallbackBody, mergeFallbackIssueLabels(labels), configAssignees);
const { data: issue } = await createFallbackIssue(githubClient, repoParts, title, fallbackBody, mergeFallbackIssueLabels(effectiveFallbackLabels), configAssignees);

core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`);
await assignCopilotToFallbackIssueIfEnabled(repoParts.owner, repoParts.repo, issue.number);
Expand Down Expand Up @@ -1731,7 +1751,7 @@ gh pr create --title "${title}" --base ${baseBranch} --head ${branchName} --repo
${patchPreview}`;

try {
const { data: issue } = await createFallbackIssue(githubClient, repoParts, title, fallbackBody, mergeFallbackIssueLabels(labels), configAssignees);
const { data: issue } = await createFallbackIssue(githubClient, repoParts, title, fallbackBody, mergeFallbackIssueLabels(effectiveFallbackLabels), configAssignees);

core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`);
await assignCopilotToFallbackIssueIfEnabled(repoParts.owner, repoParts.repo, issue.number);
Expand Down
9 changes: 9 additions & 0 deletions actions/setup/js/create_pull_request.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -1776,6 +1776,15 @@ describe("create_pull_request - copilot assignee on fallback issues", () => {
expect(global.github.graphql).toHaveBeenCalledTimes(3);
});

it("should use configured fallback_labels for fallback issues instead of PR labels", async () => {
const { main } = require("./create_pull_request.cjs");
const handler = await main({ allow_empty: true, fallback_labels: ["failure", "automated"] });
await handler({ title: "Test PR", body: "Test body", labels: ["pr-label"] }, {});

const issueCall = global.github.rest.issues.create.mock.calls[0][0];
expect(issueCall.labels).toEqual(["agentic-workflows", "failure", "automated"]);
});

it("should warn but not fail when copilot agent is not available for fallback issue", async () => {
process.env.GH_AW_ASSIGN_COPILOT = "true";

Expand Down
7 changes: 7 additions & 0 deletions pkg/parser/schemas/main_workflow_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -5774,6 +5774,13 @@
],
"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."
},
"fallback-labels": {
"type": "array",
"description": "Optional labels to apply to fallback issues created when pull request creation cannot proceed. When omitted, fallback issues reuse pull request labels. A managed label is always added for triage.",
"items": {
"type": "string"
}
},
"draft": {
"allOf": [
{
Expand Down
54 changes: 54 additions & 0 deletions pkg/workflow/compiler_safe_outputs_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1635,6 +1635,60 @@ func TestCreatePullRequestBaseBranch(t *testing.T) {
}
}

func TestCreatePullRequestFallbackLabels(t *testing.T) {
compiler := NewCompiler()

workflowData := &WorkflowData{
Name: "Test Workflow",
SafeOutputs: &SafeOutputsConfig{
CreatePullRequests: &CreatePullRequestsConfig{
BaseSafeOutputConfig: BaseSafeOutputConfig{
Max: strPtr("1"),
},
FallbackLabels: []string{"failure", "automated"},
},
},
}

var steps []string
compiler.addHandlerManagerConfigEnvVar(&steps, workflowData)
require.NotEmpty(t, steps, "Steps should be generated")
validated := false

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 {
continue
}

Comment on lines +1658 to +1664
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

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

TestCreatePullRequestFallbackLabels can pass without validating anything if no step contains GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG (or if parsing fails before hitting the asserts), because the loop has no final assertion that the handler config was found/checked. Add a found/validated boolean that is set only after successfully asserting fallback_labels, and require.True it after the loop (and consider break once validated).

Copilot uses AI. Check for mistakes.
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, "Config JSON should be valid")

prConfig, ok := config["create_pull_request"]
require.True(t, ok, "create_pull_request config should exist")

fallbackLabelsRaw, ok := prConfig["fallback_labels"]
require.True(t, ok, "fallback_labels should be in config")

fallbackLabels, ok := fallbackLabelsRaw.([]any)
require.True(t, ok, "fallback_labels should be an array")
require.Len(t, fallbackLabels, 2, "fallback_labels should have expected length")
assert.Equal(t, "failure", fallbackLabels[0], "first fallback label should match")
assert.Equal(t, "automated", fallbackLabels[1], "second fallback label should match")
validated = true
break
}
}

require.True(t, validated, "fallback_labels validation should run when handler config env var is present")
}

// TestHandlerConfigAssignToUser tests assign_to_user configuration
func TestHandlerConfigAssignToUser(t *testing.T) {
compiler := NewCompiler()
Expand Down
1 change: 1 addition & 0 deletions pkg/workflow/compiler_safe_outputs_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,7 @@ var handlerRegistry = map[string]handlerBuilder{
AddTemplatableInt("max", c.Max).
AddIfNotEmpty("title_prefix", c.TitlePrefix).
AddStringSlice("labels", c.Labels).
AddStringSlice("fallback_labels", c.FallbackLabels).
AddStringSlice("reviewers", c.Reviewers).
AddStringSlice("team_reviewers", c.TeamReviewers).
AddStringSlice("assignees", c.Assignees).
Expand Down
11 changes: 8 additions & 3 deletions pkg/workflow/config_parsing_helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -311,9 +311,10 @@ func TestParsePullRequestsConfigWithHelpers(t *testing.T) {
compiler := &Compiler{}
outputMap := map[string]any{
"create-pull-request": map[string]any{
"title-prefix": "[auto] ",
"labels": []any{"automated", "needs-review"},
"target-repo": "org/project",
"title-prefix": "[auto] ",
"labels": []any{"automated", "needs-review"},
"fallback-labels": []any{"failure", "automated"},
"target-repo": "org/project",
},
}

Expand All @@ -330,6 +331,10 @@ func TestParsePullRequestsConfigWithHelpers(t *testing.T) {
t.Errorf("expected 2 labels, got %d", len(result.Labels))
}

if len(result.FallbackLabels) != 2 {
t.Errorf("expected 2 fallback labels, got %d", len(result.FallbackLabels))
}

if result.TargetRepoSlug != "org/project" {
t.Errorf("expected target-repo 'org/project', got %q", result.TargetRepoSlug)
}
Expand Down
1 change: 1 addition & 0 deletions pkg/workflow/create_pull_request.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ type CreatePullRequestsConfig struct {
Reviewers []string `yaml:"reviewers,omitempty"` // List of users/bots to assign as reviewers to the pull request
TeamReviewers []string `yaml:"team-reviewers,omitempty"` // List of team slugs to assign as team reviewers to the pull request
Assignees []string `yaml:"assignees,omitempty"` // List of users to assign to any fallback issue created by create-pull-request
FallbackLabels []string `yaml:"fallback-labels,omitempty"` // List of labels to apply to fallback issues created when PR creation cannot proceed. If omitted, fallback issues reuse PR labels.
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