Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
155 changes: 152 additions & 3 deletions pkg/cli/codemod_serena_import.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,27 @@ func getSerenaToSharedImportCodemod() Codemod {
IntroducedIn: "1.0.0",
Apply: func(content string, frontmatter map[string]any) (string, bool, error) {
languages, ok := findSerenaLanguagesForMigration(frontmatter)
isListForm := false
if !ok || len(languages) == 0 {
return content, false, nil
// Check if tools is a list containing "serena" (no languages specified)
if !isSerenaInToolsList(frontmatter) {
return content, false, nil
}
// List form detected — migrate with empty placeholder (languages stays nil/empty)
isListForm = true
}

alreadyImported := hasSerenaSharedImport(frontmatter)

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

if isListForm {
result, modified = removeSerenaFromToolsList(lines)
} else {
result, modified = removeFieldFromBlock(lines, "serena", "tools")
}
if !modified {
return lines, false
}
Expand All @@ -47,6 +60,8 @@ func getSerenaToSharedImportCodemod() Codemod {
if applied {
if alreadyImported {
serenaImportCodemodLog.Print("Removed tools.serena (shared/mcp/serena.md import already present)")
} else if isListForm {
serenaImportCodemodLog.Print("Migrated tools list entry 'serena' to shared/mcp/serena.md import (no languages specified — placeholder added)")
} else {
serenaImportCodemodLog.Printf("Migrated tools.serena to shared/mcp/serena.md import with %d language(s)", len(languages))
}
Expand Down Expand Up @@ -102,6 +117,131 @@ func findSerenaLanguagesForMigration(frontmatter map[string]any) ([]string, bool
return languages, true
}

// isSerenaInToolsList reports whether the "tools" frontmatter key is a YAML list that
// contains the plain string "serena". This covers the shorthand syntax:
//
// tools:
// - serena
//
// which does not carry a languages specification.
func isSerenaInToolsList(frontmatter map[string]any) bool {
toolsAny, hasTools := frontmatter["tools"]
if !hasTools {
return false
}
switch tools := toolsAny.(type) {
case []string:
for _, item := range tools {
if strings.EqualFold(strings.TrimSpace(item), "serena") {
return true
}
}
case []any:
for _, item := range tools {
s, ok := item.(string)
if ok && strings.EqualFold(strings.TrimSpace(s), "serena") {
return true
}
}
}
return false
}

// removeSerenaFromToolsList removes the string item "serena" from the "tools:" YAML list.
// It handles both block form (tools:\n - serena) and inline form (tools: [serena]).
// When "serena" is the only item in an inline list, the entire "tools:" line is removed
// so that removeBlockIfEmpty can clean up the empty block.
// Returns the modified lines and whether any changes were made.
func removeSerenaFromToolsList(lines []string) ([]string, bool) {
result := make([]string, 0, len(lines))
modified := false
inToolsBlock := false
toolsIndent := ""

for _, line := range lines {
trimmed := strings.TrimSpace(line)

// Detect the "tools:" top-level key.
if isTopLevelKey(line) && strings.HasPrefix(trimmed, "tools:") {
valuePart := strings.TrimSpace(trimmed[len("tools:"):])

// Inline list form: tools: [serena] or tools: [serena, other]
if strings.HasPrefix(valuePart, "[") {
newValue, changed := removeSerenaFromInlineList(valuePart)
if changed {
modified = true
if newValue == "[]" {
// Empty inline list — skip the line; removeBlockIfEmpty will
// drop the tools: block because no child lines follow.
continue
}
result = append(result, "tools: "+newValue)
} else {
result = append(result, line)
}
continue
}

// Block form: tools:\n - serena
inToolsBlock = true
toolsIndent = getIndentation(line)
result = append(result, line)
continue
}

// Track block exit.
if inToolsBlock && len(trimmed) > 0 && !strings.HasPrefix(trimmed, "#") {
if hasExitedBlock(line, toolsIndent) {
inToolsBlock = false
}
}

// Remove "- serena" list items (with or without quotes).
if inToolsBlock {
bare := strings.TrimPrefix(trimmed, "- ")
bare = strings.Trim(bare, "\"'")
if strings.EqualFold(bare, "serena") {
modified = true
continue
}
}
Comment on lines +164 to +207
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

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

removeSerenaFromToolsList / removeSerenaFromInlineList won’t migrate valid YAML when list items or the inline list have trailing comments. Examples that currently won’t be modified: tools: [serena] # comment (fails the HasSuffix(value, "]") check) and - serena # comment (the item compare includes the comment text). This can cause tools: [serena] repos to still be silently skipped.

Consider stripping a trailing YAML comment (e.g., split on # when it’s not inside quotes) before parsing the inline list, and when matching block list items, compare only the scalar value before any comment.

Copilot uses AI. Check for mistakes.

result = append(result, line)
}

return result, modified
}

// removeSerenaFromInlineList removes "serena" from a YAML inline list string such as
// "[serena]" or "[serena, other]". Returns the new value and whether a change was made.
func removeSerenaFromInlineList(value string) (string, bool) {
if !strings.HasPrefix(value, "[") || !strings.HasSuffix(value, "]") {
return value, false
}
inner := value[1 : len(value)-1]
parts := strings.Split(inner, ",")
kept := make([]string, 0, len(parts))
modified := false
for _, part := range parts {
trimmedPart := strings.TrimSpace(part)
unquoted := strings.Trim(trimmedPart, "\"'")
if strings.EqualFold(unquoted, "serena") {
modified = true
continue
}
if trimmedPart != "" {
kept = append(kept, trimmedPart)
}
}
if !modified {
return value, false
}
if len(kept) == 0 {
return "[]", true
}
return "[" + strings.Join(kept, ", ") + "]", true
}

func extractSerenaLanguages(serenaAny any) ([]string, bool) {
switch serena := serenaAny.(type) {
case []string:
Expand Down Expand Up @@ -203,10 +343,19 @@ func isSerenaImportPath(path string) bool {
}

func addSerenaImport(lines []string, languages []string) []string {
var langLine string
if len(languages) == 0 {
// No languages were specified in the original workflow. Emit a placeholder so
// the user knows what to fill in. The empty array is valid per the import-schema
// (the field is present); Serena simply won't analyse any language until updated.
langLine = ` languages: [] # TODO: specify languages, e.g. ["TypeScript", "JavaScript"]`
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

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

The generated placeholder comment suggests languages values like "TypeScript"/"JavaScript", but existing docs/examples in this repo use lowercase identifiers (e.g. .github/workflows/shared/mcp/serena.md shows languages: ["go", "typescript"]). If the values are case-sensitive (or users copy/paste), this could lead to confusion.

Recommend updating the TODO example to use the same lowercase identifiers used elsewhere (e.g. "typescript", "javascript").

Suggested change
langLine = ` languages: [] # TODO: specify languages, e.g. ["TypeScript", "JavaScript"]`
langLine = ` languages: [] # TODO: specify languages, e.g. ["typescript", "javascript"]`

Copilot uses AI. Check for mistakes.
} else {
langLine = " languages: " + formatStringArrayInline(languages)
}
entry := []string{
" - uses: shared/mcp/serena.md",
" with:",
" languages: " + formatStringArrayInline(languages),
langLine,
}

importsIdx := -1
Expand Down
102 changes: 102 additions & 0 deletions pkg/cli/codemod_serena_import_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -310,4 +310,106 @@ tools:
assert.Contains(t, result, "- uses: shared/mcp/serena.md", "Codemod should add shared Serena import")
assert.Contains(t, result, "languages: [\"typescript\"]", "Codemod should use languages from engine.tools.serena")
})

t.Run("migrates tools list form (tools: [serena]) with empty languages placeholder", func(t *testing.T) {
content := `---
engine: copilot
tools:
- serena
strict: false
---

# Test Workflow
`
frontmatter := map[string]any{
"engine": "copilot",
"tools": []any{"serena"},
"strict": false,
}

result, applied, err := codemod.Apply(content, frontmatter)
require.NoError(t, err, "Codemod should not return an error")
assert.True(t, applied, "Codemod should be applied for list-form tools: [serena]")
assert.NotContains(t, result, "- serena", "Codemod should remove 'serena' from tools list")
assert.Contains(t, result, "imports:", "Codemod should add imports block")
assert.Contains(t, result, "- uses: shared/mcp/serena.md", "Codemod should add Serena shared import")
assert.Contains(t, result, "languages: []", "Codemod should emit empty languages placeholder")
assert.Contains(t, result, "TODO", "Codemod should include TODO comment for languages")

parsed, parseErr := parser.ExtractFrontmatterFromContent(result)
require.NoError(t, parseErr, "Result should contain valid frontmatter")
_, hasTools := parsed.Frontmatter["tools"]
assert.False(t, hasTools, "Codemod should remove the now-empty tools block")
})

t.Run("migrates tools inline list form (tools: [serena]) with empty languages placeholder", func(t *testing.T) {
content := `---
engine: copilot
tools: [serena]
strict: false
---

# Test Workflow
`
frontmatter := map[string]any{
"engine": "copilot",
"tools": []any{"serena"},
"strict": false,
}

result, applied, err := codemod.Apply(content, frontmatter)
require.NoError(t, err, "Codemod should not return an error")
assert.True(t, applied, "Codemod should be applied for inline list-form tools: [serena]")
assert.Contains(t, result, "imports:", "Codemod should add imports block")
assert.Contains(t, result, "- uses: shared/mcp/serena.md", "Codemod should add Serena shared import")
assert.Contains(t, result, "languages: []", "Codemod should emit empty languages placeholder")
assert.NotContains(t, result, "tools: [serena]", "Codemod should remove the inline serena entry")
})

t.Run("migrates tools list form with other tools preserved", func(t *testing.T) {
content := `---
engine: copilot
tools:
- serena
- playwright
strict: false
---

# Test Workflow
`
frontmatter := map[string]any{
"engine": "copilot",
"tools": []any{"serena", "playwright"},
"strict": false,
}

result, applied, err := codemod.Apply(content, frontmatter)
require.NoError(t, err, "Codemod should not return an error")
assert.True(t, applied, "Codemod should be applied when serena is in a list with other tools")
assert.NotContains(t, result, "- serena", "Codemod should remove 'serena' from the tools list")
assert.Contains(t, result, "- playwright", "Codemod should preserve other tools list items")
assert.Contains(t, result, "tools:", "Codemod should preserve the non-empty tools block")
assert.Contains(t, result, "- uses: shared/mcp/serena.md", "Codemod should add Serena shared import")
assert.Contains(t, result, "languages: []", "Codemod should emit empty languages placeholder")
})

t.Run("does not modify workflows with tools list that does not contain serena", func(t *testing.T) {
content := `---
engine: copilot
tools:
- playwright
---

# Test Workflow
`
frontmatter := map[string]any{
"engine": "copilot",
"tools": []any{"playwright"},
}

result, applied, err := codemod.Apply(content, frontmatter)
require.NoError(t, err, "Codemod should not return an error")
assert.False(t, applied, "Codemod should not be applied when tools list does not contain serena")
assert.Equal(t, content, result, "Content should remain unchanged")
})
}
13 changes: 13 additions & 0 deletions pkg/cli/fix_command.go
Original file line number Diff line number Diff line change
Expand Up @@ -339,8 +339,21 @@ func processWorkflowFileWithInfo(filePath string, codemods []Codemod, write bool
}

const scaffoldedSerenaSharedWorkflow = `---
import-schema:
languages:
type: array
items:
type: string
required: true
description: >
List of programming language identifiers to enable for Serena LSP analysis.
Supported values include: go, typescript, javascript, python, rust, java,
ruby, csharp, cpp, c, kotlin, scala, swift, php, and more.

imports:
- uses: github/gh-aw/.github/workflows/shared/mcp/serena.md@main
with:
languages: ${{ github.aw.import-inputs.languages }}
---
`

Expand Down
16 changes: 16 additions & 0 deletions pkg/cli/fix_command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -957,4 +957,20 @@ tools:
if !strings.Contains(scaffolded, "github/gh-aw/.github/workflows/shared/mcp/serena.md@main") {
t.Errorf("Expected scaffolded Serena workflow to import upstream shared workflow, got:\n%s", scaffolded)
}

// The scaffolded file must declare import-schema so that the caller's
// "languages" input is accepted and can be forwarded to the upstream.
if !strings.Contains(scaffolded, "import-schema:") {
t.Errorf("Expected scaffolded Serena workflow to declare import-schema, got:\n%s", scaffolded)
}
if !strings.Contains(scaffolded, "languages:") {
t.Errorf("Expected scaffolded Serena workflow to declare 'languages' in import-schema, got:\n%s", scaffolded)
}
Comment on lines +963 to +968
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

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

These assertions don’t strictly verify that languages is declared under import-schema; strings.Contains(scaffolded, "languages:") could be satisfied solely by the with: languages: pass-through. To make the test resilient against regressions (e.g., import-schema: present but missing languages), consider matching a more specific substring like "import-schema:\n languages:" (or parse the frontmatter and assert the schema structure).

Suggested change
if !strings.Contains(scaffolded, "import-schema:") {
t.Errorf("Expected scaffolded Serena workflow to declare import-schema, got:\n%s", scaffolded)
}
if !strings.Contains(scaffolded, "languages:") {
t.Errorf("Expected scaffolded Serena workflow to declare 'languages' in import-schema, got:\n%s", scaffolded)
}
if !strings.Contains(scaffolded, "import-schema:\n languages:") {
t.Errorf("Expected scaffolded Serena workflow to declare 'languages' under import-schema, got:\n%s", scaffolded)
}

Copilot uses AI. Check for mistakes.

// The scaffolded file must forward the languages input to the upstream import via
// ${{ github.aw.import-inputs.languages }}, otherwise compilation fails with:
// "required 'with' input 'languages' is missing (declared in import-schema)".
if !strings.Contains(scaffolded, "github.aw.import-inputs.languages") {
t.Errorf("Expected scaffolded Serena workflow to pass languages through to upstream import, got:\n%s", scaffolded)
}
}
Loading