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

import (
"strings"

"github.com/github/gh-aw/pkg/logger"
)

var engineStepsCodemodLog = logger.New("cli:codemod_engine_steps")

// getEngineStepsToTopLevelCodemod creates a codemod for moving engine.steps to the top-level steps field
func getEngineStepsToTopLevelCodemod() Codemod {
return Codemod{
ID: "engine-steps-to-top-level",
Name: "Move engine.steps to top-level steps",
Description: "Moves the 'steps' field from under 'engine' to the top-level 'steps' field, as 'engine.steps' is no longer supported",
IntroducedIn: "0.11.0",
Apply: func(content string, frontmatter map[string]any) (string, bool, error) {
// Check if engine.steps exists in frontmatter
engineValue, hasEngine := frontmatter["engine"]
if !hasEngine {
return content, false, nil
}

engineMap, isMap := engineValue.(map[string]any)
if !isMap {
// engine is a string, no steps to move
return content, false, nil
}

if _, hasSteps := engineMap["steps"]; !hasSteps {
return content, false, nil
}

// Parse frontmatter lines
frontmatterLines, markdown, err := parseFrontmatterLines(content)
if err != nil {
return content, false, err
}

// Find engine block and the steps field within it
engineIndent := ""
stepsStartIdx := -1
inEngineBlock := false

for i, line := range frontmatterLines {
trimmed := strings.TrimSpace(line)

if isTopLevelKey(line) && strings.HasPrefix(trimmed, "engine:") {
engineIndent = getIndentation(line)
inEngineBlock = true
engineStepsCodemodLog.Printf("Found 'engine:' block at line %d", i+1)
continue
}

// Check if we've exited the engine block
if inEngineBlock && len(trimmed) > 0 && !strings.HasPrefix(trimmed, "#") {
lineIndent := getIndentation(line)
if len(lineIndent) <= len(engineIndent) {
inEngineBlock = false
}
}

// Look for steps: within engine block
if inEngineBlock && stepsStartIdx == -1 && strings.HasPrefix(trimmed, "steps:") {
stepsStartIdx = i
engineStepsCodemodLog.Printf("Found 'engine.steps' at line %d", i+1)
}
}

if stepsStartIdx == -1 {
return content, false, nil
}

// Find end of the steps block within engine
stepsIndent := getIndentation(frontmatterLines[stepsStartIdx])
stepsEndIdx := stepsStartIdx
for j := stepsStartIdx + 1; j < len(frontmatterLines); j++ {
line := frontmatterLines[j]
trimmed := strings.TrimSpace(line)

if len(trimmed) == 0 {
continue
}

lineIndent := getIndentation(line)
if len(lineIndent) > len(stepsIndent) {
stepsEndIdx = j
} else {
break
}
}

engineStepsCodemodLog.Printf("'engine.steps' spans lines %d to %d", stepsStartIdx+1, stepsEndIdx+1)

// Extract the steps lines and un-indent them (remove the engine-level indentation)
topLevelStepsLines := make([]string, 0, stepsEndIdx-stepsStartIdx+1)
for i := stepsStartIdx; i <= stepsEndIdx; i++ {
line := frontmatterLines[i]
trimmed := strings.TrimSpace(line)
if trimmed == "" {
topLevelStepsLines = append(topLevelStepsLines, "")
continue
}
// Strip the stepsIndent prefix to un-indent to top level
if strings.HasPrefix(line, stepsIndent) {
topLevelStepsLines = append(topLevelStepsLines, line[len(stepsIndent):])
} else {
topLevelStepsLines = append(topLevelStepsLines, trimmed)
}
}

// Find existing top-level steps block (if any)
// Only treat as existing steps if it's actually a sequence
topLevelStepsEndIdx := -1
hasTopLevelSteps := false
if stepsVal, exists := frontmatter["steps"]; exists {
if _, isSlice := stepsVal.([]any); isSlice {
hasTopLevelSteps = true
engineStepsCodemodLog.Print("Found existing top-level 'steps'")
} else {
engineStepsCodemodLog.Print("Top-level 'steps' exists but is not a sequence; treating as absent")
}
}

if hasTopLevelSteps {
// Find the end of the top-level steps block in the lines
for i, line := range frontmatterLines {
Comment on lines 113 to 128
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

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

When merging into an existing top-level steps, the codemod only checks frontmatter["steps"] for presence, but doesn’t verify it’s actually a YAML sequence. If steps exists but is a scalar/object (invalid but possible in real files), this code will append list items after steps: <value>, producing invalid YAML. Consider type-checking frontmatter["steps"] as []any (or verifying the steps: line is followed by indented list items) before merging; otherwise, treat it as no top-level steps (insert a fresh steps: block) or return a clear error.

Copilot uses AI. Check for mistakes.
trimmed := strings.TrimSpace(line)
if isTopLevelKey(line) && strings.HasPrefix(trimmed, "steps:") {
topStepsIndent := getIndentation(line)
topLevelStepsEndIdx = i
for j := i + 1; j < len(frontmatterLines); j++ {
l := frontmatterLines[j]
t := strings.TrimSpace(l)
if len(t) == 0 {
continue
}
if len(getIndentation(l)) > len(topStepsIndent) {
topLevelStepsEndIdx = j
} else {
break
}
}
engineStepsCodemodLog.Printf("Top-level 'steps:' ends at line %d", topLevelStepsEndIdx+1)
break
}
}
}

// Build new frontmatter: remove engine.steps lines and insert at top level
// Pass 1: build lines without engine.steps
withoutEngineSteps := make([]string, 0, len(frontmatterLines))
for i, line := range frontmatterLines {
if i >= stepsStartIdx && i <= stepsEndIdx {
continue
}
withoutEngineSteps = append(withoutEngineSteps, line)
}

Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

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

The codemod removes the engine.steps lines but leaves the engine: key intact. If a workflow has an engine: block that only contained steps (or only comments besides steps), the result will include a dangling engine: with a null value, which is likely invalid per the workflow schema. Consider detecting when the engine block becomes empty after removal and removing the now-empty engine: key (and any adjacent blank lines) as part of the migration.

Suggested change
// Pass 1b: if the engine block is now empty (only comments/blank lines), remove it
cleanedWithoutEngine := make([]string, 0, len(withoutEngineSteps))
for i := 0; i < len(withoutEngineSteps); {
line := withoutEngineSteps[i]
trimmed := strings.TrimSpace(line)
// Look for a top-level "engine:" key
if isTopLevelKey(line) && strings.HasPrefix(trimmed, "engine:") {
engineIndent := getIndentation(line)
startIdx := i
endIdx := i
j := i + 1
onlyCommentsOrBlank := true
// Walk the lines belonging to the engine block
for ; j < len(withoutEngineSteps); j++ {
l := withoutEngineSteps[j]
t := strings.TrimSpace(l)
// Stop at the next top-level key (same or smaller indentation)
if isTopLevelKey(l) && len(getIndentation(l)) <= len(engineIndent) && t != "" {
break
}
// Track the furthest extent of the engine block
endIdx = j
// Consider only blank lines or pure comments as "empty"
if t == "" || strings.HasPrefix(t, "#") {
continue
}
// Found non-comment, non-blank content under engine
onlyCommentsOrBlank = false
break
}
if onlyCommentsOrBlank {
// Remove engine block and any adjacent blank lines around it
removeStart := startIdx
for removeStart-1 >= 0 && strings.TrimSpace(withoutEngineSteps[removeStart-1]) == "" {
removeStart--
}
removeEnd := endIdx
for k := endIdx + 1; k < len(withoutEngineSteps) && strings.TrimSpace(withoutEngineSteps[k]) == ""; k++ {
removeEnd = k
}
engineStepsCodemodLog.Print("Removed empty 'engine' block after migrating 'engine.steps'")
// Skip over the removed region
i = removeEnd + 1
// Ensure we don't drop lines already added before removeStart
continue
}
}
// Keep the current line
cleanedWithoutEngine = append(cleanedWithoutEngine, line)
i++
}
withoutEngineSteps = cleanedWithoutEngine

Copilot uses AI. Check for mistakes.
// Pass 1b: if the engine block is now empty (only blank lines or id: key),
// check whether any non-steps content remains under engine:
engineBlockIsEmpty := func() bool {
inEngine := false
engineIndentLen := 0
for _, line := range withoutEngineSteps {
trimmed := strings.TrimSpace(line)
if isTopLevelKey(line) && strings.HasPrefix(trimmed, "engine:") {
inEngine = true
engineIndentLen = len(getIndentation(line))
// Check for inline value (e.g., "engine: claude")
val := strings.TrimPrefix(trimmed, "engine:")
if strings.TrimSpace(val) != "" {
return false
}
continue
}
if inEngine {
if len(trimmed) == 0 {
continue
}
lineIndentLen := len(getIndentation(line))
if lineIndentLen <= engineIndentLen {
// Exited engine block with no content found
return true
}
// There is content under engine (e.g., id:, model:, env:)
return false
}
}
return inEngine // if we're still in engine at EOF, it's empty
}()

if engineBlockIsEmpty {
engineStepsCodemodLog.Print("Engine block is empty after removing 'steps', removing it")
// Remove the engine block (the engine: line and any blank lines around it)
cleaned := make([]string, 0, len(withoutEngineSteps))
engineIndentLen := 0
inEngine := false
for i, line := range withoutEngineSteps {
trimmed := strings.TrimSpace(line)
if isTopLevelKey(line) && strings.HasPrefix(trimmed, "engine:") {
inEngine = true
engineIndentLen = len(getIndentation(line))
// Remove trailing blank lines already added
for len(cleaned) > 0 && strings.TrimSpace(cleaned[len(cleaned)-1]) == "" {
cleaned = cleaned[:len(cleaned)-1]
}
_ = i
continue
}
if inEngine {
if len(trimmed) == 0 {
continue
}
if len(getIndentation(line)) <= engineIndentLen {
inEngine = false
} else {
continue
}
}
cleaned = append(cleaned, line)
}
withoutEngineSteps = cleaned
}

// Pass 2: insert engine steps at top level
var result []string
if !hasTopLevelSteps {
// Append engine steps at the end (as new top-level steps field)
result = append(withoutEngineSteps, topLevelStepsLines...)
engineStepsCodemodLog.Print("Added engine steps as new top-level 'steps'")
} else {
// Append engine step items after the top-level steps block
// Since we removed engine.steps lines, re-find the end of top-level steps
adjustedTopLevelEnd := topLevelStepsEndIdx
removedCount := stepsEndIdx - stepsStartIdx + 1
// Only adjust if the engine.steps came before the top-level steps end
if stepsEndIdx < topLevelStepsEndIdx {
adjustedTopLevelEnd -= removedCount
} else if stepsStartIdx <= topLevelStepsEndIdx && stepsEndIdx >= topLevelStepsEndIdx {
// engine.steps overlaps with top-level steps end (shouldn't happen but handle gracefully)
adjustedTopLevelEnd -= removedCount
}

result = make([]string, 0, len(withoutEngineSteps)+len(topLevelStepsLines))
insertedSteps := false
for i, line := range withoutEngineSteps {
result = append(result, line)
if !insertedSteps && i == adjustedTopLevelEnd {
// Append the step items (skip the "steps:" header since one already exists)
for _, stepLine := range topLevelStepsLines {
if strings.TrimSpace(stepLine) == "steps:" {
continue
}
result = append(result, stepLine)
}
insertedSteps = true
engineStepsCodemodLog.Print("Appended engine steps to existing top-level 'steps'")
}
}
}

newContent := reconstructContent(result, markdown)
engineStepsCodemodLog.Print("Successfully migrated 'engine.steps' to top-level 'steps'")
return newContent, true, nil
},
}
}
Loading
Loading