From 4bfcea64e3ad31e535ef1822ff7d309f69a82985 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Feb 2026 08:00:02 +0000 Subject: [PATCH 1/2] Initial plan From 084ede5426adaa649125a2cade1102c616cca4ca Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Feb 2026 08:18:35 +0000 Subject: [PATCH 2/2] Add auto-injection of create-issue safe output when no non-builtin safe outputs are configured - Add hasNonBuiltinSafeOutputsEnabled() to check if any non-builtin safe outputs are configured (excludes noop, missing-data, missing-tool) - Add applyDefaultCreateIssue() to auto-inject create-issues with workflow ID as label and [workflowID] as title prefix when no non-builtins exist - Add AutoInjectedCreateIssue flag to SafeOutputsConfig to track injection - Add specific prompt instruction for auto-injected create-issue - Add unit tests for all new functionality Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../compiler_orchestrator_workflow.go | 4 + pkg/workflow/compiler_types.go | 1 + pkg/workflow/safe_outputs_config_helpers.go | 67 ++++ .../safe_outputs_default_create_issue_test.go | 345 ++++++++++++++++++ pkg/workflow/unified_prompt_step.go | 3 + 5 files changed, 420 insertions(+) create mode 100644 pkg/workflow/safe_outputs_default_create_issue_test.go diff --git a/pkg/workflow/compiler_orchestrator_workflow.go b/pkg/workflow/compiler_orchestrator_workflow.go index 46cd4da94c9..f5dcdb0383a 100644 --- a/pkg/workflow/compiler_orchestrator_workflow.go +++ b/pkg/workflow/compiler_orchestrator_workflow.go @@ -532,6 +532,10 @@ func (c *Compiler) extractAdditionalConfigurations( } workflowData.SafeOutputs = mergedSafeOutputs + // Auto-inject create-issues if safe-outputs is configured but has no non-builtin outputs. + // This ensures every workflow with safe-outputs has at least one meaningful action handler. + applyDefaultCreateIssue(workflowData) + return nil } diff --git a/pkg/workflow/compiler_types.go b/pkg/workflow/compiler_types.go index 8af4660d9d0..039df5a1b5f 100644 --- a/pkg/workflow/compiler_types.go +++ b/pkg/workflow/compiler_types.go @@ -524,6 +524,7 @@ type SafeOutputsConfig struct { Mentions *MentionsConfig `yaml:"mentions,omitempty"` // Configuration for @mention filtering in safe outputs Footer *bool `yaml:"footer,omitempty"` // Global footer control - when false, omits visible footer from all safe outputs (XML markers still included) GroupReports bool `yaml:"group-reports,omitempty"` // If true, create parent "Failed runs" issue for agent failures (default: false) + AutoInjectedCreateIssue bool `yaml:"-"` // Internal: true when create-issues was automatically injected by the compiler (not user-configured) } // SafeOutputMessagesConfig holds custom message templates for safe-output footer and notification messages diff --git a/pkg/workflow/safe_outputs_config_helpers.go b/pkg/workflow/safe_outputs_config_helpers.go index 9958fe3364a..5e18573c67b 100644 --- a/pkg/workflow/safe_outputs_config_helpers.go +++ b/pkg/workflow/safe_outputs_config_helpers.go @@ -121,6 +121,51 @@ func (c *Compiler) formatSafeOutputsRunsOn(safeOutputs *SafeOutputsConfig) strin return fmt.Sprintf("runs-on: %s", safeOutputs.RunsOn) } +// builtinSafeOutputFields contains the struct field names for the built-in safe output types +// that are excluded from the "non-builtin" check. These are: noop, missing-data, missing-tool. +var builtinSafeOutputFields = map[string]bool{ + "NoOp": true, + "MissingData": true, + "MissingTool": true, +} + +// nonBuiltinSafeOutputFieldNames is a pre-computed list of field names from safeOutputFieldMapping +// that are not builtins, used by hasNonBuiltinSafeOutputsEnabled to avoid repeated map iterations. +var nonBuiltinSafeOutputFieldNames = func() []string { + var fields []string + for fieldName := range safeOutputFieldMapping { + if !builtinSafeOutputFields[fieldName] { + fields = append(fields, fieldName) + } + } + return fields +}() + +// hasNonBuiltinSafeOutputsEnabled checks if any non-builtin safe outputs are configured. +// The builtin types (noop, missing-data, missing-tool) are excluded from this check +// because they are always auto-enabled and do not represent a meaningful output action. +func hasNonBuiltinSafeOutputsEnabled(safeOutputs *SafeOutputsConfig) bool { + if safeOutputs == nil { + return false + } + + // Custom safe-jobs are always non-builtin + if len(safeOutputs.Jobs) > 0 { + return true + } + + // Check non-builtin pointer fields using the pre-computed list + val := reflect.ValueOf(safeOutputs).Elem() + for _, fieldName := range nonBuiltinSafeOutputFieldNames { + field := val.FieldByName(fieldName) + if field.IsValid() && !field.IsNil() { + return true + } + } + + return false +} + // HasSafeOutputsEnabled checks if any safe-outputs are enabled func HasSafeOutputsEnabled(safeOutputs *SafeOutputsConfig) bool { enabled := hasAnySafeOutputEnabled(safeOutputs) @@ -132,6 +177,28 @@ func HasSafeOutputsEnabled(safeOutputs *SafeOutputsConfig) bool { return enabled } +// applyDefaultCreateIssue injects a default create-issues safe output when safe-outputs is configured +// but has no non-builtin output types. The injected config uses the workflow ID as the label +// and [workflowID] as the title prefix. The AutoInjectedCreateIssue flag is set so the prompt +// generator can add a specific instruction for the agent. +func applyDefaultCreateIssue(workflowData *WorkflowData) { + if workflowData.SafeOutputs == nil { + return + } + if hasNonBuiltinSafeOutputsEnabled(workflowData.SafeOutputs) { + return + } + + workflowID := workflowData.WorkflowID + safeOutputsConfigLog.Printf("Auto-injecting create-issues for workflow %q (no non-builtin safe outputs configured)", workflowID) + workflowData.SafeOutputs.CreateIssues = &CreateIssuesConfig{ + BaseSafeOutputConfig: BaseSafeOutputConfig{Max: 1}, + Labels: []string{workflowID}, + TitlePrefix: fmt.Sprintf("[%s]", workflowID), + } + workflowData.SafeOutputs.AutoInjectedCreateIssue = true +} + // GetEnabledSafeOutputToolNames returns a list of enabled safe output tool names. // NOTE: Tool names should NOT be included in agent prompts. The agent should query // the MCP server to discover available tools. This function is used for generating diff --git a/pkg/workflow/safe_outputs_default_create_issue_test.go b/pkg/workflow/safe_outputs_default_create_issue_test.go new file mode 100644 index 00000000000..5afd61934f2 --- /dev/null +++ b/pkg/workflow/safe_outputs_default_create_issue_test.go @@ -0,0 +1,345 @@ +//go:build !integration + +package workflow + +import ( + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestHasNonBuiltinSafeOutputsEnabled verifies that only non-builtin safe outputs are counted +func TestHasNonBuiltinSafeOutputsEnabled(t *testing.T) { + tests := []struct { + name string + config *SafeOutputsConfig + expected bool + }{ + { + name: "nil config returns false", + config: nil, + expected: false, + }, + { + name: "empty config returns false", + config: &SafeOutputsConfig{}, + expected: false, + }, + { + name: "only noop returns false (builtin)", + config: &SafeOutputsConfig{ + NoOp: &NoOpConfig{}, + }, + expected: false, + }, + { + name: "only missing-data returns false (builtin)", + config: &SafeOutputsConfig{ + MissingData: &MissingDataConfig{}, + }, + expected: false, + }, + { + name: "only missing-tool returns false (builtin)", + config: &SafeOutputsConfig{ + MissingTool: &MissingToolConfig{}, + }, + expected: false, + }, + { + name: "all builtins returns false", + config: &SafeOutputsConfig{ + NoOp: &NoOpConfig{}, + MissingData: &MissingDataConfig{}, + MissingTool: &MissingToolConfig{}, + }, + expected: false, + }, + { + name: "create-issue is non-builtin returns true", + config: &SafeOutputsConfig{ + CreateIssues: &CreateIssuesConfig{}, + }, + expected: true, + }, + { + name: "add-comment is non-builtin returns true", + config: &SafeOutputsConfig{ + AddComments: &AddCommentsConfig{}, + }, + expected: true, + }, + { + name: "create-pull-request is non-builtin returns true", + config: &SafeOutputsConfig{ + CreatePullRequests: &CreatePullRequestsConfig{}, + }, + expected: true, + }, + { + name: "non-builtin alongside builtins returns true", + config: &SafeOutputsConfig{ + NoOp: &NoOpConfig{}, + MissingData: &MissingDataConfig{}, + MissingTool: &MissingToolConfig{}, + CreateIssues: &CreateIssuesConfig{}, + }, + expected: true, + }, + { + name: "custom safe-job returns true", + config: &SafeOutputsConfig{ + Jobs: map[string]*SafeJobConfig{ + "my_custom_job": {}, + }, + }, + expected: true, + }, + { + name: "custom safe-job alongside builtins returns true", + config: &SafeOutputsConfig{ + NoOp: &NoOpConfig{}, + Jobs: map[string]*SafeJobConfig{ + "my_custom_job": {}, + }, + }, + expected: true, + }, + { + name: "create-discussion is non-builtin returns true", + config: &SafeOutputsConfig{ + CreateDiscussions: &CreateDiscussionsConfig{}, + }, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := hasNonBuiltinSafeOutputsEnabled(tt.config) + assert.Equal(t, tt.expected, result, "hasNonBuiltinSafeOutputsEnabled(%v)", tt.config) + }) + } +} + +// TestAutoInjectCreateIssue verifies that create-issues is auto-injected when no non-builtin +// safe outputs are configured, and uses the workflow ID for labels and title-prefix. +func TestAutoInjectCreateIssue(t *testing.T) { + tests := []struct { + name string + workflowID string + safeOutputs *SafeOutputsConfig + expectInjection bool + expectedLabel string + expectedTitlePrefix string + expectedAutoInjected bool + }{ + { + name: "nil safe-outputs - no injection", + workflowID: "my-workflow", + safeOutputs: nil, + expectInjection: false, + }, + { + name: "only builtins configured - inject create-issue", + workflowID: "my-workflow", + safeOutputs: &SafeOutputsConfig{ + NoOp: &NoOpConfig{}, + MissingData: &MissingDataConfig{}, + MissingTool: &MissingToolConfig{}, + }, + expectInjection: true, + expectedLabel: "my-workflow", + expectedTitlePrefix: "[my-workflow]", + expectedAutoInjected: true, + }, + { + name: "empty safe-outputs - inject create-issue", + workflowID: "daily-report", + safeOutputs: &SafeOutputsConfig{ + NoOp: &NoOpConfig{}, + }, + expectInjection: true, + expectedLabel: "daily-report", + expectedTitlePrefix: "[daily-report]", + expectedAutoInjected: true, + }, + { + name: "create-issue already configured - no injection", + workflowID: "my-workflow", + safeOutputs: &SafeOutputsConfig{ + CreateIssues: &CreateIssuesConfig{ + TitlePrefix: "[existing]", + }, + }, + expectInjection: false, + }, + { + name: "add-comment configured - no injection", + workflowID: "my-workflow", + safeOutputs: &SafeOutputsConfig{ + AddComments: &AddCommentsConfig{}, + }, + expectInjection: false, + }, + { + name: "create-pull-request configured - no injection", + workflowID: "my-workflow", + safeOutputs: &SafeOutputsConfig{ + CreatePullRequests: &CreatePullRequestsConfig{}, + }, + expectInjection: false, + }, + { + name: "custom safe-job configured - no injection", + workflowID: "my-workflow", + safeOutputs: &SafeOutputsConfig{ + Jobs: map[string]*SafeJobConfig{ + "my_job": {}, + }, + }, + expectInjection: false, + }, + { + name: "empty safe-outputs config struct - inject create-issue", + workflowID: "status-checker", + safeOutputs: &SafeOutputsConfig{}, + expectInjection: true, + expectedLabel: "status-checker", + expectedTitlePrefix: "[status-checker]", + expectedAutoInjected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + workflowData := &WorkflowData{ + WorkflowID: tt.workflowID, + SafeOutputs: tt.safeOutputs, + } + + // Simulate the auto-injection logic + applyDefaultCreateIssue(workflowData) + + if !tt.expectInjection { + // If no injection expected, check the original state is preserved + if tt.safeOutputs == nil { + assert.Nil(t, workflowData.SafeOutputs, "SafeOutputs should remain nil") + } else if tt.safeOutputs.CreateIssues != nil { + // Original create-issues should be preserved unchanged + assert.Equal(t, tt.safeOutputs.CreateIssues.TitlePrefix, workflowData.SafeOutputs.CreateIssues.TitlePrefix, + "Existing create-issues config should be unchanged") + } else { + // No create-issues should be injected + assert.Nil(t, workflowData.SafeOutputs.CreateIssues, "create-issues should not be injected") + } + return + } + + // Injection expected + require.NotNil(t, workflowData.SafeOutputs, "SafeOutputs should not be nil after injection") + require.NotNil(t, workflowData.SafeOutputs.CreateIssues, "CreateIssues should be injected") + + assert.Equal(t, 1, workflowData.SafeOutputs.CreateIssues.Max, + "Injected create-issues should have max=1") + assert.Equal(t, []string{tt.expectedLabel}, workflowData.SafeOutputs.CreateIssues.Labels, + "Injected create-issues should have workflow ID as label") + assert.Equal(t, tt.expectedTitlePrefix, workflowData.SafeOutputs.CreateIssues.TitlePrefix, + "Injected create-issues should have [workflowID] as title prefix") + assert.True(t, workflowData.SafeOutputs.AutoInjectedCreateIssue, + "AutoInjectedCreateIssue should be true when injected") + }) + } +} + +// TestAutoInjectedCreateIssuePrompt verifies that the auto-injected create-issue produces +// a specific prompt instruction to create an issue with results or call noop. +func TestAutoInjectedCreateIssuePrompt(t *testing.T) { + tests := []struct { + name string + safeOutputs *SafeOutputsConfig + expectSpecific bool // expect the "IMPORTANT: Report your findings" instruction + }{ + { + name: "auto-injected create-issue produces specific prompt", + safeOutputs: &SafeOutputsConfig{ + CreateIssues: &CreateIssuesConfig{ + BaseSafeOutputConfig: BaseSafeOutputConfig{Max: 1}, + Labels: []string{"my-workflow"}, + TitlePrefix: "[my-workflow]", + }, + AutoInjectedCreateIssue: true, + }, + expectSpecific: true, + }, + { + name: "user-configured create-issue does NOT produce specific prompt", + safeOutputs: &SafeOutputsConfig{ + CreateIssues: &CreateIssuesConfig{ + TitlePrefix: "[custom]", + }, + AutoInjectedCreateIssue: false, + }, + expectSpecific: false, + }, + { + name: "no create-issue configured", + safeOutputs: &SafeOutputsConfig{ + AddComments: &AddCommentsConfig{}, + }, + expectSpecific: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var b strings.Builder + generateSafeOutputsPromptSection(&b, tt.safeOutputs) + output := b.String() + + specificInstruction := "**IMPORTANT**: Report your findings or results by creating a GitHub issue" + if tt.expectSpecific { + assert.Contains(t, output, specificInstruction, + "Auto-injected create-issue should include specific prompt instruction") + assert.Contains(t, output, "noop tool instead", + "Auto-injected create-issue prompt should mention calling noop as alternative") + } else { + assert.NotContains(t, output, specificInstruction, + "Non-auto-injected create-issue should not include specific auto-inject instruction") + } + }) + } +} + +// TestAutoInjectCreateIssueWithVariousWorkflowIDs verifies correct label/prefix generation +func TestAutoInjectCreateIssueWithVariousWorkflowIDs(t *testing.T) { + workflowIDs := []string{ + "daily-status", + "code-review", + "security-scan", + "my_workflow", + "workflow123", + } + + for _, wfID := range workflowIDs { + t.Run(fmt.Sprintf("workflowID=%s", wfID), func(t *testing.T) { + workflowData := &WorkflowData{ + WorkflowID: wfID, + SafeOutputs: &SafeOutputsConfig{ + NoOp: &NoOpConfig{}, + }, + } + + applyDefaultCreateIssue(workflowData) + + require.NotNil(t, workflowData.SafeOutputs.CreateIssues, "create-issues should be injected") + assert.Equal(t, []string{wfID}, workflowData.SafeOutputs.CreateIssues.Labels, + "Label should be the workflow ID") + assert.Equal(t, fmt.Sprintf("[%s]", wfID), workflowData.SafeOutputs.CreateIssues.TitlePrefix, + "Title prefix should be [workflowID]") + }) + } +} diff --git a/pkg/workflow/unified_prompt_step.go b/pkg/workflow/unified_prompt_step.go index 034182bba05..0846eca2698 100644 --- a/pkg/workflow/unified_prompt_step.go +++ b/pkg/workflow/unified_prompt_step.go @@ -789,6 +789,9 @@ func generateSafeOutputsPromptSection(b *strings.Builder, safeOutputs *SafeOutpu if safeOutputs.CreateIssues != nil { b.WriteString("**Creating an Issue**\n\n") fmt.Fprintf(b, "To create an issue, use the create_issue tool from %s.\n\n", constants.SafeOutputsMCPServerID) + if safeOutputs.AutoInjectedCreateIssue { + b.WriteString("**IMPORTANT**: Report your findings or results by creating a GitHub issue using the create_issue tool. If you have no meaningful results to report, call the noop tool instead.\n\n") + } } if safeOutputs.CloseIssues != nil {