Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
48 changes: 48 additions & 0 deletions pkg/cli/codemod_copilot_web_fetch.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
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) {
if extractEngineIDFromFrontmatter(frontmatter) != "copilot" {
return content, false, nil
}

_, engineConfig, _ := (&workflow.Compiler{}).ExtractEngineConfig(frontmatter)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Resolved the effective engine and SDK settings through ApplyWithContext; when resolution fails, the codemod now preserves tools.web-fetch. Added import/include coverage in daa2f746.

if engineConfig != nil && engineConfig.CopilotSDK {
return content, false, nil
}

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
}

newContent, applied, err := applyFrontmatterLineTransform(content, func(lines []string) ([]string, bool) {
result, modified := removeFieldFromBlock(lines, "web-fetch", "tools")

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Both removals now edit YAML mapping nodes, covering flow maps and quoted keys while retaining siblings. Regression cases were added in daa2f746.

return result, modified
})
if applied {
copilotWebFetchCodemodLog.Print("Removed unsupported tools.web-fetch for Copilot CLI")
}
return newContent, applied, err
},
}
}
139 changes: 139 additions & 0 deletions pkg/cli/codemod_copilot_web_fetch_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
//go:build !integration

package cli

import (
"os"
"path/filepath"
"testing"

"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)
}

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")))
}
119 changes: 119 additions & 0 deletions pkg/cli/codemod_workflow_dispatch_aw_context.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package cli

import (
"strings"

"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 := applyFrontmatterLineTransform(content, func(lines []string) ([]string, bool) {
onLine := findFrontmatterKeyLine(lines, "on", 0, len(lines), -1)
if onLine < 0 || onLine >= len(lines) {
return lines, false
}
onEnd := frontmatterBlockEnd(lines, onLine, len(getIndentation(lines[onLine])))
onIndent := len(getIndentation(lines[onLine]))
dispatchLine := findFrontmatterKeyLine(lines, "workflow_dispatch", onLine+1, onEnd, onIndent)
if dispatchLine < 0 || dispatchLine >= len(lines) {
return lines, false
}
dispatchEnd := frontmatterBlockEnd(lines, dispatchLine, len(getIndentation(lines[dispatchLine])))
block, modified := removeFieldFromBlock(lines[dispatchLine:dispatchEnd], "aw_context", "inputs")

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The removals now follow the actual YAML mapping path, so matching text inside block scalars remains untouched. Added literal-text regression tests in daa2f746.

if !modified {
return lines, false
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

When the reserved input is the only entry, the codemod now leaves inputs: {} and preserves header/surviving comments. Added compiler-valid regression cases in daa2f746.

result := make([]string, 0, len(lines)-(dispatchEnd-dispatchLine)+len(block))
result = append(result, lines[:dispatchLine]...)
result = append(result, block...)
result = append(result, lines[dispatchEnd:]...)
return result, true
})
if applied {
workflowDispatchAwContextCodemodLog.Print("Removed reserved on.workflow_dispatch.inputs.aw_context")
}
return newContent, applied, err
},
}
}

func findFrontmatterKeyLine(lines []string, key string, start, end, parentIndent int) int {
childIndent := -1
for i, line := range lines {
if i < start || i >= end {
continue
}
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
indent := len(getIndentation(line))
if parentIndent >= 0 {
if indent <= parentIndent {
continue
}
if childIndent < 0 {
childIndent = indent
}
if indent < childIndent {
return -1
}
if indent > childIndent {
continue
}
}
if frontmatterLineKey(line) == key {
return i
}
}
return -1
}

func frontmatterBlockEnd(lines []string, start, parentIndent int) int {
for i, line := range lines {
if i <= start {
continue
}
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
if len(getIndentation(line)) <= parentIndent {
return i
}
}
return len(lines)
}

func frontmatterLineKey(line string) string {
trimmed := strings.TrimSpace(line)
key, _, found := strings.Cut(trimmed, ":")
if !found {
return ""
}
return strings.Trim(key, `"'`)
}
Loading
Loading