diff --git a/pkg/cli/codemod_copilot_web_fetch.go b/pkg/cli/codemod_copilot_web_fetch.go new file mode 100644 index 00000000000..a3499d065af --- /dev/null +++ b/pkg/cli/codemod_copilot_web_fetch.go @@ -0,0 +1,62 @@ +package cli + +import ( + "github.com/github/gh-aw/pkg/logger" + "github.com/github/gh-aw/pkg/workflow" +) + +var copilotWebFetchCodemodLog = logger.New("cli:codemod_copilot_web_fetch") + +func getCopilotWebFetchRemovalCodemod() Codemod { + return Codemod{ + ID: "copilot-web-fetch-removal", + Name: "Remove unsupported Copilot web-fetch tool", + Description: "Removes tools.web-fetch when Copilot CLI runs in offline BYOK mode. Copilot SDK mode retains its proxy-aware custom fetch tool.", + IntroducedIn: "1.0.0", + Apply: func(content string, frontmatter map[string]any) (string, bool, error) { + return applyCopilotWebFetchRemoval(content, frontmatter, "") + }, + ApplyWithContext: func(content string, frontmatter map[string]any, filePath string) (string, bool, error) { + return applyCopilotWebFetchRemoval(content, frontmatter, filePath) + }, + } +} + +func applyCopilotWebFetchRemoval(content string, frontmatter map[string]any, filePath string) (string, bool, error) { + tools, ok := frontmatter["tools"].(map[string]any) + if !ok { + return content, false, nil + } + webFetch, exists := tools["web-fetch"] + if !exists { + return content, false, nil + } + if enabled, ok := webFetch.(bool); ok && !enabled { + return content, false, nil + } + + compiler := workflow.NewCompiler() + var engineConfig *workflow.EngineConfig + if filePath != "" { + resolvedConfig, err := compiler.ResolveEffectiveEngineConfig(content, filePath) + if err != nil { + copilotWebFetchCodemodLog.Printf("Unable to resolve effective engine; preserving tools.web-fetch: %v", err) + return content, false, nil + } + engineConfig = resolvedConfig + } else { + if _, hasEngine := frontmatter["engine"]; !hasEngine { + return content, false, nil + } + _, engineConfig, _ = compiler.ExtractEngineConfig(frontmatter) + } + if engineConfig == nil || engineConfig.ID != "copilot" || engineConfig.CopilotSDK { + return content, false, nil + } + + updated, applied, err := removeYAMLMappingPath(content, []string{"tools", "web-fetch"}, false) + if applied { + copilotWebFetchCodemodLog.Print("Removed unsupported tools.web-fetch for Copilot CLI") + } + return updated, applied, err +} diff --git a/pkg/cli/codemod_copilot_web_fetch_test.go b/pkg/cli/codemod_copilot_web_fetch_test.go new file mode 100644 index 00000000000..7417ee1592c --- /dev/null +++ b/pkg/cli/codemod_copilot_web_fetch_test.go @@ -0,0 +1,316 @@ +//go:build !integration + +package cli + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/github/gh-aw/pkg/parser" + "github.com/github/gh-aw/pkg/workflow" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCopilotWebFetchRemovalCodemod_Metadata(t *testing.T) { + t.Parallel() + + codemod := getCopilotWebFetchRemovalCodemod() + assert.Equal(t, "copilot-web-fetch-removal", codemod.ID) + assert.NotEmpty(t, codemod.Name) + assert.NotEmpty(t, codemod.Description) + assert.Equal(t, "1.0.0", codemod.IntroducedIn) + require.NotNil(t, codemod.Apply) + require.NotNil(t, codemod.ApplyWithContext) +} + +func TestCopilotWebFetchRemovalCodemod_Apply(t *testing.T) { + t.Parallel() + + content := `--- +engine: + id: copilot +tools: + web-fetch: true # native fetch is unavailable + bash: ["git"] +--- + +# Keep this body intact. +` + frontmatter := map[string]any{ + "engine": map[string]any{"id": "copilot"}, + "tools": map[string]any{"web-fetch": true, "bash": []any{"git"}}, + } + + codemod := getCopilotWebFetchRemovalCodemod() + updated, applied, err := codemod.Apply(content, frontmatter) + require.NoError(t, err) + require.True(t, applied) + assert.Equal(t, `--- +engine: + id: copilot +tools: + bash: ["git"] +--- + +# Keep this body intact. +`, updated) + + updatedAgain, appliedAgain, err := codemod.Apply(updated, frontmatter) + require.NoError(t, err) + assert.False(t, appliedAgain) + assert.Equal(t, updated, updatedAgain) +} + +func TestCopilotWebFetchRemovalCodemod_CompatibleInputsAreUnchanged(t *testing.T) { + t.Parallel() + + codemod := getCopilotWebFetchRemovalCodemod() + tests := []struct { + name string + frontmatter map[string]any + content string + }{ + { + name: "Copilot SDK custom fetch", + frontmatter: map[string]any{ + "engine": map[string]any{"id": "copilot", "copilot-sdk": true}, + "tools": map[string]any{"web-fetch": true}, + }, + content: "---\nengine:\n id: copilot\n copilot-sdk: true\ntools:\n web-fetch:\n---\n", + }, + { + name: "other engine", + frontmatter: map[string]any{"engine": "codex", "tools": map[string]any{"web-fetch": true}}, + content: "---\nengine: codex\ntools:\n web-fetch:\n---\n", + }, + { + name: "explicitly disabled", + frontmatter: map[string]any{"engine": "copilot", "tools": map[string]any{"web-fetch": false}}, + content: "---\nengine: copilot\ntools:\n web-fetch: false\n---\n", + }, + { + name: "no web-fetch setting", + frontmatter: map[string]any{"engine": "copilot", "tools": map[string]any{"bash": true}}, + content: "---\nengine: copilot\ntools:\n bash: true\n---\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + updated, applied, err := codemod.Apply(tt.content, tt.frontmatter) + require.NoError(t, err) + assert.False(t, applied) + assert.Equal(t, tt.content, updated) + }) + } +} + +func TestCopilotWebFetchRemovalCodemod_FixesStrictCompilation(t *testing.T) { + t.Parallel() + + content := `--- +on: workflow_dispatch +strict: true +engine: + id: copilot +tools: + web-fetch: +--- +Fetch content. +` + compiler := workflow.NewCompiler() + _, err := compiler.ParseWorkflowString(content, "copilot-web-fetch.md") + require.ErrorContains(t, err, "Copilot's native 'web-fetch' tool is unavailable") + + path := filepath.Join(t.TempDir(), "copilot-web-fetch.md") + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + fixed, _, err := processWorkflowFileWithInfo(path, GetAllCodemods(), true, false) + require.NoError(t, err) + require.True(t, fixed) + + updatedBytes, err := os.ReadFile(path) + require.NoError(t, err) + updated := string(updatedBytes) + compiler = workflow.NewCompiler() + data, err := compiler.ParseWorkflowString(updated, "copilot-web-fetch.md") + require.NoError(t, err) + require.NoError(t, compiler.CompileWorkflowData(data, filepath.Join(t.TempDir(), "copilot-web-fetch.md"))) +} + +func TestCopilotWebFetchRemovalCodemod_YAMLFormsAndLiteralText(t *testing.T) { + t.Parallel() + + codemod := getCopilotWebFetchRemovalCodemod() + tests := []struct { + name string + content string + hasBash bool + expectedComment string + }{ + { + name: "flow mapping and quoted block key", + content: `--- +engine: copilot +tools: {web-fetch: true, bash: ["git"]} # keep tools comment +description: | + Example: + tools: + 'web-fetch': example +--- +`, + hasBash: true, + expectedComment: "# keep tools comment", + }, + { + name: "quoted key with sibling literal", + content: `--- +engine: copilot +tools: + 'web-fetch': true + bash: ["git"] # keep sibling comment +description: | + tools: + web-fetch: example +--- +`, + hasBash: true, + expectedComment: "# keep sibling comment", + }, + { + name: "flow mapping with only web-fetch", + content: `--- +engine: copilot +tools: {web-fetch: true} +description: | + tools: + web-fetch: example +--- +`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + frontmatter, err := parser.ExtractFrontmatterFromContent(tt.content) + require.NoError(t, err) + updated, applied, err := codemod.Apply(tt.content, frontmatter.Frontmatter) + require.NoError(t, err) + require.True(t, applied) + + updatedFrontmatter, err := parser.ExtractFrontmatterFromContent(updated) + require.NoError(t, err) + if tt.hasBash { + tools := updatedFrontmatter.Frontmatter["tools"].(map[string]any) + assert.NotContains(t, tools, "web-fetch") + assert.Contains(t, tools, "bash") + } else { + assert.NotContains(t, updatedFrontmatter.Frontmatter, "tools") + } + assert.Equal(t, frontmatter.Frontmatter["description"], updatedFrontmatter.Frontmatter["description"]) + if tt.expectedComment != "" { + assert.Contains(t, updated, tt.expectedComment) + } + }) + } +} + +func TestCopilotWebFetchRemovalCodemod_ResolvesIncludedEngine(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + engineSource string + importEngine bool + expectedID string + copilotSDK bool + shouldRemove bool + }{ + { + name: "imported Copilot SDK", + engineSource: `engine: + id: copilot + copilot-sdk: true`, + importEngine: true, + expectedID: "copilot", + copilotSDK: true, + }, + { + name: "included other engine", + engineSource: "engine: codex", + expectedID: "codex", + }, + { + name: "included Copilot CLI", + engineSource: "engine: copilot", + expectedID: "copilot", + shouldRemove: true, + }, + } + codemod := getCopilotWebFetchRemovalCodemod() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + includePath := filepath.Join(dir, "engine.md") + include := "---\n" + tt.engineSource + "\n---\n" + require.NoError(t, os.WriteFile(includePath, []byte(include), 0o600)) + + content := `--- +on: issue_comment +tools: + web-fetch: true +--- +` + if tt.importEngine { + content = strings.Replace(content, "---\n", "---\nimports:\n - engine.md\n", 1) + } else { + content += "@include engine.md\n" + } + path := filepath.Join(dir, "workflow.md") + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + frontmatter, err := parser.ExtractFrontmatterFromContent(content) + require.NoError(t, err) + + engineConfig, err := workflow.NewCompiler().ResolveEffectiveEngineConfig(content, path) + require.NoError(t, err) + require.NotNil(t, engineConfig) + assert.Equal(t, tt.expectedID, engineConfig.ID) + assert.Equal(t, tt.copilotSDK, engineConfig.CopilotSDK) + + updated, applied, err := codemod.ApplyWithContext(content, frontmatter.Frontmatter, path) + require.NoError(t, err) + assert.Equal(t, tt.shouldRemove, applied) + if tt.shouldRemove { + assert.NotContains(t, updated, "web-fetch") + } else { + assert.Equal(t, content, updated) + } + }) + } +} + +func TestCopilotWebFetchRemovalCodemod_PreservesSettingWhenEngineResolutionFails(t *testing.T) { + t.Parallel() + + content := `--- +on: issue_comment +tools: + web-fetch: true +--- +@include missing-engine.md +` + frontmatter, err := parser.ExtractFrontmatterFromContent(content) + require.NoError(t, err) + codemod := getCopilotWebFetchRemovalCodemod() + + updated, applied, err := codemod.ApplyWithContext(content, frontmatter.Frontmatter, filepath.Join(t.TempDir(), "workflow.md")) + require.NoError(t, err) + assert.False(t, applied) + assert.Equal(t, content, updated) +} diff --git a/pkg/cli/codemod_workflow_dispatch_aw_context.go b/pkg/cli/codemod_workflow_dispatch_aw_context.go new file mode 100644 index 00000000000..b2752588c0c --- /dev/null +++ b/pkg/cli/codemod_workflow_dispatch_aw_context.go @@ -0,0 +1,37 @@ +package cli + +import "github.com/github/gh-aw/pkg/logger" + +var workflowDispatchAwContextCodemodLog = logger.New("cli:codemod_workflow_dispatch_aw_context") + +func getWorkflowDispatchAwContextRemovalCodemod() Codemod { + return Codemod{ + ID: "workflow-dispatch-aw-context-removal", + Name: "Remove reserved workflow_dispatch aw_context input", + Description: "Removes on.workflow_dispatch.inputs.aw_context, which is reserved and managed by the compiler.", + IntroducedIn: "1.0.0", + Apply: func(content string, frontmatter map[string]any) (string, bool, error) { + on, ok := frontmatter["on"].(map[string]any) + if !ok { + return content, false, nil + } + dispatch, ok := on["workflow_dispatch"].(map[string]any) + if !ok { + return content, false, nil + } + inputs, ok := dispatch["inputs"].(map[string]any) + if !ok { + return content, false, nil + } + if _, exists := inputs["aw_context"]; !exists { + return content, false, nil + } + + newContent, applied, err := removeYAMLMappingPath(content, []string{"on", "workflow_dispatch", "inputs", "aw_context"}, true) + if applied { + workflowDispatchAwContextCodemodLog.Print("Removed reserved on.workflow_dispatch.inputs.aw_context") + } + return newContent, applied, err + }, + } +} diff --git a/pkg/cli/codemod_workflow_dispatch_aw_context_test.go b/pkg/cli/codemod_workflow_dispatch_aw_context_test.go new file mode 100644 index 00000000000..e68910556e2 --- /dev/null +++ b/pkg/cli/codemod_workflow_dispatch_aw_context_test.go @@ -0,0 +1,270 @@ +//go:build !integration + +package cli + +import ( + "os" + "path/filepath" + "testing" + + "github.com/github/gh-aw/pkg/parser" + "github.com/github/gh-aw/pkg/workflow" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWorkflowDispatchAwContextRemovalCodemod_Metadata(t *testing.T) { + t.Parallel() + + codemod := getWorkflowDispatchAwContextRemovalCodemod() + assert.Equal(t, "workflow-dispatch-aw-context-removal", codemod.ID) + assert.NotEmpty(t, codemod.Name) + assert.NotEmpty(t, codemod.Description) + assert.Equal(t, "1.0.0", codemod.IntroducedIn) + require.NotNil(t, codemod.Apply) +} + +func TestWorkflowDispatchAwContextRemovalCodemod_Apply(t *testing.T) { + t.Parallel() + + content := `--- +on: + workflow_dispatch: + inputs: + aw_context: + type: string # reserved input + task: + type: string +engine: copilot +--- + +# Preserve the body. +` + frontmatter := map[string]any{ + "on": map[string]any{ + "workflow_dispatch": map[string]any{ + "inputs": map[string]any{ + "aw_context": map[string]any{"type": "string"}, + "task": map[string]any{"type": "string"}, + }, + }, + }, + "engine": "copilot", + } + + codemod := getWorkflowDispatchAwContextRemovalCodemod() + updated, applied, err := codemod.Apply(content, frontmatter) + require.NoError(t, err) + require.True(t, applied) + assert.Equal(t, `--- +on: + workflow_dispatch: + inputs: + task: + type: string +engine: copilot +--- + +# Preserve the body. +`, updated) + + updatedAgain, appliedAgain, err := codemod.Apply(updated, frontmatter) + require.NoError(t, err) + assert.False(t, appliedAgain) + assert.Equal(t, updated, updatedAgain) +} + +func TestWorkflowDispatchAwContextRemovalCodemod_AbsentOrOtherTriggerIsUnchanged(t *testing.T) { + t.Parallel() + + codemod := getWorkflowDispatchAwContextRemovalCodemod() + content := "---\non: workflow_call\n---\n" + frontmatter := map[string]any{ + "on": map[string]any{ + "workflow_call": map[string]any{ + "inputs": map[string]any{"aw_context": map[string]any{"type": "string"}}, + }, + }, + } + + updated, applied, err := codemod.Apply(content, frontmatter) + require.NoError(t, err) + assert.False(t, applied) + assert.Equal(t, content, updated) + + updated, applied, err = codemod.Apply(content, map[string]any{"on": map[string]any{"workflow_dispatch": nil}}) + require.NoError(t, err) + assert.False(t, applied) + assert.Equal(t, content, updated) +} + +func TestWorkflowDispatchAwContextRemovalCodemod_FixesStrictCompilation(t *testing.T) { + t.Parallel() + + content := `--- +on: + workflow_dispatch: + inputs: + aw_context: + type: string +strict: true +--- +Use caller context. +` + compiler := workflow.NewCompiler() + data, err := compiler.ParseWorkflowString(content, "aw-context.md") + require.NoError(t, err) + err = compiler.CompileWorkflowData(data, filepath.Join(t.TempDir(), "aw-context.md")) + require.ErrorContains(t, err, "on.workflow_dispatch.inputs.aw_context is reserved") + + path := filepath.Join(t.TempDir(), "aw-context.md") + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + fixed, _, err := processWorkflowFileWithInfo(path, GetAllCodemods(), true, false) + require.NoError(t, err) + require.True(t, fixed) + + updatedBytes, err := os.ReadFile(path) + require.NoError(t, err) + updated := string(updatedBytes) + compiler = workflow.NewCompiler() + data, err = compiler.ParseWorkflowString(updated, "aw-context.md") + require.NoError(t, err) + require.NoError(t, compiler.CompileWorkflowData(data, filepath.Join(t.TempDir(), "aw-context.md"))) +} + +func TestWorkflowDispatchAwContextRemovalCodemod_YAMLFormsAndLiteralText(t *testing.T) { + t.Parallel() + + codemod := getWorkflowDispatchAwContextRemovalCodemod() + tests := []struct { + name string + content string + expectedComment string + }{ + { + name: "flow mappings and quoted key", + content: `--- +on: {workflow_dispatch: {inputs: {'aw_context': {type: string}, task: {type: string}}}} +--- +`, + }, + { + name: "block mapping and description literal", + content: `--- +on: + workflow_dispatch: + inputs: + 'aw_context': + type: string + task: + description: | + aw_context: example + type: string # keep task comment +--- +`, + expectedComment: "# keep task comment", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + frontmatter, err := parser.ExtractFrontmatterFromContent(tt.content) + require.NoError(t, err) + updated, applied, err := codemod.Apply(tt.content, frontmatter.Frontmatter) + require.NoError(t, err) + require.True(t, applied) + + updatedFrontmatter, err := parser.ExtractFrontmatterFromContent(updated) + require.NoError(t, err) + dispatch := updatedFrontmatter.Frontmatter["on"].(map[string]any)["workflow_dispatch"].(map[string]any) + inputs := dispatch["inputs"].(map[string]any) + assert.NotContains(t, inputs, "aw_context") + assert.Contains(t, inputs, "task") + if task, ok := inputs["task"].(map[string]any); ok { + assert.Equal(t, frontmatter.Frontmatter["on"].(map[string]any)["workflow_dispatch"].(map[string]any)["inputs"].(map[string]any)["task"].(map[string]any)["description"], task["description"]) + } + if tt.expectedComment != "" { + assert.Contains(t, updated, tt.expectedComment) + } + }) + } +} + +func TestWorkflowDispatchAwContextRemovalCodemod_EmptyInputsPreserveComments(t *testing.T) { + t.Parallel() + + codemod := getWorkflowDispatchAwContextRemovalCodemod() + tests := []struct { + name string + content string + expectedComment string + }{ + { + name: "flow mapping with only reserved input", + content: `--- +on: {workflow_dispatch: {inputs: {aw_context: {type: string}}}} +--- + +# Workflow +`, + }, + { + name: "inline input header comment", + content: `--- +on: + workflow_dispatch: + inputs: # keep the input header comment + aw_context: + type: string +--- + +# Workflow +`, + expectedComment: "# keep the input header comment", + }, + { + name: "comment after reserved input", + content: `--- +on: + workflow_dispatch: + inputs: + aw_context: + type: string + # retain this comment +--- + +# Workflow +`, + expectedComment: "# retain this comment", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + frontmatter, err := parser.ExtractFrontmatterFromContent(tt.content) + require.NoError(t, err) + updated, applied, err := codemod.Apply(tt.content, frontmatter.Frontmatter) + require.NoError(t, err) + require.True(t, applied) + assert.Contains(t, updated, "inputs: {}") + if tt.expectedComment != "" { + assert.Contains(t, updated, tt.expectedComment) + } + + updatedFrontmatter, err := parser.ExtractFrontmatterFromContent(updated) + require.NoError(t, err) + dispatch := updatedFrontmatter.Frontmatter["on"].(map[string]any)["workflow_dispatch"].(map[string]any) + require.IsType(t, map[string]any{}, dispatch["inputs"]) + assert.Empty(t, dispatch["inputs"]) + + workflowPath := filepath.Join(t.TempDir(), "aw-context-empty-inputs.md") + require.NoError(t, os.WriteFile(workflowPath, []byte(updated), 0o600)) + compiler := workflow.NewCompiler() + data, err := compiler.ParseWorkflowFile(workflowPath) + require.NoError(t, err) + require.NoError(t, compiler.CompileWorkflowData(data, workflowPath)) + }) + } +} diff --git a/pkg/cli/codemod_yaml_mapping.go b/pkg/cli/codemod_yaml_mapping.go new file mode 100644 index 00000000000..80de53291a9 --- /dev/null +++ b/pkg/cli/codemod_yaml_mapping.go @@ -0,0 +1,233 @@ +package cli + +import ( + "bytes" + "errors" + "fmt" + "strings" + + "gopkg.in/yaml.v3" +) + +func removeYAMLMappingPath(content string, path []string, keepEmptyMapping bool) (string, bool, error) { + if len(path) < 2 { + return content, false, errors.New("YAML mapping path must contain at least two keys") + } + + targetKey := lastYAMLPathKey(path) + frontmatterYAML, suffix, err := splitFrontmatterForFormatting(content) + if err != nil { + return content, false, err + } + + var document yaml.Node + if err := yaml.Unmarshal([]byte(frontmatterYAML), &document); err != nil { + return content, false, fmt.Errorf("failed to parse frontmatter YAML: %w", err) + } + root := yamlDocumentRoot(&document) + if root == nil { + return content, false, nil + } + + mapping := root + mappingPath := []string{} + for _, key := range path[:len(path)-1] { + value, ok := findYAMLMappingValue(mapping, key) + if !ok || value.Kind != yaml.MappingNode { + return content, false, nil + } + mapping = value + mappingPath = append(mappingPath, key) + } + + keyIndex, keyNode, valueNode, ok := findYAMLMappingEntry(mapping, targetKey) + if !ok { + return content, false, nil + } + + for _, comment := range collectYAMLComments(keyNode, valueNode) { + preserveYAMLFootComment(mapping, comment) + } + removeYAMLMappingEntryAt(mapping, keyIndex) + + if err := normalizeEmptyYAMLMapping(root, mapping, mappingPath, keyNode, valueNode, keepEmptyMapping); err != nil { + return content, false, err + } + + updated, err := encodeUpdatedFrontmatter(content, suffix, &document) + if err != nil { + return content, false, err + } + return updated, true, nil +} + +func normalizeEmptyYAMLMapping(root, mapping *yaml.Node, path []string, removedKey, removedValue *yaml.Node, keepEmpty bool) error { + if len(mapping.Content) > 0 { + return nil + } + parentMapping, parentKeyNode, found := findYAMLMappingParent(root, path) + if !found { + return fmt.Errorf("unable to locate parent mapping for %q", lastYAMLPathKey(path)) + } + if keepEmpty || hasYAMLComments(mapping) || hasYAMLComments(removedKey) || hasYAMLComments(removedValue) || hasYAMLComments(parentKeyNode) { + preserveYAMLHeadComment(mapping, parentKeyNode.HeadComment) + preserveYAMLLineComment(mapping, parentKeyNode.LineComment) + preserveYAMLFootComment(mapping, parentKeyNode.FootComment) + mapping.Style |= yaml.FlowStyle + return nil + } + parentIndex, _, _, found := findYAMLMappingEntry(parentMapping, lastYAMLPathKey(path)) + if !found { + return fmt.Errorf("unable to locate parent mapping entry %q", lastYAMLPathKey(path)) + } + removeYAMLMappingEntryAt(parentMapping, parentIndex) + return nil +} + +func yamlDocumentRoot(document *yaml.Node) *yaml.Node { + for _, node := range document.Content { + if node.Kind == yaml.MappingNode { + return node + } + } + return nil +} + +func lastYAMLPathKey(path []string) string { + lastKey := "" + for _, key := range path { + lastKey = key + } + return lastKey +} + +func encodeUpdatedFrontmatter(content, suffix string, document *yaml.Node) (string, error) { + var output bytes.Buffer + encoder := yaml.NewEncoder(&output) + encoder.SetIndent(2) + if err := encoder.Encode(document); err != nil { + return "", fmt.Errorf("failed to encode updated frontmatter: %w", err) + } + if err := encoder.Close(); err != nil { + return "", fmt.Errorf("failed to encode updated frontmatter: %w", err) + } + firstNewline := strings.IndexByte(content, '\n') + if firstNewline < 0 { + return "", errors.New("unable to locate frontmatter text in workflow content") + } + return content[:firstNewline+1] + output.String() + "---" + suffix, nil +} + +func findYAMLMappingEntry(mapping *yaml.Node, key string) (int, *yaml.Node, *yaml.Node, bool) { + if mapping == nil || mapping.Kind != yaml.MappingNode { + return -1, nil, nil, false + } + entryIndex := 0 + var keyNode *yaml.Node + for index, node := range mapping.Content { + if keyNode == nil { + keyNode = node + entryIndex = index + continue + } + if keyNode.Kind == yaml.ScalarNode && keyNode.Value == key { + return entryIndex, keyNode, node, true + } + keyNode = nil + } + return -1, nil, nil, false +} + +func removeYAMLMappingEntryAt(mapping *yaml.Node, keyIndex int) { + if mapping == nil || keyIndex < 0 || keyIndex+1 >= len(mapping.Content) { + return + } + remaining := make([]*yaml.Node, 0, len(mapping.Content)-2) + for index, node := range mapping.Content { + if index == keyIndex || index == keyIndex+1 { + continue + } + remaining = append(remaining, node) + } + mapping.Content = remaining +} + +func findYAMLMappingValue(mapping *yaml.Node, key string) (*yaml.Node, bool) { + _, _, value, ok := findYAMLMappingEntry(mapping, key) + return value, ok +} + +func findYAMLMappingParent(root *yaml.Node, path []string) (*yaml.Node, *yaml.Node, bool) { + if len(path) == 0 { + return nil, nil, false + } + mapping := root + for _, key := range path[:len(path)-1] { + value, ok := findYAMLMappingValue(mapping, key) + if !ok || value.Kind != yaml.MappingNode { + return nil, nil, false + } + mapping = value + } + _, keyNode, _, ok := findYAMLMappingEntry(mapping, lastYAMLPathKey(path)) + return mapping, keyNode, ok +} + +func hasYAMLComments(node *yaml.Node) bool { + return node != nil && (node.HeadComment != "" || node.LineComment != "" || node.FootComment != "") +} + +func collectYAMLComments(nodes ...*yaml.Node) []string { + var comments []string + var visit func(*yaml.Node) + visit = func(node *yaml.Node) { + if node == nil { + return + } + for _, comment := range []string{node.HeadComment, node.FootComment} { + if comment != "" { + comments = append(comments, comment) + } + } + for _, child := range node.Content { + visit(child) + } + } + for _, node := range nodes { + visit(node) + } + return comments +} + +func preserveYAMLHeadComment(node *yaml.Node, comment string) { + if comment == "" { + return + } + if node.HeadComment == "" { + node.HeadComment = comment + return + } + node.HeadComment = comment + "\n" + node.HeadComment +} + +func preserveYAMLLineComment(node *yaml.Node, comment string) { + if comment == "" { + return + } + if node.LineComment == "" { + node.LineComment = comment + return + } + node.LineComment += " " + comment +} + +func preserveYAMLFootComment(node *yaml.Node, comment string) { + if comment == "" { + return + } + if node.FootComment == "" { + node.FootComment = comment + return + } + node.FootComment += "\n" + comment +} diff --git a/pkg/cli/fix_codemods.go b/pkg/cli/fix_codemods.go index 6c65fac32ef..8d666ba9ab9 100644 --- a/pkg/cli/fix_codemods.go +++ b/pkg/cli/fix_codemods.go @@ -62,6 +62,7 @@ func getEarlyCodemods() []Codemod { getNetworkFirewallCodemod(), getCommandToSlashCommandCodemod(), getWorkflowDispatchRequiredFalseCodemod(), // Set required: false for slash/label command triggers + getWorkflowDispatchAwContextRemovalCodemod(), getMCPScriptsModeCodemod(), getUploadAssetsCodemod(), getMigrateWritePermissionsToReadCodemod(), @@ -78,6 +79,7 @@ func getEarlyCodemods() []Codemod { getInstallScriptURLCodemod(), getBashAnonymousRemovalCodemod(), // Replace bash: with bash: false getBashSingleQuotedArgsCodemod(), // Rewrite single-quoted bash args to double-quoted form + getCopilotWebFetchRemovalCodemod(), // Remove native Copilot web-fetch, unavailable in offline BYOK mode getBashAllowlistUnsupportedEngineCodemod(), // Detect restricted tools.bash on engines that ignore it and emit guided error getActivationOutputsCodemod(), // Transform needs.activation.outputs.* to steps.sanitized.outputs.* getRolesToOnRolesCodemod(), // Move top-level roles to on.roles diff --git a/pkg/cli/fix_codemods_test.go b/pkg/cli/fix_codemods_test.go index 933019c2037..c50bdb29ff0 100644 --- a/pkg/cli/fix_codemods_test.go +++ b/pkg/cli/fix_codemods_test.go @@ -75,6 +75,7 @@ func TestGetAllCodemods_ContainsExpectedCodemods(t *testing.T) { "network-firewall-migration", "command-to-slash-command-migration", "workflow-dispatch-required-false-with-slash-command", + "workflow-dispatch-aw-context-removal", "mcp-scripts-mode-removal", "upload-assets-to-upload-asset-migration", "write-permissions-to-read-migration", @@ -132,6 +133,7 @@ func TestGetAllCodemods_ContainsExpectedCodemods(t *testing.T) { "sandbox-fields-kebab-case", "bash-single-quoted-args-rewrite", "bash-allowlist-unsupported-engine-guided-error", + "copilot-web-fetch-removal", "infer-to-disable-model-invocation", "run-install-scripts-to-runtimes-node", "mentions-allow-team-members-to-allowed-collaborators", @@ -199,6 +201,7 @@ func expectedCodemodOrder() []string { "network-firewall-migration", "command-to-slash-command-migration", "workflow-dispatch-required-false-with-slash-command", + "workflow-dispatch-aw-context-removal", "mcp-scripts-mode-removal", "upload-assets-to-upload-asset-migration", "write-permissions-to-read-migration", @@ -215,6 +218,7 @@ func expectedCodemodOrder() []string { "install-script-url-migration", "bash-anonymous-removal", "bash-single-quoted-args-rewrite", + "copilot-web-fetch-removal", "bash-allowlist-unsupported-engine-guided-error", "activation-outputs-to-sanitized-step", "roles-to-on-roles", diff --git a/pkg/workflow/engine_resolution.go b/pkg/workflow/engine_resolution.go new file mode 100644 index 00000000000..a49b75a1add --- /dev/null +++ b/pkg/workflow/engine_resolution.go @@ -0,0 +1,30 @@ +package workflow + +import ( + "errors" + "fmt" + "path/filepath" + + "github.com/github/gh-aw/pkg/parser" +) + +// ResolveEffectiveEngineConfig resolves engine settings from the workflow and its imports/includes. +func (c *Compiler) ResolveEffectiveEngineConfig(content, markdownPath string) (*EngineConfig, error) { + if markdownPath == "" { + return nil, errors.New("workflow path is required to resolve engine imports") + } + result, err := parser.ExtractFrontmatterFromContent(content) + if err != nil { + return nil, fmt.Errorf("failed to parse workflow frontmatter: %w", err) + } + + c.configureGHESCompatibility() + c.engineRegistry = NewEngineRegistry() + c.engineCatalog = NewEngineCatalog(c.engineRegistry) + cleanPath := filepath.Clean(markdownPath) + setup, err := c.setupEngineAndImports(result, cleanPath, []byte(content), filepath.Dir(cleanPath)) + if err != nil { + return nil, err + } + return setup.engineConfig, nil +}