From bfd647859ac5c3d5209d84675f45005ccbafa955 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Apr 2026 22:15:44 +0000 Subject: [PATCH 1/3] Initial plan From 83a0ff29a4bcca3694b4fe3f40e05e8fb68561c0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Apr 2026 23:07:22 +0000 Subject: [PATCH 2/3] fix: safe-outputs blocks from imports not merged when main has different types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bug: extractSafeOutputsConfig auto-defaults threat-detection, noop, missing-tool, missing-data, and report-incomplete even when not explicitly in frontmatter. These auto-defaults polluted topDefinedTypes in MergeSafeOutputs, causing imported configurations for those types to be dropped. Fix 1 (MergeSafeOutputs): Accept topRawSafeOutputs map[string]any. When provided, only keys explicitly present in the raw frontmatter map count as "defined" — preventing auto-defaults from blocking import merges. Fix 2 (mergeSafeOutputConfig): For the 5 auto-defaultable types, use the raw import config map key presence as the merge signal instead of (or in addition to) result.X == nil checks. This prevents an auto-default in result from blocking an explicitly-configured value from an import. Add integration test TestSafeOutputsDifferentTypesFromImportsMerged that reproduces the exact scenario from the bug report. Agent-Logs-Url: https://github.com/github/gh-aw/sessions/0924a615-0379-4777-8e36-0b1a83b701dc Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../compiler_orchestrator_workflow.go | 9 ++- pkg/workflow/imports.go | 57 +++++++++---- pkg/workflow/safe_outputs_fix_test.go | 2 +- pkg/workflow/safe_outputs_import_test.go | 80 +++++++++++++++++-- 4 files changed, 123 insertions(+), 25 deletions(-) diff --git a/pkg/workflow/compiler_orchestrator_workflow.go b/pkg/workflow/compiler_orchestrator_workflow.go index 079677fc545..4630e2d294b 100644 --- a/pkg/workflow/compiler_orchestrator_workflow.go +++ b/pkg/workflow/compiler_orchestrator_workflow.go @@ -942,8 +942,13 @@ func (c *Compiler) extractAdditionalConfigurations( workflowData.SafeOutputs.GitHubApp = includedApp } - // Merge safe-outputs types from imports - mergedSafeOutputs, err := c.MergeSafeOutputs(workflowData.SafeOutputs, allSafeOutputsConfigs) + // Merge safe-outputs types from imports. + // Pass the raw safe-outputs map from frontmatter so MergeSafeOutputs can distinguish + // between types the user explicitly configured and types that were auto-defaulted by + // extractSafeOutputsConfig. Without this, auto-defaults (e.g. threat-detection) would + // prevent imported configurations for those types from being merged. + rawSafeOutputsMap, _ := frontmatter["safe-outputs"].(map[string]any) + mergedSafeOutputs, err := c.MergeSafeOutputs(workflowData.SafeOutputs, allSafeOutputsConfigs, rawSafeOutputsMap) if err != nil { return fmt.Errorf("failed to merge safe-outputs from imports: %w", err) } diff --git a/pkg/workflow/imports.go b/pkg/workflow/imports.go index ff17098219e..29dd7e8ab2c 100644 --- a/pkg/workflow/imports.go +++ b/pkg/workflow/imports.go @@ -170,9 +170,17 @@ func getSafeOutputTypeKeys() ([]string, error) { return safeOutputTypeKeys, safeOutputTypeKeysErr } -// MergeSafeOutputs merges safe-outputs configurations from imports into the top-level safe-outputs -// Returns an error if a conflict is detected (same safe-output type defined in both main and imported) -func (c *Compiler) MergeSafeOutputs(topSafeOutputs *SafeOutputsConfig, importedSafeOutputsJSON []string) (*SafeOutputsConfig, error) { +// MergeSafeOutputs merges safe-outputs configurations from imports into the top-level safe-outputs. +// Returns an error if a conflict is detected (same safe-output type defined in both main and imported). +// +// topRawSafeOutputs is the raw safe-outputs map from the main workflow's frontmatter (may be nil). +// When provided, only keys explicitly present in the raw map are treated as "defined" in the main +// workflow for the purpose of conflict detection. This prevents auto-defaults applied by +// extractSafeOutputsConfig (e.g. threat-detection, noop, missing-tool) from blocking import +// configurations for types the user never explicitly configured. +// When nil, the processed topSafeOutputs config fields are used to determine defined types +// (legacy behavior used by unit tests that construct configs directly). +func (c *Compiler) MergeSafeOutputs(topSafeOutputs *SafeOutputsConfig, importedSafeOutputsJSON []string, topRawSafeOutputs map[string]any) (*SafeOutputsConfig, error) { importsLog.Print("Merging safe-outputs from imports") if len(importedSafeOutputsJSON) == 0 { @@ -186,11 +194,18 @@ func (c *Compiler) MergeSafeOutputs(topSafeOutputs *SafeOutputsConfig, importedS return nil, fmt.Errorf("failed to get safe output type keys: %w", err) } - // Collect all safe output types defined in the top-level config + // Collect all safe output types defined in the top-level config. + // When topRawSafeOutputs is provided (from raw frontmatter), use only keys that are + // explicitly present in the raw map to avoid counting auto-defaults as user-defined types. + // When nil, fall back to inspecting the processed config struct (legacy/test behaviour). topDefinedTypes := make(map[string]bool) if topSafeOutputs != nil { for _, key := range typeKeys { - if hasSafeOutputType(topSafeOutputs, key) { + if topRawSafeOutputs != nil { + if _, exists := topRawSafeOutputs[key]; exists { + topDefinedTypes[key] = true + } + } else if hasSafeOutputType(topSafeOutputs, key) { topDefinedTypes[key] = true } } @@ -483,24 +498,36 @@ func mergeSafeOutputConfig(result *SafeOutputsConfig, config map[string]any, c * if result.CallWorkflow == nil && importedConfig.CallWorkflow != nil { result.CallWorkflow = importedConfig.CallWorkflow } - if result.MissingTool == nil && importedConfig.MissingTool != nil { + // missing-tool, missing-data, noop, and report-incomplete are auto-defaulted by + // extractSafeOutputsConfig whenever any safe-outputs are present, even when the user + // has not explicitly configured those types. This means result.X can be non-nil (the + // auto-default) even though the main workflow never explicitly set it. We therefore use + // the presence of the key in the raw imported config map as the authoritative signal: + // if the import explicitly carries the key, its value wins over any auto-default in result. + // The "|| result.X == nil" arm preserves the legacy path where result has no value at all. + _, hasMissingTool := config["missing-tool"] + if (hasMissingTool || result.MissingTool == nil) && importedConfig.MissingTool != nil { result.MissingTool = importedConfig.MissingTool } - if result.MissingData == nil && importedConfig.MissingData != nil { + _, hasMissingData := config["missing-data"] + if (hasMissingData || result.MissingData == nil) && importedConfig.MissingData != nil { result.MissingData = importedConfig.MissingData } - if result.NoOp == nil && importedConfig.NoOp != nil { + _, hasNoop := config["noop"] + if (hasNoop || result.NoOp == nil) && importedConfig.NoOp != nil { result.NoOp = importedConfig.NoOp } - if result.ReportIncomplete == nil && importedConfig.ReportIncomplete != nil { + _, hasReportIncomplete := config["report-incomplete"] + if (hasReportIncomplete || result.ReportIncomplete == nil) && importedConfig.ReportIncomplete != nil { result.ReportIncomplete = importedConfig.ReportIncomplete } - // ThreatDetection is a workflow-level concern; only merge from an import that - // explicitly carries a threat-detection key (not just an auto-enabled default). - if result.ThreatDetection == nil { - if _, hasTD := config["threat-detection"]; hasTD && importedConfig.ThreatDetection != nil { - result.ThreatDetection = importedConfig.ThreatDetection - } + // ThreatDetection is also auto-defaulted by extractSafeOutputsConfig; apply the same + // pattern — the import's explicit threat-detection key takes precedence over the result's + // auto-default empty struct (which is not user-authored). If the main workflow explicitly + // defined threat-detection, MergeSafeOutputs will have already removed it from the import + // config (via topDefinedTypes), so config["threat-detection"] won't exist in that case. + if _, hasTD := config["threat-detection"]; hasTD && importedConfig.ThreatDetection != nil { + result.ThreatDetection = importedConfig.ThreatDetection } // Merge meta-configuration fields (only set if empty/zero in result) diff --git a/pkg/workflow/safe_outputs_fix_test.go b/pkg/workflow/safe_outputs_fix_test.go index d6a0da0b842..0570a9f729a 100644 --- a/pkg/workflow/safe_outputs_fix_test.go +++ b/pkg/workflow/safe_outputs_fix_test.go @@ -249,7 +249,7 @@ func TestMergeSafeOutputsMetaFieldsUnit(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result, err := compiler.MergeSafeOutputs(tt.topConfig, []string{tt.imported}) + result, err := compiler.MergeSafeOutputs(tt.topConfig, []string{tt.imported}, nil) require.NoError(t, err, "MergeSafeOutputs should not error") require.NotNil(t, result, "result should not be nil") tt.verify(t, result) diff --git a/pkg/workflow/safe_outputs_import_test.go b/pkg/workflow/safe_outputs_import_test.go index a8e0b00283a..4a6df3e45e9 100644 --- a/pkg/workflow/safe_outputs_import_test.go +++ b/pkg/workflow/safe_outputs_import_test.go @@ -485,7 +485,7 @@ func TestMergeSafeOutputsUnit(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result, err := compiler.MergeSafeOutputs(tt.topConfig, tt.importedJSON) + result, err := compiler.MergeSafeOutputs(tt.topConfig, tt.importedJSON, nil) if tt.expectError { require.Error(t, err) @@ -596,7 +596,7 @@ func TestMergeSafeOutputsMessagesUnit(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result, err := compiler.MergeSafeOutputs(tt.topConfig, tt.importedJSON) + result, err := compiler.MergeSafeOutputs(tt.topConfig, tt.importedJSON, nil) if tt.expectError { require.Error(t, err) @@ -1309,7 +1309,7 @@ func TestMergeSafeOutputsJobsNotMerged(t *testing.T) { `{"jobs":{"imported-job":{"name":"Imported Job","runs-on":"ubuntu-latest"}},"create-issue":{"title-prefix":"[test] "}}`, } - result, err := compiler.MergeSafeOutputs(topConfig, importedJSON) + result, err := compiler.MergeSafeOutputs(topConfig, importedJSON, nil) require.NoError(t, err, "MergeSafeOutputs should not error") // Verify that the existing job is preserved (Jobs field untouched) @@ -1336,7 +1336,7 @@ func TestMergeSafeOutputsJobsSkippedWhenEmpty(t *testing.T) { `{"jobs":{"imported-job":{"name":"Imported Job"}},"add-comment":{"max":5}}`, } - result, err := compiler.MergeSafeOutputs(topConfig, importedJSON) + result, err := compiler.MergeSafeOutputs(topConfig, importedJSON, nil) require.NoError(t, err, "MergeSafeOutputs should not error") // Jobs should still be nil since we don't merge them in MergeSafeOutputs @@ -1388,7 +1388,7 @@ func TestMergeSafeOutputsErrorPropagation(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result, err := compiler.MergeSafeOutputs(nil, tt.importedJSON) + result, err := compiler.MergeSafeOutputs(nil, tt.importedJSON, nil) if tt.expectError { require.Error(t, err, "Expected error") @@ -1842,7 +1842,7 @@ func TestMergeSafeOutputsThreatDetectionExplicitDisableNotOverridden(t *testing. `{"add-comment":{"max":1}}`, } - result, err := compiler.MergeSafeOutputs(topConfig, importedJSON) + result, err := compiler.MergeSafeOutputs(topConfig, importedJSON, nil) require.NoError(t, err, "MergeSafeOutputs should not error") require.NotNil(t, result, "Result should not be nil") @@ -1860,7 +1860,7 @@ func TestMergeSafeOutputsThreatDetectionImportedWhenExplicit(t *testing.T) { `{"add-comment":{"max":1},"threat-detection":{"enabled":true}}`, } - result, err := compiler.MergeSafeOutputs(nil, importedJSON) + result, err := compiler.MergeSafeOutputs(nil, importedJSON, nil) require.NoError(t, err, "MergeSafeOutputs should not error") require.NotNil(t, result, "Result should not be nil") @@ -1927,3 +1927,69 @@ safe-outputs: // The explicit disable must survive the import merge. assert.Nil(t, workflowData.SafeOutputs.ThreatDetection, "ThreatDetection must remain nil when explicitly disabled by main workflow") } + +// TestSafeOutputsDifferentTypesFromImportsMerged reproduces the bug reported in +// https://github.com/github/gh-aw/issues/: +// When the main workflow defines one safe-outputs type (e.g. noop) and an imported +// workflow defines a different type (e.g. threat-detection), the imported type should +// be merged into the compiled output. Previously the auto-default applied by +// extractSafeOutputsConfig (which enabled threat-detection by default whenever any +// safe-outputs were present) caused threat-detection to appear as "already defined" in +// topDefinedTypes, so the import's explicit threat-detection configuration was dropped. +func TestSafeOutputsDifferentTypesFromImportsMerged(t *testing.T) { + compiler := NewCompilerWithVersion("1.0.0") + + tmpDir := t.TempDir() + workflowsDir := filepath.Join(tmpDir, ".github", "workflows") + err := os.MkdirAll(workflowsDir, 0755) + require.NoError(t, err, "Failed to create workflows directory") + + // Imported workflow: only defines threat-detection with a custom step + importedWorkflow := `--- +safe-outputs: + threat-detection: + steps: + - name: Print abc + run: echo "abc" +--- +` + importedFile := filepath.Join(workflowsDir, "abc.md") + err = os.WriteFile(importedFile, []byte(importedWorkflow), 0644) + require.NoError(t, err, "Failed to write imported file") + + // Main workflow: only defines noop, does NOT define threat-detection + mainWorkflow := `--- +description: hello world +on: + workflow_dispatch: +imports: + - ./abc.md +safe-outputs: + noop: + report-as-issue: false +--- +Print "hello world!". +` + mainFile := filepath.Join(workflowsDir, "hello-world.md") + err = os.WriteFile(mainFile, []byte(mainWorkflow), 0644) + require.NoError(t, err, "Failed to write main file") + + oldDir, err := os.Getwd() + require.NoError(t, err, "Failed to get current directory") + err = os.Chdir(workflowsDir) + require.NoError(t, err, "Failed to change directory") + defer func() { _ = os.Chdir(oldDir) }() + + workflowData, err := compiler.ParseWorkflowFile("hello-world.md") + require.NoError(t, err, "ParseWorkflowFile should not error") + require.NotNil(t, workflowData.SafeOutputs, "SafeOutputs should not be nil") + + // noop was explicitly set in the main workflow + require.NotNil(t, workflowData.SafeOutputs.NoOp, "NoOp should be set (from main workflow)") + + // threat-detection was explicitly set in the import — it must be merged + require.NotNil(t, workflowData.SafeOutputs.ThreatDetection, + "ThreatDetection should be merged from the imported workflow") + assert.Len(t, workflowData.SafeOutputs.ThreatDetection.Steps, 1, + "ThreatDetection should have 1 custom step from the import") +} From a62daa73241151160998436ca3e5afa7c94f91b7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Apr 2026 23:36:32 +0000 Subject: [PATCH 3/3] test: add more test scenarios for safe-outputs import merging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add three new integration tests covering the auto-defaultable types fix: 1. TestSafeOutputsAutoDefaultableTypesImportedWhenMainHasNone - all five auto-defaultable types (noop, missing-tool, missing-data, report-incomplete, threat-detection) imported with custom config when main has no safe-outputs 2. TestSafeOutputsMainExplicitAutoDefaultableTypeOverridesImport - main explicitly configures noop, import also defines noop → main wins; import's other types (missing-tool) still merge in 3. TestSafeOutputsMultipleImportsEachContributeAutoDefaultableType - three imports each contribute a different auto-defaultable type; no conflict, all merge alongside main's create-issue Agent-Logs-Url: https://github.com/github/gh-aw/sessions/e03dc446-5102-4a4b-823f-38784e0c1d8f Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/safe_outputs_import_test.go | 232 +++++++++++++++++++++++ 1 file changed, 232 insertions(+) diff --git a/pkg/workflow/safe_outputs_import_test.go b/pkg/workflow/safe_outputs_import_test.go index 4a6df3e45e9..b9d6f89f344 100644 --- a/pkg/workflow/safe_outputs_import_test.go +++ b/pkg/workflow/safe_outputs_import_test.go @@ -1993,3 +1993,235 @@ Print "hello world!". assert.Len(t, workflowData.SafeOutputs.ThreatDetection.Steps, 1, "ThreatDetection should have 1 custom step from the import") } + +// TestSafeOutputsAutoDefaultableTypesImportedWhenMainHasNone verifies that every +// auto-defaultable type (noop, missing-tool, missing-data, report-incomplete, +// threat-detection) is properly imported when the main workflow has no safe-outputs. +// Previously, extractSafeOutputsConfig created auto-defaults for these types that +// would silently block import merges. +func TestSafeOutputsAutoDefaultableTypesImportedWhenMainHasNone(t *testing.T) { + compiler := NewCompilerWithVersion("1.0.0") + + tmpDir := t.TempDir() + workflowsDir := filepath.Join(tmpDir, ".github", "workflows") + err := os.MkdirAll(workflowsDir, 0755) + require.NoError(t, err, "Failed to create workflows directory") + + // Import defines all five auto-defaultable types with explicit custom values. + importedWorkflow := `--- +safe-outputs: + noop: + report-as-issue: false + missing-tool: + title-prefix: "[imported missing-tool] " + missing-data: + title-prefix: "[imported missing-data] " + report-incomplete: + title-prefix: "[imported report-incomplete] " + threat-detection: + steps: + - name: Custom detection step + run: echo "custom" +--- +` + importedFile := filepath.Join(workflowsDir, "shared.md") + err = os.WriteFile(importedFile, []byte(importedWorkflow), 0644) + require.NoError(t, err, "Failed to write imported file") + + // Main workflow has no safe-outputs section at all. + mainWorkflow := `--- +on: + workflow_dispatch: +imports: + - ./shared.md +--- +Run a task. +` + mainFile := filepath.Join(workflowsDir, "main.md") + err = os.WriteFile(mainFile, []byte(mainWorkflow), 0644) + require.NoError(t, err, "Failed to write main file") + + oldDir, err := os.Getwd() + require.NoError(t, err, "Failed to get current directory") + err = os.Chdir(workflowsDir) + require.NoError(t, err, "Failed to change directory") + defer func() { _ = os.Chdir(oldDir) }() + + workflowData, err := compiler.ParseWorkflowFile("main.md") + require.NoError(t, err, "ParseWorkflowFile should not error") + require.NotNil(t, workflowData.SafeOutputs, "SafeOutputs should not be nil") + + // noop: report-as-issue was explicitly set to false in the import + require.NotNil(t, workflowData.SafeOutputs.NoOp, "NoOp should be imported") + require.NotNil(t, workflowData.SafeOutputs.NoOp.ReportAsIssue, "NoOp.ReportAsIssue should be set") + assert.Equal(t, "false", *workflowData.SafeOutputs.NoOp.ReportAsIssue, + "NoOp.ReportAsIssue should be 'false' from the import") + + // missing-tool with custom title-prefix + require.NotNil(t, workflowData.SafeOutputs.MissingTool, "MissingTool should be imported") + assert.Equal(t, "[imported missing-tool] ", workflowData.SafeOutputs.MissingTool.TitlePrefix, + "MissingTool.TitlePrefix should come from the import") + + // missing-data with custom title-prefix + require.NotNil(t, workflowData.SafeOutputs.MissingData, "MissingData should be imported") + assert.Equal(t, "[imported missing-data] ", workflowData.SafeOutputs.MissingData.TitlePrefix, + "MissingData.TitlePrefix should come from the import") + + // report-incomplete with custom title-prefix + require.NotNil(t, workflowData.SafeOutputs.ReportIncomplete, "ReportIncomplete should be imported") + assert.Equal(t, "[imported report-incomplete] ", workflowData.SafeOutputs.ReportIncomplete.TitlePrefix, + "ReportIncomplete.TitlePrefix should come from the import") + + // threat-detection with custom step + require.NotNil(t, workflowData.SafeOutputs.ThreatDetection, "ThreatDetection should be imported") + assert.Len(t, workflowData.SafeOutputs.ThreatDetection.Steps, 1, + "ThreatDetection should have the 1 custom step from the import") +} + +// TestSafeOutputsMainExplicitAutoDefaultableTypeOverridesImport verifies that when the main +// workflow explicitly configures an auto-defaultable type (e.g. noop), an import that also +// defines the same type is overridden by the main (main wins / override semantics). +func TestSafeOutputsMainExplicitAutoDefaultableTypeOverridesImport(t *testing.T) { + compiler := NewCompilerWithVersion("1.0.0") + + tmpDir := t.TempDir() + workflowsDir := filepath.Join(tmpDir, ".github", "workflows") + err := os.MkdirAll(workflowsDir, 0755) + require.NoError(t, err, "Failed to create workflows directory") + + importedWorkflow := `--- +safe-outputs: + noop: + report-as-issue: true + missing-tool: + title-prefix: "[imported] " +--- +` + importedFile := filepath.Join(workflowsDir, "shared.md") + err = os.WriteFile(importedFile, []byte(importedWorkflow), 0644) + require.NoError(t, err, "Failed to write imported file") + + // Main explicitly sets noop (report-as-issue: false) — import's noop should be ignored. + mainWorkflow := `--- +on: + workflow_dispatch: +imports: + - ./shared.md +safe-outputs: + noop: + report-as-issue: false +--- +Run a task. +` + mainFile := filepath.Join(workflowsDir, "main.md") + err = os.WriteFile(mainFile, []byte(mainWorkflow), 0644) + require.NoError(t, err, "Failed to write main file") + + oldDir, err := os.Getwd() + require.NoError(t, err, "Failed to get current directory") + err = os.Chdir(workflowsDir) + require.NoError(t, err, "Failed to change directory") + defer func() { _ = os.Chdir(oldDir) }() + + workflowData, err := compiler.ParseWorkflowFile("main.md") + require.NoError(t, err, "ParseWorkflowFile should not error") + require.NotNil(t, workflowData.SafeOutputs, "SafeOutputs should not be nil") + + // Main's noop (report-as-issue: false) must take precedence over import's (true). + require.NotNil(t, workflowData.SafeOutputs.NoOp, "NoOp should be present") + require.NotNil(t, workflowData.SafeOutputs.NoOp.ReportAsIssue, "NoOp.ReportAsIssue should be set") + assert.Equal(t, "false", *workflowData.SafeOutputs.NoOp.ReportAsIssue, + "Main's noop (report-as-issue: false) should override import's noop (true)") + + // missing-tool was only in the import — it must still be merged. + require.NotNil(t, workflowData.SafeOutputs.MissingTool, "MissingTool should be imported") + assert.Equal(t, "[imported] ", workflowData.SafeOutputs.MissingTool.TitlePrefix, + "MissingTool.TitlePrefix should come from the import") +} + +// TestSafeOutputsMultipleImportsEachContributeAutoDefaultableType verifies that when +// several imports each contribute a different auto-defaultable type, all of them are +// merged and none triggers a conflict error. +func TestSafeOutputsMultipleImportsEachContributeAutoDefaultableType(t *testing.T) { + compiler := NewCompilerWithVersion("1.0.0") + + tmpDir := t.TempDir() + workflowsDir := filepath.Join(tmpDir, ".github", "workflows") + err := os.MkdirAll(workflowsDir, 0755) + require.NoError(t, err, "Failed to create workflows directory") + + // First import: noop + import1 := `--- +safe-outputs: + noop: + report-as-issue: false +--- +` + err = os.WriteFile(filepath.Join(workflowsDir, "noop.md"), []byte(import1), 0644) + require.NoError(t, err, "Failed to write noop.md") + + // Second import: missing-tool + import2 := `--- +safe-outputs: + missing-tool: + title-prefix: "[missing-tool import] " +--- +` + err = os.WriteFile(filepath.Join(workflowsDir, "missing-tool.md"), []byte(import2), 0644) + require.NoError(t, err, "Failed to write missing-tool.md") + + // Third import: report-incomplete + import3 := `--- +safe-outputs: + report-incomplete: + title-prefix: "[report-incomplete import] " +--- +` + err = os.WriteFile(filepath.Join(workflowsDir, "report-incomplete.md"), []byte(import3), 0644) + require.NoError(t, err, "Failed to write report-incomplete.md") + + // Main workflow: only defines create-issue; all three auto-defaultable types come from imports. + mainWorkflow := `--- +on: + workflow_dispatch: +imports: + - ./noop.md + - ./missing-tool.md + - ./report-incomplete.md +safe-outputs: + create-issue: + title-prefix: "[main] " +--- +Run a task. +` + mainFile := filepath.Join(workflowsDir, "main.md") + err = os.WriteFile(mainFile, []byte(mainWorkflow), 0644) + require.NoError(t, err, "Failed to write main file") + + oldDir, err := os.Getwd() + require.NoError(t, err, "Failed to get current directory") + err = os.Chdir(workflowsDir) + require.NoError(t, err, "Failed to change directory") + defer func() { _ = os.Chdir(oldDir) }() + + workflowData, err := compiler.ParseWorkflowFile("main.md") + require.NoError(t, err, "ParseWorkflowFile should not error — no conflicts expected") + require.NotNil(t, workflowData.SafeOutputs, "SafeOutputs should not be nil") + + // create-issue from main + require.NotNil(t, workflowData.SafeOutputs.CreateIssues, "CreateIssues should be present from main") + assert.Equal(t, "[main] ", workflowData.SafeOutputs.CreateIssues.TitlePrefix) + + // noop from first import + require.NotNil(t, workflowData.SafeOutputs.NoOp, "NoOp should be imported from noop.md") + require.NotNil(t, workflowData.SafeOutputs.NoOp.ReportAsIssue, "NoOp.ReportAsIssue should be set") + assert.Equal(t, "false", *workflowData.SafeOutputs.NoOp.ReportAsIssue) + + // missing-tool from second import + require.NotNil(t, workflowData.SafeOutputs.MissingTool, "MissingTool should be imported from missing-tool.md") + assert.Equal(t, "[missing-tool import] ", workflowData.SafeOutputs.MissingTool.TitlePrefix) + + // report-incomplete from third import + require.NotNil(t, workflowData.SafeOutputs.ReportIncomplete, "ReportIncomplete should be imported from report-incomplete.md") + assert.Equal(t, "[report-incomplete import] ", workflowData.SafeOutputs.ReportIncomplete.TitlePrefix) +}