Skip to content

Commit f46ecdb

Browse files
Copilotpelikhan
andauthored
Add codemod to migrate tools.serena to shared Serena import (#27403)
* feat: add serena tools-to-import codemod Agent-Logs-Url: https://github.com/github/gh-aw/sessions/4b18acd2-1622-4e97-8f1b-4960fd6ce6a9 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> * test: harden serena codemod assertions Agent-Logs-Url: https://github.com/github/gh-aw/sessions/4b18acd2-1622-4e97-8f1b-4960fd6ce6a9 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
1 parent e717787 commit f46ecdb

4 files changed

Lines changed: 428 additions & 1 deletion

File tree

pkg/cli/codemod_serena_import.go

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
package cli
2+
3+
import (
4+
"fmt"
5+
"slices"
6+
"sort"
7+
"strings"
8+
9+
"github.com/github/gh-aw/pkg/logger"
10+
"github.com/github/gh-aw/pkg/sliceutil"
11+
)
12+
13+
var serenaImportCodemodLog = logger.New("cli:codemod_serena_import")
14+
15+
// getSerenaToSharedImportCodemod creates a codemod that migrates removed tools.serena
16+
// configuration to an equivalent imports entry using shared/mcp/serena.md.
17+
func getSerenaToSharedImportCodemod() Codemod {
18+
return Codemod{
19+
ID: "serena-tools-to-shared-import",
20+
Name: "Migrate tools.serena to shared Serena import",
21+
Description: "Removes 'tools.serena' and adds an equivalent 'imports' entry using shared/mcp/serena.md with languages.",
22+
IntroducedIn: "1.0.0",
23+
Apply: func(content string, frontmatter map[string]any) (string, bool, error) {
24+
toolsAny, hasTools := frontmatter["tools"]
25+
if !hasTools {
26+
return content, false, nil
27+
}
28+
29+
toolsMap, ok := toolsAny.(map[string]any)
30+
if !ok {
31+
return content, false, nil
32+
}
33+
34+
serenaAny, hasSerena := toolsMap["serena"]
35+
if !hasSerena {
36+
return content, false, nil
37+
}
38+
39+
languages, ok := extractSerenaLanguages(serenaAny)
40+
if !ok || len(languages) == 0 {
41+
serenaImportCodemodLog.Print("Found tools.serena but languages configuration is invalid or empty - skipping migration; verify tools.serena languages are set")
42+
return content, false, nil
43+
}
44+
45+
alreadyImported := hasSerenaSharedImport(frontmatter)
46+
47+
newContent, applied, err := applyFrontmatterLineTransform(content, func(lines []string) ([]string, bool) {
48+
result, modified := removeFieldFromBlock(lines, "serena", "tools")
49+
if !modified {
50+
return lines, false
51+
}
52+
53+
result = removeTopLevelBlockIfEmpty(result, "tools")
54+
55+
if alreadyImported {
56+
return result, true
57+
}
58+
59+
return addSerenaImport(result, languages), true
60+
})
61+
if applied {
62+
if alreadyImported {
63+
serenaImportCodemodLog.Print("Removed tools.serena (shared/mcp/serena.md import already present)")
64+
} else {
65+
serenaImportCodemodLog.Printf("Migrated tools.serena to shared/mcp/serena.md import with %d language(s)", len(languages))
66+
}
67+
}
68+
return newContent, applied, err
69+
},
70+
}
71+
}
72+
73+
func extractSerenaLanguages(serenaAny any) ([]string, bool) {
74+
switch serena := serenaAny.(type) {
75+
case []string:
76+
return sliceutil.Deduplicate(serena), len(serena) > 0
77+
case []any:
78+
var languages []string
79+
for _, item := range serena {
80+
lang, ok := item.(string)
81+
if ok && strings.TrimSpace(lang) != "" {
82+
languages = append(languages, lang)
83+
}
84+
}
85+
return sliceutil.Deduplicate(languages), len(languages) > 0
86+
case string:
87+
trimmed := strings.TrimSpace(serena)
88+
if trimmed == "" {
89+
return nil, false
90+
}
91+
return []string{trimmed}, true
92+
case map[string]any:
93+
languagesAny, hasLanguages := serena["languages"]
94+
if !hasLanguages {
95+
return nil, false
96+
}
97+
return extractSerenaLanguagesFromLanguagesField(languagesAny)
98+
default:
99+
return nil, false
100+
}
101+
}
102+
103+
func extractSerenaLanguagesFromLanguagesField(languagesAny any) ([]string, bool) {
104+
switch languages := languagesAny.(type) {
105+
case []string:
106+
return sliceutil.Deduplicate(languages), len(languages) > 0
107+
case []any:
108+
var result []string
109+
for _, item := range languages {
110+
lang, ok := item.(string)
111+
if ok && strings.TrimSpace(lang) != "" {
112+
result = append(result, lang)
113+
}
114+
}
115+
return sliceutil.Deduplicate(result), len(result) > 0
116+
case string:
117+
trimmed := strings.TrimSpace(languages)
118+
if trimmed == "" {
119+
return nil, false
120+
}
121+
return []string{trimmed}, true
122+
case map[string]any:
123+
var result []string
124+
for language := range languages {
125+
if strings.TrimSpace(language) != "" {
126+
result = append(result, language)
127+
}
128+
}
129+
sort.Strings(result)
130+
return sliceutil.Deduplicate(result), len(result) > 0
131+
default:
132+
return nil, false
133+
}
134+
}
135+
136+
func hasSerenaSharedImport(frontmatter map[string]any) bool {
137+
importsAny, hasImports := frontmatter["imports"]
138+
if !hasImports {
139+
return false
140+
}
141+
142+
switch imports := importsAny.(type) {
143+
case []string:
144+
return slices.ContainsFunc(imports, isSerenaImportPath)
145+
case []any:
146+
for _, entry := range imports {
147+
switch typed := entry.(type) {
148+
case string:
149+
if isSerenaImportPath(typed) {
150+
return true
151+
}
152+
case map[string]any:
153+
usesAny, hasUses := typed["uses"]
154+
if !hasUses {
155+
continue
156+
}
157+
uses, ok := usesAny.(string)
158+
if ok && isSerenaImportPath(uses) {
159+
return true
160+
}
161+
}
162+
}
163+
}
164+
165+
return false
166+
}
167+
168+
func isSerenaImportPath(path string) bool {
169+
trimmed := strings.TrimSpace(path)
170+
return trimmed == "shared/mcp/serena.md" || trimmed == "shared/mcp/serena"
171+
}
172+
173+
func addSerenaImport(lines []string, languages []string) []string {
174+
entry := []string{
175+
" - uses: shared/mcp/serena.md",
176+
" with:",
177+
" languages: " + formatStringArrayInline(languages),
178+
}
179+
180+
importsIdx := -1
181+
importsEnd := len(lines)
182+
for i, line := range lines {
183+
trimmed := strings.TrimSpace(line)
184+
if isTopLevelKey(line) && strings.HasPrefix(trimmed, "imports:") {
185+
importsIdx = i
186+
for j := i + 1; j < len(lines); j++ {
187+
if isTopLevelKey(lines[j]) {
188+
importsEnd = j
189+
break
190+
}
191+
}
192+
break
193+
}
194+
}
195+
196+
if importsIdx >= 0 {
197+
result := make([]string, 0, len(lines)+len(entry))
198+
result = append(result, lines[:importsEnd]...)
199+
result = append(result, entry...)
200+
result = append(result, lines[importsEnd:]...)
201+
return result
202+
}
203+
204+
insertAt := 0
205+
for i, line := range lines {
206+
if isTopLevelKey(line) && strings.HasPrefix(strings.TrimSpace(line), "engine:") {
207+
insertAt = i + 1
208+
break
209+
}
210+
}
211+
212+
importBlock := make([]string, 0, 1+len(entry))
213+
importBlock = append(importBlock, "imports:")
214+
importBlock = append(importBlock, entry...)
215+
216+
result := make([]string, 0, len(lines)+len(importBlock))
217+
result = append(result, lines[:insertAt]...)
218+
result = append(result, importBlock...)
219+
result = append(result, lines[insertAt:]...)
220+
return result
221+
}
222+
223+
func formatStringArrayInline(values []string) string {
224+
quoted := make([]string, 0, len(values))
225+
for _, value := range values {
226+
quoted = append(quoted, fmt.Sprintf("%q", value))
227+
}
228+
return "[" + strings.Join(quoted, ", ") + "]"
229+
}
230+
231+
func removeTopLevelBlockIfEmpty(lines []string, blockName string) []string {
232+
blockIdx := -1
233+
blockEnd := len(lines)
234+
for i, line := range lines {
235+
if isTopLevelKey(line) && strings.HasPrefix(strings.TrimSpace(line), blockName+":") {
236+
blockIdx = i
237+
for j := i + 1; j < len(lines); j++ {
238+
if isTopLevelKey(lines[j]) {
239+
blockEnd = j
240+
break
241+
}
242+
}
243+
break
244+
}
245+
}
246+
247+
if blockIdx == -1 {
248+
return lines
249+
}
250+
251+
hasMeaningfulNestedContent := false
252+
for _, line := range lines[blockIdx+1 : blockEnd] {
253+
trimmed := strings.TrimSpace(line)
254+
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
255+
continue
256+
}
257+
hasMeaningfulNestedContent = true
258+
break
259+
}
260+
261+
if hasMeaningfulNestedContent {
262+
return lines
263+
}
264+
265+
result := make([]string, 0, len(lines)-(blockEnd-blockIdx))
266+
result = append(result, lines[:blockIdx]...)
267+
result = append(result, lines[blockEnd:]...)
268+
return result
269+
}

0 commit comments

Comments
 (0)