diff --git a/actions/setup/js/create_pull_request.cjs b/actions/setup/js/create_pull_request.cjs index 48e828c81a3..ec4dff5d153 100644 --- a/actions/setup/js/create_pull_request.cjs +++ b/actions/setup/js/create_pull_request.cjs @@ -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. @@ -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); @@ -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(", ")}`); } @@ -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; @@ -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); @@ -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); @@ -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); @@ -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); @@ -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); diff --git a/actions/setup/js/create_pull_request.test.cjs b/actions/setup/js/create_pull_request.test.cjs index ad1da7f9b81..2e88ceb2655 100644 --- a/actions/setup/js/create_pull_request.test.cjs +++ b/actions/setup/js/create_pull_request.test.cjs @@ -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"; diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index de540a1e181..f67edfabfd4 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -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": [ { diff --git a/pkg/workflow/compiler_safe_outputs_config_test.go b/pkg/workflow/compiler_safe_outputs_config_test.go index 7e8f78c7021..b395cafd290 100644 --- a/pkg/workflow/compiler_safe_outputs_config_test.go +++ b/pkg/workflow/compiler_safe_outputs_config_test.go @@ -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 + } + + 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() diff --git a/pkg/workflow/compiler_safe_outputs_handlers.go b/pkg/workflow/compiler_safe_outputs_handlers.go index 323799e839c..d8b45725950 100644 --- a/pkg/workflow/compiler_safe_outputs_handlers.go +++ b/pkg/workflow/compiler_safe_outputs_handlers.go @@ -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). diff --git a/pkg/workflow/config_parsing_helpers_test.go b/pkg/workflow/config_parsing_helpers_test.go index 86413f9e7e4..12450635c59 100644 --- a/pkg/workflow/config_parsing_helpers_test.go +++ b/pkg/workflow/config_parsing_helpers_test.go @@ -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", }, } @@ -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) } diff --git a/pkg/workflow/create_pull_request.go b/pkg/workflow/create_pull_request.go index ee959facc52..c8812e30039 100644 --- a/pkg/workflow/create_pull_request.go +++ b/pkg/workflow/create_pull_request.go @@ -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)