diff --git a/pkg/cli/codemod_agent_session.go b/pkg/cli/codemod_agent_session.go index be98020e03..e451a753af 100644 --- a/pkg/cli/codemod_agent_session.go +++ b/pkg/cli/codemod_agent_session.go @@ -40,60 +40,49 @@ func getAgentTaskToAgentSessionCodemod() Codemod { return content, false, nil } - // Parse frontmatter to get raw lines - frontmatterLines, markdown, err := parseFrontmatterLines(content) - if err != nil { - return content, false, err - } - - // Find and replace create-agent-task with create-agent-session within the safe-outputs block - var modified bool - var inSafeOutputsBlock bool - var safeOutputsIndent string - - result := make([]string, len(frontmatterLines)) - - for i, line := range frontmatterLines { - trimmedLine := strings.TrimSpace(line) - - // Track if we're in the safe-outputs block - if strings.HasPrefix(trimmedLine, "safe-outputs:") { - inSafeOutputsBlock = true - safeOutputsIndent = getIndentation(line) - result[i] = line - continue - } + newContent, applied, err := applyFrontmatterLineTransform(content, func(lines []string) ([]string, bool) { + var modified bool + var inSafeOutputsBlock bool + var safeOutputsIndent string + result := make([]string, len(lines)) + for i, line := range lines { + trimmedLine := strings.TrimSpace(line) + + // Track if we're in the safe-outputs block + if strings.HasPrefix(trimmedLine, "safe-outputs:") { + inSafeOutputsBlock = true + safeOutputsIndent = getIndentation(line) + result[i] = line + continue + } - // Check if we've left the safe-outputs block - if inSafeOutputsBlock && len(trimmedLine) > 0 && !strings.HasPrefix(trimmedLine, "#") { - if hasExitedBlock(line, safeOutputsIndent) { - inSafeOutputsBlock = false + // Check if we've left the safe-outputs block + if inSafeOutputsBlock && len(trimmedLine) > 0 && !strings.HasPrefix(trimmedLine, "#") { + if hasExitedBlock(line, safeOutputsIndent) { + inSafeOutputsBlock = false + } } - } - // Replace create-agent-task with create-agent-session if in safe-outputs block - if inSafeOutputsBlock && strings.HasPrefix(trimmedLine, "create-agent-task:") { - replacedLine, didReplace := findAndReplaceInLine(line, "create-agent-task", "create-agent-session") - if didReplace { - result[i] = replacedLine - modified = true - agentSessionCodemodLog.Printf("Replaced safe-outputs.create-agent-task with safe-outputs.create-agent-session on line %d", i+1) + // Replace create-agent-task with create-agent-session if in safe-outputs block + if inSafeOutputsBlock && strings.HasPrefix(trimmedLine, "create-agent-task:") { + replacedLine, didReplace := findAndReplaceInLine(line, "create-agent-task", "create-agent-session") + if didReplace { + result[i] = replacedLine + modified = true + agentSessionCodemodLog.Printf("Replaced safe-outputs.create-agent-task with safe-outputs.create-agent-session on line %d", i+1) + } else { + result[i] = line + } } else { result[i] = line } - } else { - result[i] = line } + return result, modified + }) + if applied { + agentSessionCodemodLog.Print("Applied create-agent-task to create-agent-session migration") } - - if !modified { - return content, false, nil - } - - // Reconstruct the content - newContent := reconstructContent(result, markdown) - agentSessionCodemodLog.Print("Applied create-agent-task to create-agent-session migration") - return newContent, true, nil + return newContent, applied, err }, } } diff --git a/pkg/cli/codemod_assign_to_agent.go b/pkg/cli/codemod_assign_to_agent.go index 0d5b9c0377..7633b891ae 100644 --- a/pkg/cli/codemod_assign_to_agent.go +++ b/pkg/cli/codemod_assign_to_agent.go @@ -51,76 +51,67 @@ func getAssignToAgentDefaultAgentCodemod() Codemod { return content, false, nil } - // Parse frontmatter to get raw lines - frontmatterLines, markdown, err := parseFrontmatterLines(content) - if err != nil { - return content, false, err - } - - var modified bool - var inSafeOutputsBlock bool - var safeOutputsIndent string - var inAssignToAgentBlock bool - var assignToAgentIndent string - - result := make([]string, len(frontmatterLines)) - - for i, line := range frontmatterLines { - trimmedLine := strings.TrimSpace(line) - - // Track if we're in the safe-outputs block - if strings.HasPrefix(trimmedLine, "safe-outputs:") { - inSafeOutputsBlock = true - safeOutputsIndent = getIndentation(line) - result[i] = line - continue - } + newContent, applied, err := applyFrontmatterLineTransform(content, func(lines []string) ([]string, bool) { + var modified bool + var inSafeOutputsBlock bool + var safeOutputsIndent string + var inAssignToAgentBlock bool + var assignToAgentIndent string + result := make([]string, len(lines)) + for i, line := range lines { + trimmedLine := strings.TrimSpace(line) + + // Track if we're in the safe-outputs block + if strings.HasPrefix(trimmedLine, "safe-outputs:") { + inSafeOutputsBlock = true + safeOutputsIndent = getIndentation(line) + result[i] = line + continue + } - // Check if we've left the safe-outputs block - if inSafeOutputsBlock && len(trimmedLine) > 0 && !strings.HasPrefix(trimmedLine, "#") { - if hasExitedBlock(line, safeOutputsIndent) { - inSafeOutputsBlock = false - inAssignToAgentBlock = false + // Check if we've left the safe-outputs block + if inSafeOutputsBlock && len(trimmedLine) > 0 && !strings.HasPrefix(trimmedLine, "#") { + if hasExitedBlock(line, safeOutputsIndent) { + inSafeOutputsBlock = false + inAssignToAgentBlock = false + } } - } - // Track if we're in the assign-to-agent block within safe-outputs - if inSafeOutputsBlock && strings.HasPrefix(trimmedLine, "assign-to-agent:") { - inAssignToAgentBlock = true - assignToAgentIndent = getIndentation(line) - result[i] = line - continue - } + // Track if we're in the assign-to-agent block within safe-outputs + if inSafeOutputsBlock && strings.HasPrefix(trimmedLine, "assign-to-agent:") { + inAssignToAgentBlock = true + assignToAgentIndent = getIndentation(line) + result[i] = line + continue + } - // Check if we've left the assign-to-agent block - if inAssignToAgentBlock && len(trimmedLine) > 0 && !strings.HasPrefix(trimmedLine, "#") { - if hasExitedBlock(line, assignToAgentIndent) { - inAssignToAgentBlock = false + // Check if we've left the assign-to-agent block + if inAssignToAgentBlock && len(trimmedLine) > 0 && !strings.HasPrefix(trimmedLine, "#") { + if hasExitedBlock(line, assignToAgentIndent) { + inAssignToAgentBlock = false + } } - } - // Replace default-agent with name if in assign-to-agent block - if inAssignToAgentBlock && strings.HasPrefix(trimmedLine, "default-agent:") { - replacedLine, didReplace := findAndReplaceInLine(line, "default-agent", "name") - if didReplace { - result[i] = replacedLine - modified = true - assignToAgentCodemodLog.Printf("Replaced safe-outputs.assign-to-agent.default-agent with safe-outputs.assign-to-agent.name on line %d", i+1) + // Replace default-agent with name if in assign-to-agent block + if inAssignToAgentBlock && strings.HasPrefix(trimmedLine, "default-agent:") { + replacedLine, didReplace := findAndReplaceInLine(line, "default-agent", "name") + if didReplace { + result[i] = replacedLine + modified = true + assignToAgentCodemodLog.Printf("Replaced safe-outputs.assign-to-agent.default-agent with safe-outputs.assign-to-agent.name on line %d", i+1) + } else { + result[i] = line + } } else { result[i] = line } - } else { - result[i] = line } + return result, modified + }) + if applied { + assignToAgentCodemodLog.Print("Applied assign-to-agent default-agent to name migration") } - - if !modified { - return content, false, nil - } - - newContent := reconstructContent(result, markdown) - assignToAgentCodemodLog.Print("Applied assign-to-agent default-agent to name migration") - return newContent, true, nil + return newContent, applied, err }, } } diff --git a/pkg/cli/codemod_bash_anonymous.go b/pkg/cli/codemod_bash_anonymous.go index a7a8deb835..03ceab4710 100644 --- a/pkg/cli/codemod_bash_anonymous.go +++ b/pkg/cli/codemod_bash_anonymous.go @@ -1,6 +1,10 @@ package cli -import "github.com/github/gh-aw/pkg/logger" +import ( + "strings" + + "github.com/github/gh-aw/pkg/logger" +) var bashAnonymousCodemodLog = logger.New("cli:codemod_bash_anonymous") @@ -34,22 +38,11 @@ func getBashAnonymousRemovalCodemod() Codemod { return content, false, nil } - // Parse frontmatter to get raw lines - frontmatterLines, markdown, err := parseFrontmatterLines(content) - if err != nil { - return content, false, err - } - - // Replace the bash field from anonymous to explicit true - modifiedLines, modified := replaceBashAnonymousWithTrue(frontmatterLines) - if !modified { - return content, false, nil + newContent, applied, err := applyFrontmatterLineTransform(content, replaceBashAnonymousWithTrue) + if applied { + bashAnonymousCodemodLog.Print("Applied bash anonymous removal, replaced with 'bash: true'") } - - // Reconstruct the content - newContent := reconstructContent(modifiedLines, markdown) - bashAnonymousCodemodLog.Print("Applied bash anonymous removal, replaced with 'bash: true'") - return newContent, true, nil + return newContent, applied, err }, } } @@ -62,10 +55,7 @@ func replaceBashAnonymousWithTrue(lines []string) ([]string, bool) { var toolsIndent string for _, line := range lines { - trimmedLine := line - - // Trim to check content but preserve original spacing - trimmed := trimLine(trimmedLine) + trimmed := strings.TrimSpace(line) // Track if we're in the tools block if trimmed == "tools:" { @@ -76,14 +66,14 @@ func replaceBashAnonymousWithTrue(lines []string) ([]string, bool) { } // Check if we've left the tools block - if inToolsBlock && len(trimmed) > 0 && !startsWith(trimmed, "#") { + if inToolsBlock && len(trimmed) > 0 && !strings.HasPrefix(trimmed, "#") { if hasExitedBlock(line, toolsIndent) { inToolsBlock = false } } // Replace bash: with bash: true if in tools block - if inToolsBlock && (trimmed == "bash:" || startsWith(trimmed, "bash: ")) { + if inToolsBlock && (trimmed == "bash:" || strings.HasPrefix(trimmed, "bash: ")) { // Check if it's just 'bash:' with nothing after the colon if trimmed == "bash:" { indent := getIndentation(line) @@ -99,21 +89,3 @@ func replaceBashAnonymousWithTrue(lines []string) ([]string, bool) { return result, modified } - -// Helper function to trim whitespace -func trimLine(s string) string { - start := 0 - for start < len(s) && (s[start] == ' ' || s[start] == '\t') { - start++ - } - end := len(s) - for end > start && (s[end-1] == ' ' || s[end-1] == '\t' || s[end-1] == '\n' || s[end-1] == '\r') { - end-- - } - return s[start:end] -} - -// Helper function to check if string starts with prefix -func startsWith(s, prefix string) bool { - return len(s) >= len(prefix) && s[:len(prefix)] == prefix -} diff --git a/pkg/cli/codemod_bots.go b/pkg/cli/codemod_bots.go index d2dcef92d1..da4465d42c 100644 --- a/pkg/cli/codemod_bots.go +++ b/pkg/cli/codemod_bots.go @@ -33,177 +33,164 @@ func getBotsToOnBotsCodemod() Codemod { } } - // Parse frontmatter to get raw lines - frontmatterLines, markdown, err := parseFrontmatterLines(content) - if err != nil { - return content, false, err - } + return applyFrontmatterLineTransform(content, func(frontmatterLines []string) ([]string, bool) { + // Find bots line and on: block + var botsLineIdx = -1 + var botsLineValue string + var onBlockIdx = -1 + var onIndent string + + // First pass: find the bots line and on: block + for i, line := range frontmatterLines { + trimmedLine := strings.TrimSpace(line) + + // Find top-level bots + if isTopLevelKey(line) && strings.HasPrefix(trimmedLine, "bots:") { + botsLineIdx = i + // Extract the value (could be on same line or on next lines) + parts := strings.SplitN(line, ":", 2) + if len(parts) == 2 { + botsLineValue = strings.TrimSpace(parts[1]) + } + botsCodemodLog.Printf("Found top-level bots at line %d", i+1) + } - // Find bots line and on: block - var botsLineIdx = -1 - var botsLineValue string - var onBlockIdx = -1 - var onIndent string - - // First pass: find the bots line and on: block - for i, line := range frontmatterLines { - trimmedLine := strings.TrimSpace(line) - - // Find top-level bots - if isTopLevelKey(line) && strings.HasPrefix(trimmedLine, "bots:") { - botsLineIdx = i - // Extract the value (could be on same line or on next lines) - parts := strings.SplitN(line, ":", 2) - if len(parts) == 2 { - botsLineValue = strings.TrimSpace(parts[1]) + // Find on: block + if isTopLevelKey(line) && strings.HasPrefix(trimmedLine, "on:") { + onBlockIdx = i + onIndent = getIndentation(line) + botsCodemodLog.Printf("Found 'on:' block at line %d", i+1) } - botsCodemodLog.Printf("Found top-level bots at line %d", i+1) } - // Find on: block - if isTopLevelKey(line) && strings.HasPrefix(trimmedLine, "on:") { - onBlockIdx = i - onIndent = getIndentation(line) - botsCodemodLog.Printf("Found 'on:' block at line %d", i+1) + // If no bots found, nothing to do + if botsLineIdx == -1 { + return frontmatterLines, false } - } - // If no bots found, nothing to do - if botsLineIdx == -1 { - return content, false, nil - } + // Determine how bots is formatted + var botsLines []string + var botsEndIdx int + + if strings.HasPrefix(botsLineValue, "[") { + // bots: [dependabot, renovate] - single line format + botsLines = []string{frontmatterLines[botsLineIdx]} + botsEndIdx = botsLineIdx + } else { + // Multi-line array format OR bots: with empty value + // Find all lines that are part of the bots block + botsStartIndent := getIndentation(frontmatterLines[botsLineIdx]) + botsLines = append(botsLines, frontmatterLines[botsLineIdx]) + botsEndIdx = botsLineIdx + + for j := botsLineIdx + 1; j < len(frontmatterLines); j++ { + line := frontmatterLines[j] + trimmed := strings.TrimSpace(line) + + // Empty lines or comments might be part of the block + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + botsLines = append(botsLines, line) + botsEndIdx = j + continue + } - // Determine how bots is formatted - var botsLines []string - var botsEndIdx int - - if strings.HasPrefix(botsLineValue, "[") { - // bots: [dependabot, renovate] - single line format - botsLines = []string{frontmatterLines[botsLineIdx]} - botsEndIdx = botsLineIdx - } else { - // Multi-line array format OR bots: with empty value - // Find all lines that are part of the bots block - botsStartIndent := getIndentation(frontmatterLines[botsLineIdx]) - botsLines = append(botsLines, frontmatterLines[botsLineIdx]) - botsEndIdx = botsLineIdx - - for j := botsLineIdx + 1; j < len(frontmatterLines); j++ { - line := frontmatterLines[j] - trimmed := strings.TrimSpace(line) - - // Empty lines or comments might be part of the block - if trimmed == "" || strings.HasPrefix(trimmed, "#") { - botsLines = append(botsLines, line) - botsEndIdx = j - continue + // Check if still in the bots block (indented more than bots:) + if isNestedUnder(line, botsStartIndent) { + botsLines = append(botsLines, line) + botsEndIdx = j + } else { + // Exited the block + break + } } + } - // Check if still in the bots block (indented more than bots:) - if isNestedUnder(line, botsStartIndent) { - botsLines = append(botsLines, line) - botsEndIdx = j - } else { - // Exited the block - break + botsCodemodLog.Printf("Bots spans lines %d to %d (%d lines)", botsLineIdx+1, botsEndIdx+1, len(botsLines)) + + // If no on: block found, we need to create one + result := make([]string, 0, len(frontmatterLines)) + + if onBlockIdx == -1 { + // No on: block exists - create one with bots inside it + botsCodemodLog.Print("No 'on:' block found - creating new one with bots") + + for i, line := range frontmatterLines { + if i >= botsLineIdx && i <= botsEndIdx { + // Skip the original bots lines - we'll add them to the new on: block + if i == botsLineIdx { + // Add new on: block with bots inside + result = append(result, "on:") + // Add bots lines with proper indentation + for _, botsLine := range botsLines { + trimmed := strings.TrimSpace(botsLine) + if trimmed == "" { + result = append(result, botsLine) + } else if strings.HasPrefix(trimmed, "bots:") { + // bots: line gets 2 spaces (nested under on:) + result = append(result, " "+botsLine) + } else { + // Array items get 4 spaces (nested under on: and bots:) + result = append(result, " "+trimmed) + } + } + } + // Skip all other bots lines + continue + } + result = append(result, line) } - } - } + } else { + // on: block exists - add bots to it + botsCodemodLog.Print("Found 'on:' block - adding bots to it") + + // Determine indentation for items inside on: block + onItemIndent := onIndent + " " - botsCodemodLog.Printf("Bots spans lines %d to %d (%d lines)", botsLineIdx+1, botsEndIdx+1, len(botsLines)) + // Track if we've inserted bots + insertedBots := false - // If no on: block found, we need to create one - result := make([]string, 0, len(frontmatterLines)) - modified := false + for i, line := range frontmatterLines { + // Skip the original bots lines + if i >= botsLineIdx && i <= botsEndIdx { + continue + } - if onBlockIdx == -1 { - // No on: block exists - create one with bots inside it - botsCodemodLog.Print("No 'on:' block found - creating new one with bots") + // Add the line + result = append(result, line) - for i, line := range frontmatterLines { - if i >= botsLineIdx && i <= botsEndIdx { - // Skip the original bots lines - we'll add them to the new on: block - if i == botsLineIdx { - // Add new on: block with bots inside - result = append(result, "on:") - // Add bots lines with proper indentation + // After the on: line, insert bots + if i == onBlockIdx && !insertedBots { + // Add bots lines with proper indentation inside on: block for _, botsLine := range botsLines { trimmed := strings.TrimSpace(botsLine) if trimmed == "" { result = append(result, botsLine) - } else if strings.HasPrefix(trimmed, "bots:") { - // bots: line gets 2 spaces (nested under on:) - result = append(result, " "+botsLine) } else { - // Array items get 4 spaces (nested under on: and bots:) - result = append(result, " "+trimmed) - } - } - modified = true - } - // Skip all other bots lines - continue - } - result = append(result, line) - } - } else { - // on: block exists - add bots to it - botsCodemodLog.Print("Found 'on:' block - adding bots to it") - - // Determine indentation for items inside on: block - onItemIndent := onIndent + " " - - // Track if we've inserted bots - insertedBots := false - - for i, line := range frontmatterLines { - // Skip the original bots lines - if i >= botsLineIdx && i <= botsEndIdx { - modified = true - continue - } - - // Add the line - result = append(result, line) - - // After the on: line, insert bots - if i == onBlockIdx && !insertedBots { - // Add bots lines with proper indentation inside on: block - for _, botsLine := range botsLines { - trimmed := strings.TrimSpace(botsLine) - if trimmed == "" { - result = append(result, botsLine) - } else { - // Adjust indentation to be nested under on: - // Remove "bots:" prefix and re-add with proper indentation - if strings.HasPrefix(trimmed, "bots:") { - // bots: value or bots: - parts := strings.SplitN(trimmed, ":", 2) - if len(parts) == 2 { - result = append(result, fmt.Sprintf("%sbots:%s", onItemIndent, parts[1])) + // Adjust indentation to be nested under on: + // Remove "bots:" prefix and re-add with proper indentation + if strings.HasPrefix(trimmed, "bots:") { + // bots: value or bots: + parts := strings.SplitN(trimmed, ":", 2) + if len(parts) == 2 { + result = append(result, fmt.Sprintf("%sbots:%s", onItemIndent, parts[1])) + } else { + result = append(result, onItemIndent+"bots:") + } } else { - result = append(result, onItemIndent+"bots:") + // Array item line (e.g., "- dependabot") + // These should be indented 2 more spaces than bots: to be nested under it + result = append(result, onItemIndent+" "+trimmed) } - } else { - // Array item line (e.g., "- dependabot") - // These should be indented 2 more spaces than bots: to be nested under it - result = append(result, onItemIndent+" "+trimmed) } } + insertedBots = true } - insertedBots = true } } - } - - if !modified { - return content, false, nil - } - // Reconstruct the content - newContent := reconstructContent(result, markdown) - botsCodemodLog.Print("Successfully migrated top-level 'bots' to 'on.bots'") - return newContent, true, nil + botsCodemodLog.Print("Successfully migrated top-level 'bots' to 'on.bots'") + return result, true + }) }, } } diff --git a/pkg/cli/codemod_discussion_flag.go b/pkg/cli/codemod_discussion_flag.go index 63f49b8e8f..700d942c50 100644 --- a/pkg/cli/codemod_discussion_flag.go +++ b/pkg/cli/codemod_discussion_flag.go @@ -44,93 +44,84 @@ func getDiscussionFlagRemovalCodemod() Codemod { return content, false, nil } - // Parse frontmatter to get raw lines - frontmatterLines, markdown, err := parseFrontmatterLines(content) - if err != nil { - return content, false, err - } - - // Remove the discussion field from the add-comment block in safe-outputs - var result []string - var modified bool - var inSafeOutputsBlock bool - var safeOutputsIndent string - var inAddCommentBlock bool - var addCommentIndent string - var inDiscussionField bool - - for i, line := range frontmatterLines { - trimmedLine := strings.TrimSpace(line) - - // Track if we're in the safe-outputs block - if strings.HasPrefix(trimmedLine, "safe-outputs:") { - inSafeOutputsBlock = true - safeOutputsIndent = getIndentation(line) - result = append(result, line) - continue - } - - // Check if we've left the safe-outputs block - if inSafeOutputsBlock && len(trimmedLine) > 0 && !strings.HasPrefix(trimmedLine, "#") { - if hasExitedBlock(line, safeOutputsIndent) { - inSafeOutputsBlock = false - inAddCommentBlock = false + newContent, applied, err := applyFrontmatterLineTransform(content, func(lines []string) ([]string, bool) { + var result []string + var modified bool + var inSafeOutputsBlock bool + var safeOutputsIndent string + var inAddCommentBlock bool + var addCommentIndent string + var inDiscussionField bool + + for i, line := range lines { + trimmedLine := strings.TrimSpace(line) + + // Track if we're in the safe-outputs block + if strings.HasPrefix(trimmedLine, "safe-outputs:") { + inSafeOutputsBlock = true + safeOutputsIndent = getIndentation(line) + result = append(result, line) + continue } - } - // Track if we're in the add-comment block within safe-outputs - if inSafeOutputsBlock && strings.HasPrefix(trimmedLine, "add-comment:") { - inAddCommentBlock = true - addCommentIndent = getIndentation(line) - result = append(result, line) - continue - } - - // Check if we've left the add-comment block - if inAddCommentBlock && len(trimmedLine) > 0 && !strings.HasPrefix(trimmedLine, "#") { - if hasExitedBlock(line, addCommentIndent) { - inAddCommentBlock = false + // Check if we've left the safe-outputs block + if inSafeOutputsBlock && len(trimmedLine) > 0 && !strings.HasPrefix(trimmedLine, "#") { + if hasExitedBlock(line, safeOutputsIndent) { + inSafeOutputsBlock = false + inAddCommentBlock = false + } } - } - // Remove discussion field line if in add-comment block - if inAddCommentBlock && strings.HasPrefix(trimmedLine, "discussion:") { - modified = true - inDiscussionField = true - discussionFlagCodemodLog.Printf("Removed safe-outputs.add-comment.discussion on line %d", i+1) - continue - } - - // Skip any nested content under the discussion field (shouldn't be any, but for completeness) - if inDiscussionField { - // Empty lines within the field block should be removed - if len(trimmedLine) == 0 { + // Track if we're in the add-comment block within safe-outputs + if inSafeOutputsBlock && strings.HasPrefix(trimmedLine, "add-comment:") { + inAddCommentBlock = true + addCommentIndent = getIndentation(line) + result = append(result, line) continue } - currentIndent := getIndentation(line) - discussionIndent := addCommentIndent + " " // discussion would be 2 spaces more than add-comment + // Check if we've left the add-comment block + if inAddCommentBlock && len(trimmedLine) > 0 && !strings.HasPrefix(trimmedLine, "#") { + if hasExitedBlock(line, addCommentIndent) { + inAddCommentBlock = false + } + } - // If this line has more indentation than discussion field, skip it - if len(currentIndent) > len(discussionIndent) { - discussionFlagCodemodLog.Printf("Removed nested discussion property on line %d: %s", i+1, trimmedLine) + // Remove discussion field line if in add-comment block + if inAddCommentBlock && strings.HasPrefix(trimmedLine, "discussion:") { + modified = true + inDiscussionField = true + discussionFlagCodemodLog.Printf("Removed safe-outputs.add-comment.discussion on line %d", i+1) continue } - // We've exited the discussion field - inDiscussionField = false - } - result = append(result, line) - } + // Skip any nested content under the discussion field (shouldn't be any, but for completeness) + if inDiscussionField { + // Empty lines within the field block should be removed + if len(trimmedLine) == 0 { + continue + } + + currentIndent := getIndentation(line) + discussionIndent := addCommentIndent + " " // discussion would be 2 spaces more than add-comment + + // If this line has more indentation than discussion field, skip it + if len(currentIndent) > len(discussionIndent) { + discussionFlagCodemodLog.Printf("Removed nested discussion property on line %d: %s", i+1, trimmedLine) + continue + } + // We've exited the discussion field + inDiscussionField = false + } - if !modified { - return content, false, nil + result = append(result, line) + } + return result, modified + }) + if applied { + discussionFlagCodemodLog.Print("Applied add-comment.discussion removal") } - - // Reconstruct the content - newContent := reconstructContent(result, markdown) - discussionFlagCodemodLog.Print("Applied add-comment.discussion removal") - return newContent, true, nil + return newContent, applied, err }, } } diff --git a/pkg/cli/codemod_engine_steps.go b/pkg/cli/codemod_engine_steps.go index d1588cca98..f0ccffc92e 100644 --- a/pkg/cli/codemod_engine_steps.go +++ b/pkg/cli/codemod_engine_steps.go @@ -32,87 +32,7 @@ func getEngineStepsToTopLevelCodemod() Codemod { 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 + // Determine if existing top-level steps is a sequence hasTopLevelSteps := false if stepsVal, exists := frontmatter["steps"]; exists { if _, isSlice := stepsVal.([]any); isSlice { @@ -123,147 +43,222 @@ func getEngineStepsToTopLevelCodemod() Codemod { } } - if hasTopLevelSteps { - // Find the end of the top-level steps block in the lines + return applyFrontmatterLineTransform(content, func(frontmatterLines []string) ([]string, bool) { + // 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, "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 - } + + 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 } - engineStepsCodemodLog.Printf("Top-level 'steps:' ends at line %d", topLevelStepsEndIdx+1) - break + } + + // 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) } } - } - // 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 + if stepsStartIdx == -1 { + return frontmatterLines, false } - withoutEngineSteps = append(withoutEngineSteps, line) - } - // 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 { + // 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 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 - } + + if len(trimmed) == 0 { 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 + + lineIndent := getIndentation(line) + if len(lineIndent) > len(stepsIndent) { + stepsEndIdx = j + } else { + break } } - 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 { + 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 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] + 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 end (if any) + topLevelStepsEndIdx := -1 + if hasTopLevelSteps { + // Find the end of the top-level steps block in the lines + for i, line := range frontmatterLines { + 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 } - _ = i + } + } + + // 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 } - if inEngine { - if len(trimmed) == 0 { + withoutEngineSteps = append(withoutEngineSteps, line) + } + + // 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 len(getIndentation(line)) <= engineIndentLen { - inEngine = false - } else { + 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) } - cleaned = append(cleaned, line) + withoutEngineSteps = cleaned } - 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 - } + // 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 = 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) } - result = append(result, stepLine) + insertedSteps = true + engineStepsCodemodLog.Print("Appended engine steps to existing top-level 'steps'") } - 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 + engineStepsCodemodLog.Print("Successfully migrated 'engine.steps' to top-level 'steps'") + return result, true + }) }, } } diff --git a/pkg/cli/codemod_expires_integer.go b/pkg/cli/codemod_expires_integer.go index 8d4cd7751e..e7dab3a732 100644 --- a/pkg/cli/codemod_expires_integer.go +++ b/pkg/cli/codemod_expires_integer.go @@ -52,22 +52,11 @@ func getExpiresIntegerToStringCodemod() Codemod { return content, false, nil } - // Parse frontmatter to get raw lines - frontmatterLines, markdown, err := parseFrontmatterLines(content) - if err != nil { - return content, false, err + newContent, applied, err := applyFrontmatterLineTransform(content, convertExpiresIntegersToDayStrings) + if applied { + expiresIntegerCodemodLog.Print("Applied expires integer-to-string migration") } - - // Convert integer expires values to day strings within safe-outputs blocks - result, modified := convertExpiresIntegersToDayStrings(frontmatterLines) - if !modified { - return content, false, nil - } - - // Reconstruct the content - newContent := reconstructContent(result, markdown) - expiresIntegerCodemodLog.Print("Applied expires integer-to-string migration") - return newContent, true, nil + return newContent, applied, err }, } } diff --git a/pkg/cli/codemod_grep_tool.go b/pkg/cli/codemod_grep_tool.go index 1a1b5df1d2..7f18dbc6b8 100644 --- a/pkg/cli/codemod_grep_tool.go +++ b/pkg/cli/codemod_grep_tool.go @@ -29,22 +29,13 @@ func getGrepToolRemovalCodemod() Codemod { return content, false, nil } - // Parse frontmatter to get raw lines - frontmatterLines, markdown, err := parseFrontmatterLines(content) - if err != nil { - return content, false, err + newContent, applied, err := applyFrontmatterLineTransform(content, func(lines []string) ([]string, bool) { + return removeFieldFromBlock(lines, "grep", "tools") + }) + if applied { + grepToolCodemodLog.Print("Applied grep tool removal") } - - // Remove the grep field from the tools block - modifiedLines, modified := removeFieldFromBlock(frontmatterLines, "grep", "tools") - if !modified { - return content, false, nil - } - - // Reconstruct the content - newContent := reconstructContent(modifiedLines, markdown) - grepToolCodemodLog.Print("Applied grep tool removal") - return newContent, true, nil + return newContent, applied, err }, } } diff --git a/pkg/cli/codemod_install_script_url.go b/pkg/cli/codemod_install_script_url.go index 718caf2d85..6569e4cce0 100644 --- a/pkg/cli/codemod_install_script_url.go +++ b/pkg/cli/codemod_install_script_url.go @@ -16,12 +16,6 @@ func getInstallScriptURLCodemod() Codemod { Description: "Updates install script URLs in job steps from the older githubnext/gh-aw location to the new github/gh-aw location", IntroducedIn: "0.9.0", Apply: func(content string, frontmatter map[string]any) (string, bool, error) { - // Parse frontmatter to get raw lines - frontmatterLines, markdown, err := parseFrontmatterLines(content) - if err != nil { - return content, false, err - } - // Define patterns to search and replace // Order matters: Check URL patterns first (with slash), then general patterns oldPatterns := []string{ @@ -34,32 +28,26 @@ func getInstallScriptURLCodemod() Codemod { "github/gh-aw", } - modified := false - result := make([]string, len(frontmatterLines)) - - for i, line := range frontmatterLines { - modifiedLine := line - - // Try to replace each old pattern with the new one in all lines - for j, oldPattern := range oldPatterns { - if strings.Contains(modifiedLine, oldPattern) { - modifiedLine = strings.ReplaceAll(modifiedLine, oldPattern, newReplacements[j]) - modified = true - installScriptURLCodemodLog.Printf("Replaced '%s' with '%s' on line %d", oldPattern, newReplacements[j], i+1) + return applyFrontmatterLineTransform(content, func(lines []string) ([]string, bool) { + modified := false + result := make([]string, len(lines)) + for i, line := range lines { + modifiedLine := line + // Try to replace each old pattern with the new one in all lines + for j, oldPattern := range oldPatterns { + if strings.Contains(modifiedLine, oldPattern) { + modifiedLine = strings.ReplaceAll(modifiedLine, oldPattern, newReplacements[j]) + modified = true + installScriptURLCodemodLog.Printf("Replaced '%s' with '%s' on line %d", oldPattern, newReplacements[j], i+1) + } } + result[i] = modifiedLine } - - result[i] = modifiedLine - } - - if !modified { - return content, false, nil - } - - // Reconstruct the content - newContent := reconstructContent(result, markdown) - installScriptURLCodemodLog.Print("Applied install script URL migration") - return newContent, true, nil + if modified { + installScriptURLCodemodLog.Print("Applied install script URL migration") + } + return result, modified + }) }, } } diff --git a/pkg/cli/codemod_mcp_mode_to_type.go b/pkg/cli/codemod_mcp_mode_to_type.go index 1037b11e8a..c23a17164e 100644 --- a/pkg/cli/codemod_mcp_mode_to_type.go +++ b/pkg/cli/codemod_mcp_mode_to_type.go @@ -44,22 +44,11 @@ func getMCPModeToTypeCodemod() Codemod { return content, false, nil } - // Parse frontmatter to get raw lines - frontmatterLines, markdown, err := parseFrontmatterLines(content) - if err != nil { - return content, false, err + newContent, applied, err := applyFrontmatterLineTransform(content, renameModeToTypeInMCPServers) + if applied { + mcpModeToTypeCodemodLog.Print("Applied MCP mode-to-type migration") } - - // Rename 'mode' to 'type' in all MCP servers - result, modified := renameModeToTypeInMCPServers(frontmatterLines) - if !modified { - return content, false, nil - } - - // Reconstruct the content - newContent := reconstructContent(result, markdown) - mcpModeToTypeCodemodLog.Print("Applied MCP mode-to-type migration") - return newContent, true, nil + return newContent, applied, err }, } } diff --git a/pkg/cli/codemod_mcp_network.go b/pkg/cli/codemod_mcp_network.go index 75b967c003..f46ac26c1e 100644 --- a/pkg/cli/codemod_mcp_network.go +++ b/pkg/cli/codemod_mcp_network.go @@ -85,28 +85,6 @@ func getMCPNetworkMigrationCodemod() Codemod { // Remove duplicates from collected domains allAllowedDomains = sliceutil.Deduplicate(allAllowedDomains) - // Parse frontmatter to get raw lines - frontmatterLines, markdown, err := parseFrontmatterLines(content) - if err != nil { - return content, false, err - } - - // Remove network fields from all MCP servers - result := frontmatterLines - var modified bool - for serverName := range serversWithNetwork { - var serverModified bool - result, serverModified = removeFieldFromMCPServer(result, serverName, "network") - if serverModified { - modified = true - mcpNetworkCodemodLog.Printf("Removed network configuration from MCP server '%s'", serverName) - } - } - - if !modified { - return content, false, nil - } - // Check if top-level network configuration already exists existingNetworkValue, hasTopLevelNetwork := frontmatter["network"] var existingAllowed []string @@ -129,24 +107,39 @@ func getMCPNetworkMigrationCodemod() Codemod { } // Merge existing and new domains, remove duplicates - mergedDomains := append(existingAllowed, allAllowedDomains...) - mergedDomains = sliceutil.Deduplicate(mergedDomains) + mergedDomains := sliceutil.Deduplicate(append(existingAllowed, allAllowedDomains...)) + + return applyFrontmatterLineTransform(content, func(lines []string) ([]string, bool) { + // Remove network fields from all MCP servers + result := lines + var modified bool + for serverName := range serversWithNetwork { + var serverModified bool + result, serverModified = removeFieldFromMCPServer(result, serverName, "network") + if serverModified { + modified = true + mcpNetworkCodemodLog.Printf("Removed network configuration from MCP server '%s'", serverName) + } + } - // Add or update top-level network configuration - if hasTopLevelNetwork { - // Update existing network.allowed - result = updateNetworkAllowed(result, mergedDomains) - mcpNetworkCodemodLog.Printf("Updated top-level network.allowed with %d domains", len(mergedDomains)) - } else { - // Add new top-level network configuration - result = addTopLevelNetwork(result, mergedDomains) - mcpNetworkCodemodLog.Printf("Added top-level network.allowed with %d domains", len(mergedDomains)) - } + if !modified { + return lines, false + } + + // Add or update top-level network configuration + if hasTopLevelNetwork { + // Update existing network.allowed + result = updateNetworkAllowed(result, mergedDomains) + mcpNetworkCodemodLog.Printf("Updated top-level network.allowed with %d domains", len(mergedDomains)) + } else { + // Add new top-level network configuration + result = addTopLevelNetwork(result, mergedDomains) + mcpNetworkCodemodLog.Printf("Added top-level network.allowed with %d domains", len(mergedDomains)) + } - // Reconstruct the content - newContent := reconstructContent(result, markdown) - mcpNetworkCodemodLog.Print("Applied MCP network migration to top-level") - return newContent, true, nil + mcpNetworkCodemodLog.Print("Applied MCP network migration to top-level") + return result, true + }) }, } } diff --git a/pkg/cli/codemod_network_firewall.go b/pkg/cli/codemod_network_firewall.go index 800d56bb0a..f81d25b10d 100644 --- a/pkg/cli/codemod_network_firewall.go +++ b/pkg/cli/codemod_network_firewall.go @@ -36,64 +36,62 @@ func getNetworkFirewallCodemod() Codemod { // Note: We no longer set sandbox.agent: false since the firewall is mandatory // The firewall is always enabled via the default sandbox.agent: awf - // Parse frontmatter to get raw lines - frontmatterLines, markdown, err := parseFrontmatterLines(content) - if err != nil { - return content, false, err - } - - // Remove the firewall field from the network block - result, modified := removeFieldFromBlock(frontmatterLines, "firewall", "network") - if !modified { - return content, false, nil - } - - // Add sandbox.agent if not already present AND if firewall was explicitly true - // (no need to add sandbox.agent: awf if firewall was false, since awf is now the default) _, hasSandbox := frontmatter["sandbox"] - if !hasSandbox && firewallValue == true { - // Only add sandbox.agent: awf if firewall was explicitly set to true - sandboxLines := []string{ - "sandbox:", - " agent: awf # Firewall enabled (migrated from network.firewall)", + + newContent, applied, err := applyFrontmatterLineTransform(content, func(lines []string) ([]string, bool) { + // Remove the firewall field from the network block + result, modified := removeFieldFromBlock(lines, "firewall", "network") + if !modified { + return lines, false } - // Try to place it after network block - insertIndex := -1 - inNet := false - for i, line := range result { - trimmed := strings.TrimSpace(line) - if strings.HasPrefix(trimmed, "network:") { - inNet = true - } else if inNet && len(trimmed) > 0 { - // Check if this is a top-level key (no leading whitespace) - if isTopLevelKey(line) { - // Found next top-level key - insertIndex = i - break + // Add sandbox.agent if not already present AND if firewall was explicitly true + // (no need to add sandbox.agent: awf if firewall was false, since awf is now the default) + if !hasSandbox && firewallValue == true { + // Only add sandbox.agent: awf if firewall was explicitly set to true + sandboxLines := []string{ + "sandbox:", + " agent: awf # Firewall enabled (migrated from network.firewall)", + } + + // Try to place it after network block + insertIndex := -1 + inNet := false + for i, line := range result { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "network:") { + inNet = true + } else if inNet && len(trimmed) > 0 { + // Check if this is a top-level key (no leading whitespace) + if isTopLevelKey(line) { + // Found next top-level key + insertIndex = i + break + } } } - } - if insertIndex >= 0 { - // Insert after network block - newLines := make([]string, 0, len(result)+len(sandboxLines)) - newLines = append(newLines, result[:insertIndex]...) - newLines = append(newLines, sandboxLines...) - newLines = append(newLines, result[insertIndex:]...) - result = newLines - } else { - // Append at the end - result = append(result, sandboxLines...) + if insertIndex >= 0 { + // Insert after network block + newLines := make([]string, 0, len(result)+len(sandboxLines)) + newLines = append(newLines, result[:insertIndex]...) + newLines = append(newLines, sandboxLines...) + newLines = append(newLines, result[insertIndex:]...) + result = newLines + } else { + // Append at the end + result = append(result, sandboxLines...) + } + + networkFirewallCodemodLog.Print("Added sandbox.agent: awf (firewall was explicitly enabled)") } - networkFirewallCodemodLog.Print("Added sandbox.agent: awf (firewall was explicitly enabled)") + return result, true + }) + if applied { + networkFirewallCodemodLog.Printf("Applied network.firewall removal (firewall: %v removed, firewall now always enabled via sandbox.agent: awf default)", firewallValue) } - - // Reconstruct the content - newContent := reconstructContent(result, markdown) - networkFirewallCodemodLog.Printf("Applied network.firewall removal (firewall: %v removed, firewall now always enabled via sandbox.agent: awf default)", firewallValue) - return newContent, true, nil + return newContent, applied, err }, } } diff --git a/pkg/cli/codemod_permissions.go b/pkg/cli/codemod_permissions.go index e46000115e..f4da4722f7 100644 --- a/pkg/cli/codemod_permissions.go +++ b/pkg/cli/codemod_permissions.go @@ -35,52 +35,42 @@ func getPermissionsReadCodemod() Codemod { return content, false, nil } - // Parse frontmatter to get raw lines - frontmatterLines, markdown, err := parseFrontmatterLines(content) - if err != nil { - return content, false, err - } - - // Find and replace invalid shorthand permissions - var modified bool - result := make([]string, len(frontmatterLines)) - - for i, line := range frontmatterLines { - trimmedLine := strings.TrimSpace(line) - - // Check for permissions line with shorthand - if strings.HasPrefix(trimmedLine, "permissions:") { - // Handle shorthand on same line: "permissions: read" or "permissions: write" - if strings.Contains(trimmedLine, ": read") && !strings.Contains(trimmedLine, "read-all") && !strings.Contains(trimmedLine, ": read\n") { - // Make sure it's "permissions: read" and not "contents: read" - if strings.TrimSpace(strings.Split(line, ":")[0]) == "permissions" { - result[i] = strings.Replace(line, ": read", ": read-all", 1) - modified = true - permissionsReadCodemodLog.Printf("Replaced 'permissions: read' with 'permissions: read-all' on line %d", i+1) - continue - } - } else if strings.Contains(trimmedLine, ": write") && !strings.Contains(trimmedLine, "write-all") { - // Make sure it's "permissions: write" and not "contents: write" - if strings.TrimSpace(strings.Split(line, ":")[0]) == "permissions" { - result[i] = strings.Replace(line, ": write", ": write-all", 1) - modified = true - permissionsReadCodemodLog.Printf("Replaced 'permissions: write' with 'permissions: write-all' on line %d", i+1) - continue + newContent, applied, err := applyFrontmatterLineTransform(content, func(lines []string) ([]string, bool) { + var modified bool + result := make([]string, len(lines)) + for i, line := range lines { + trimmedLine := strings.TrimSpace(line) + + // Check for permissions line with shorthand + if strings.HasPrefix(trimmedLine, "permissions:") { + // Handle shorthand on same line: "permissions: read" or "permissions: write" + if strings.Contains(trimmedLine, ": read") && !strings.Contains(trimmedLine, "read-all") && !strings.Contains(trimmedLine, ": read\n") { + // Make sure it's "permissions: read" and not "contents: read" + if strings.TrimSpace(strings.Split(line, ":")[0]) == "permissions" { + result[i] = strings.Replace(line, ": read", ": read-all", 1) + modified = true + permissionsReadCodemodLog.Printf("Replaced 'permissions: read' with 'permissions: read-all' on line %d", i+1) + continue + } + } else if strings.Contains(trimmedLine, ": write") && !strings.Contains(trimmedLine, "write-all") { + // Make sure it's "permissions: write" and not "contents: write" + if strings.TrimSpace(strings.Split(line, ":")[0]) == "permissions" { + result[i] = strings.Replace(line, ": write", ": write-all", 1) + modified = true + permissionsReadCodemodLog.Printf("Replaced 'permissions: write' with 'permissions: write-all' on line %d", i+1) + continue + } } } - } - result[i] = line - } - - if !modified { - return content, false, nil + result[i] = line + } + return result, modified + }) + if applied { + permissionsReadCodemodLog.Print("Applied permissions read/write to read-all/write-all migration") } - - // Reconstruct the content - newContent := reconstructContent(result, markdown) - permissionsReadCodemodLog.Print("Applied permissions read/write to read-all/write-all migration") - return newContent, true, nil + return newContent, applied, err }, } } @@ -125,81 +115,70 @@ func getWritePermissionsCodemod() Codemod { return content, false, nil } - // Parse frontmatter to get raw lines - frontmatterLines, markdown, err := parseFrontmatterLines(content) - if err != nil { - return content, false, err - } - - // Find and replace write permissions - var modified bool - var inPermissionsBlock bool - var permissionsIndent string - - result := make([]string, len(frontmatterLines)) - - for i, line := range frontmatterLines { - trimmedLine := strings.TrimSpace(line) - - // Track if we're in the permissions block - if strings.HasPrefix(trimmedLine, "permissions:") { - inPermissionsBlock = true - permissionsIndent = getIndentation(line) + newContent, applied, err := applyFrontmatterLineTransform(content, func(lines []string) ([]string, bool) { + var modified bool + var inPermissionsBlock bool + var permissionsIndent string + result := make([]string, len(lines)) + for i, line := range lines { + trimmedLine := strings.TrimSpace(line) + + // Track if we're in the permissions block + if strings.HasPrefix(trimmedLine, "permissions:") { + inPermissionsBlock = true + permissionsIndent = getIndentation(line) + + // Handle shorthand on same line: "permissions: write-all" or "permissions: write" + if strings.Contains(trimmedLine, ": write-all") { + result[i] = strings.Replace(line, ": write-all", ": read-all", 1) + modified = true + writePermissionsCodemodLog.Printf("Replaced permissions: write-all with permissions: read-all on line %d", i+1) + continue + } else if strings.Contains(trimmedLine, ": write") && !strings.Contains(trimmedLine, "write-all") { + result[i] = strings.Replace(line, ": write", ": read", 1) + modified = true + writePermissionsCodemodLog.Printf("Replaced permissions: write with permissions: read on line %d", i+1) + continue + } - // Handle shorthand on same line: "permissions: write-all" or "permissions: write" - if strings.Contains(trimmedLine, ": write-all") { - result[i] = strings.Replace(line, ": write-all", ": read-all", 1) - modified = true - writePermissionsCodemodLog.Printf("Replaced permissions: write-all with permissions: read-all on line %d", i+1) - continue - } else if strings.Contains(trimmedLine, ": write") && !strings.Contains(trimmedLine, "write-all") { - result[i] = strings.Replace(line, ": write", ": read", 1) - modified = true - writePermissionsCodemodLog.Printf("Replaced permissions: write with permissions: read on line %d", i+1) + result[i] = line continue } - result[i] = line - continue - } - - // Check if we've left the permissions block - if inPermissionsBlock && len(trimmedLine) > 0 && !strings.HasPrefix(trimmedLine, "#") { - if hasExitedBlock(line, permissionsIndent) { - inPermissionsBlock = false + // Check if we've left the permissions block + if inPermissionsBlock && len(trimmedLine) > 0 && !strings.HasPrefix(trimmedLine, "#") { + if hasExitedBlock(line, permissionsIndent) { + inPermissionsBlock = false + } } - } - // Replace write with read if in permissions block - if inPermissionsBlock && strings.Contains(trimmedLine, ": write") { - // Preserve indentation and everything else - // Extract the key, value, and any trailing comment - parts := strings.SplitN(line, ":", 2) - if len(parts) >= 2 { - key := parts[0] - valueAndComment := parts[1] - - // Replace "write" with "read" in the value part - newValueAndComment := strings.Replace(valueAndComment, " write", " read", 1) - result[i] = fmt.Sprintf("%s:%s", key, newValueAndComment) - modified = true - writePermissionsCodemodLog.Printf("Replaced write with read on line %d", i+1) + // Replace write with read if in permissions block + if inPermissionsBlock && strings.Contains(trimmedLine, ": write") { + // Preserve indentation and everything else + // Extract the key, value, and any trailing comment + parts := strings.SplitN(line, ":", 2) + if len(parts) >= 2 { + key := parts[0] + valueAndComment := parts[1] + + // Replace "write" with "read" in the value part + newValueAndComment := strings.Replace(valueAndComment, " write", " read", 1) + result[i] = fmt.Sprintf("%s:%s", key, newValueAndComment) + modified = true + writePermissionsCodemodLog.Printf("Replaced write with read on line %d", i+1) + } else { + result[i] = line + } } else { result[i] = line } - } else { - result[i] = line } + return result, modified + }) + if applied { + writePermissionsCodemodLog.Print("Applied write permissions to read migration") } - - if !modified { - return content, false, nil - } - - // Reconstruct the content - newContent := reconstructContent(result, markdown) - writePermissionsCodemodLog.Print("Applied write permissions to read migration") - return newContent, true, nil + return newContent, applied, err }, } } diff --git a/pkg/cli/codemod_playwright_domains.go b/pkg/cli/codemod_playwright_domains.go index add870bcea..e98fae60b0 100644 --- a/pkg/cli/codemod_playwright_domains.go +++ b/pkg/cli/codemod_playwright_domains.go @@ -59,20 +59,6 @@ func getPlaywrightDomainsCodemod() Codemod { domains = []string{v} } - // Parse frontmatter lines - frontmatterLines, markdown, err := parseFrontmatterLines(content) - if err != nil { - return content, false, err - } - - // Remove allowed_domains from the tools.playwright block - result, modified := removeFieldFromPlaywright(frontmatterLines, "allowed_domains") - if !modified { - return content, false, nil - } - - playwrightDomainsCodemodLog.Printf("Removed allowed_domains from tools.playwright (%d domain(s))", len(domains)) - // Merge with existing network.allowed existingNetworkValue, hasTopLevelNetwork := frontmatter["network"] var existingAllowed []string @@ -95,16 +81,25 @@ func getPlaywrightDomainsCodemod() Codemod { mergedDomains := sliceutil.Deduplicate(append(existingAllowed, domains...)) - if hasTopLevelNetwork { - result = updateNetworkAllowed(result, mergedDomains) - playwrightDomainsCodemodLog.Printf("Updated top-level network.allowed with %d domain(s)", len(mergedDomains)) - } else { - result = addTopLevelNetwork(result, mergedDomains) - playwrightDomainsCodemodLog.Printf("Added top-level network.allowed with %d domain(s)", len(mergedDomains)) - } + return applyFrontmatterLineTransform(content, func(lines []string) ([]string, bool) { + // Remove allowed_domains from the tools.playwright block + result, modified := removeFieldFromPlaywright(lines, "allowed_domains") + if !modified { + return lines, false + } + + playwrightDomainsCodemodLog.Printf("Removed allowed_domains from tools.playwright (%d domain(s))", len(domains)) + + if hasTopLevelNetwork { + result = updateNetworkAllowed(result, mergedDomains) + playwrightDomainsCodemodLog.Printf("Updated top-level network.allowed with %d domain(s)", len(mergedDomains)) + } else { + result = addTopLevelNetwork(result, mergedDomains) + playwrightDomainsCodemodLog.Printf("Added top-level network.allowed with %d domain(s)", len(mergedDomains)) + } - newContent := reconstructContent(result, markdown) - return newContent, true, nil + return result, true + }) }, } } diff --git a/pkg/cli/codemod_roles.go b/pkg/cli/codemod_roles.go index 3d20365e82..ca0a4cf148 100644 --- a/pkg/cli/codemod_roles.go +++ b/pkg/cli/codemod_roles.go @@ -33,177 +33,164 @@ func getRolesToOnRolesCodemod() Codemod { } } - // Parse frontmatter to get raw lines - frontmatterLines, markdown, err := parseFrontmatterLines(content) - if err != nil { - return content, false, err - } + return applyFrontmatterLineTransform(content, func(frontmatterLines []string) ([]string, bool) { + // Find roles line and on: block + var rolesLineIdx = -1 + var rolesLineValue string + var onBlockIdx = -1 + var onIndent string + + // First pass: find the roles line and on: block + for i, line := range frontmatterLines { + trimmedLine := strings.TrimSpace(line) + + // Find top-level roles + if isTopLevelKey(line) && strings.HasPrefix(trimmedLine, "roles:") { + rolesLineIdx = i + // Extract the value (could be on same line or on next lines) + parts := strings.SplitN(line, ":", 2) + if len(parts) == 2 { + rolesLineValue = strings.TrimSpace(parts[1]) + } + rolesCodemodLog.Printf("Found top-level roles at line %d", i+1) + } - // Find roles line and on: block - var rolesLineIdx = -1 - var rolesLineValue string - var onBlockIdx = -1 - var onIndent string - - // First pass: find the roles line and on: block - for i, line := range frontmatterLines { - trimmedLine := strings.TrimSpace(line) - - // Find top-level roles - if isTopLevelKey(line) && strings.HasPrefix(trimmedLine, "roles:") { - rolesLineIdx = i - // Extract the value (could be on same line or on next lines) - parts := strings.SplitN(line, ":", 2) - if len(parts) == 2 { - rolesLineValue = strings.TrimSpace(parts[1]) + // Find on: block + if isTopLevelKey(line) && strings.HasPrefix(trimmedLine, "on:") { + onBlockIdx = i + onIndent = getIndentation(line) + rolesCodemodLog.Printf("Found 'on:' block at line %d", i+1) } - rolesCodemodLog.Printf("Found top-level roles at line %d", i+1) } - // Find on: block - if isTopLevelKey(line) && strings.HasPrefix(trimmedLine, "on:") { - onBlockIdx = i - onIndent = getIndentation(line) - rolesCodemodLog.Printf("Found 'on:' block at line %d", i+1) + // If no roles found, nothing to do + if rolesLineIdx == -1 { + return frontmatterLines, false } - } - // If no roles found, nothing to do - if rolesLineIdx == -1 { - return content, false, nil - } + // Determine how roles is formatted + var rolesLines []string + var rolesEndIdx int + + if rolesLineValue == "all" || strings.HasPrefix(rolesLineValue, "[") { + // roles: all or roles: [admin, write] - single line format + rolesLines = []string{frontmatterLines[rolesLineIdx]} + rolesEndIdx = rolesLineIdx + } else { + // Multi-line array format OR roles: with empty value + // Find all lines that are part of the roles block + rolesStartIndent := getIndentation(frontmatterLines[rolesLineIdx]) + rolesLines = append(rolesLines, frontmatterLines[rolesLineIdx]) + rolesEndIdx = rolesLineIdx + + for j := rolesLineIdx + 1; j < len(frontmatterLines); j++ { + line := frontmatterLines[j] + trimmed := strings.TrimSpace(line) + + // Empty lines or comments might be part of the block + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + rolesLines = append(rolesLines, line) + rolesEndIdx = j + continue + } - // Determine how roles is formatted - var rolesLines []string - var rolesEndIdx int - - if rolesLineValue == "all" || strings.HasPrefix(rolesLineValue, "[") { - // roles: all or roles: [admin, write] - single line format - rolesLines = []string{frontmatterLines[rolesLineIdx]} - rolesEndIdx = rolesLineIdx - } else { - // Multi-line array format OR roles: with empty value - // Find all lines that are part of the roles block - rolesStartIndent := getIndentation(frontmatterLines[rolesLineIdx]) - rolesLines = append(rolesLines, frontmatterLines[rolesLineIdx]) - rolesEndIdx = rolesLineIdx - - for j := rolesLineIdx + 1; j < len(frontmatterLines); j++ { - line := frontmatterLines[j] - trimmed := strings.TrimSpace(line) - - // Empty lines or comments might be part of the block - if trimmed == "" || strings.HasPrefix(trimmed, "#") { - rolesLines = append(rolesLines, line) - rolesEndIdx = j - continue + // Check if still in the roles block (indented more than roles:) + if isNestedUnder(line, rolesStartIndent) { + rolesLines = append(rolesLines, line) + rolesEndIdx = j + } else { + // Exited the block + break + } } + } - // Check if still in the roles block (indented more than roles:) - if isNestedUnder(line, rolesStartIndent) { - rolesLines = append(rolesLines, line) - rolesEndIdx = j - } else { - // Exited the block - break + rolesCodemodLog.Printf("Roles spans lines %d to %d (%d lines)", rolesLineIdx+1, rolesEndIdx+1, len(rolesLines)) + + // If no on: block found, we need to create one + result := make([]string, 0, len(frontmatterLines)) + + if onBlockIdx == -1 { + // No on: block exists - create one with roles inside it + rolesCodemodLog.Print("No 'on:' block found - creating new one with roles") + + for i, line := range frontmatterLines { + if i >= rolesLineIdx && i <= rolesEndIdx { + // Skip the original roles lines - we'll add them to the new on: block + if i == rolesLineIdx { + // Add new on: block with roles inside + result = append(result, "on:") + // Add roles lines with proper indentation + for _, rolesLine := range rolesLines { + trimmed := strings.TrimSpace(rolesLine) + if trimmed == "" { + result = append(result, rolesLine) + } else if strings.HasPrefix(trimmed, "roles:") { + // roles: line gets 2 spaces (nested under on:) + result = append(result, " "+rolesLine) + } else { + // Array items get 4 spaces (nested under on: and roles:) + result = append(result, " "+trimmed) + } + } + } + // Skip all other roles lines + continue + } + result = append(result, line) } - } - } + } else { + // on: block exists - add roles to it + rolesCodemodLog.Print("Found 'on:' block - adding roles to it") + + // Determine indentation for items inside on: block + onItemIndent := onIndent + " " - rolesCodemodLog.Printf("Roles spans lines %d to %d (%d lines)", rolesLineIdx+1, rolesEndIdx+1, len(rolesLines)) + // Track if we've inserted roles + insertedRoles := false - // If no on: block found, we need to create one - result := make([]string, 0, len(frontmatterLines)) - modified := false + for i, line := range frontmatterLines { + // Skip the original roles lines + if i >= rolesLineIdx && i <= rolesEndIdx { + continue + } - if onBlockIdx == -1 { - // No on: block exists - create one with roles inside it - rolesCodemodLog.Print("No 'on:' block found - creating new one with roles") + // Add the line + result = append(result, line) - for i, line := range frontmatterLines { - if i >= rolesLineIdx && i <= rolesEndIdx { - // Skip the original roles lines - we'll add them to the new on: block - if i == rolesLineIdx { - // Add new on: block with roles inside - result = append(result, "on:") - // Add roles lines with proper indentation + // After the on: line, insert roles + if i == onBlockIdx && !insertedRoles { + // Add roles lines with proper indentation inside on: block for _, rolesLine := range rolesLines { trimmed := strings.TrimSpace(rolesLine) if trimmed == "" { result = append(result, rolesLine) - } else if strings.HasPrefix(trimmed, "roles:") { - // roles: line gets 2 spaces (nested under on:) - result = append(result, " "+rolesLine) } else { - // Array items get 4 spaces (nested under on: and roles:) - result = append(result, " "+trimmed) - } - } - modified = true - } - // Skip all other roles lines - continue - } - result = append(result, line) - } - } else { - // on: block exists - add roles to it - rolesCodemodLog.Print("Found 'on:' block - adding roles to it") - - // Determine indentation for items inside on: block - onItemIndent := onIndent + " " - - // Track if we've inserted roles - insertedRoles := false - - for i, line := range frontmatterLines { - // Skip the original roles lines - if i >= rolesLineIdx && i <= rolesEndIdx { - modified = true - continue - } - - // Add the line - result = append(result, line) - - // After the on: line, insert roles - if i == onBlockIdx && !insertedRoles { - // Add roles lines with proper indentation inside on: block - for _, rolesLine := range rolesLines { - trimmed := strings.TrimSpace(rolesLine) - if trimmed == "" { - result = append(result, rolesLine) - } else { - // Adjust indentation to be nested under on: - // Remove "roles:" prefix and re-add with proper indentation - if strings.HasPrefix(trimmed, "roles:") { - // roles: value or roles: - parts := strings.SplitN(trimmed, ":", 2) - if len(parts) == 2 { - result = append(result, fmt.Sprintf("%sroles:%s", onItemIndent, parts[1])) + // Adjust indentation to be nested under on: + // Remove "roles:" prefix and re-add with proper indentation + if strings.HasPrefix(trimmed, "roles:") { + // roles: value or roles: + parts := strings.SplitN(trimmed, ":", 2) + if len(parts) == 2 { + result = append(result, fmt.Sprintf("%sroles:%s", onItemIndent, parts[1])) + } else { + result = append(result, onItemIndent+"roles:") + } } else { - result = append(result, onItemIndent+"roles:") + // Array item line (e.g., "- admin") + // These should be indented 2 more spaces than roles: to be nested under it + result = append(result, onItemIndent+" "+trimmed) } - } else { - // Array item line (e.g., "- admin") - // These should be indented 2 more spaces than roles: to be nested under it - result = append(result, onItemIndent+" "+trimmed) } } + insertedRoles = true } - insertedRoles = true } } - } - - if !modified { - return content, false, nil - } - // Reconstruct the content - newContent := reconstructContent(result, markdown) - rolesCodemodLog.Print("Successfully migrated top-level 'roles' to 'on.roles'") - return newContent, true, nil + rolesCodemodLog.Print("Successfully migrated top-level 'roles' to 'on.roles'") + return result, true + }) }, } } diff --git a/pkg/cli/codemod_safe_inputs.go b/pkg/cli/codemod_safe_inputs.go index 22e25b03b9..8bb6a2dc05 100644 --- a/pkg/cli/codemod_safe_inputs.go +++ b/pkg/cli/codemod_safe_inputs.go @@ -31,22 +31,13 @@ func getSafeInputsModeCodemod() Codemod { return content, false, nil } - // Parse frontmatter to get raw lines - frontmatterLines, markdown, err := parseFrontmatterLines(content) - if err != nil { - return content, false, err + newContent, applied, err := applyFrontmatterLineTransform(content, func(lines []string) ([]string, bool) { + return removeFieldFromBlock(lines, "mode", "safe-inputs") + }) + if applied { + safeInputsModeCodemodLog.Print("Applied safe-inputs.mode removal") } - - // Remove the mode field from the safe-inputs block - result, modified := removeFieldFromBlock(frontmatterLines, "mode", "safe-inputs") - if !modified { - return content, false, nil - } - - // Reconstruct the content - newContent := reconstructContent(result, markdown) - safeInputsModeCodemodLog.Print("Applied safe-inputs.mode removal") - return newContent, true, nil + return newContent, applied, err }, } } diff --git a/pkg/cli/codemod_sandbox_agent.go b/pkg/cli/codemod_sandbox_agent.go index 7524552052..99554db035 100644 --- a/pkg/cli/codemod_sandbox_agent.go +++ b/pkg/cli/codemod_sandbox_agent.go @@ -28,46 +28,36 @@ func getSandboxFalseToAgentFalseCodemod() Codemod { return content, false, nil } - // Parse frontmatter to get raw lines - frontmatterLines, markdown, err := parseFrontmatterLines(content) - if err != nil { - return content, false, err - } - - // Find and replace "sandbox: false" line - var modified bool - result := make([]string, 0, len(frontmatterLines)) - - for i, line := range frontmatterLines { - trimmedLine := strings.TrimSpace(line) - - // Check if this is the "sandbox: false" line - if strings.HasPrefix(trimmedLine, "sandbox:") { - if strings.Contains(trimmedLine, "sandbox: false") || strings.Contains(trimmedLine, "sandbox:false") { - // Get the indentation of the original line - indent := getIndentation(line) - - // Replace with sandbox.agent: false format - result = append(result, indent+"sandbox:") - result = append(result, indent+" agent: false") - - modified = true - sandboxAgentCodemodLog.Printf("Converted sandbox: false to sandbox.agent: false on line %d", i+1) - continue + newContent, applied, err := applyFrontmatterLineTransform(content, func(lines []string) ([]string, bool) { + var modified bool + result := make([]string, 0, len(lines)) + for i, line := range lines { + trimmedLine := strings.TrimSpace(line) + + // Check if this is the "sandbox: false" line + if strings.HasPrefix(trimmedLine, "sandbox:") { + if strings.Contains(trimmedLine, "sandbox: false") || strings.Contains(trimmedLine, "sandbox:false") { + // Get the indentation of the original line + indent := getIndentation(line) + + // Replace with sandbox.agent: false format + result = append(result, indent+"sandbox:") + result = append(result, indent+" agent: false") + + modified = true + sandboxAgentCodemodLog.Printf("Converted sandbox: false to sandbox.agent: false on line %d", i+1) + continue + } } - } - - result = append(result, line) - } - if !modified { - return content, false, nil + result = append(result, line) + } + return result, modified + }) + if applied { + sandboxAgentCodemodLog.Print("Applied sandbox: false to sandbox.agent: false conversion") } - - // Reconstruct the content - newContent := reconstructContent(result, markdown) - sandboxAgentCodemodLog.Print("Applied sandbox: false to sandbox.agent: false conversion") - return newContent, true, nil + return newContent, applied, err }, } } diff --git a/pkg/cli/codemod_schedule.go b/pkg/cli/codemod_schedule.go index 9871f39f8e..82387cdb4b 100644 --- a/pkg/cli/codemod_schedule.go +++ b/pkg/cli/codemod_schedule.go @@ -17,124 +17,116 @@ func getScheduleAtToAroundCodemod() Codemod { Description: "Converts deprecated 'daily at TIME', 'weekly on DAY at TIME', and 'monthly on N at TIME' to fuzzy schedules or standard cron", IntroducedIn: "0.5.0", Apply: func(content string, frontmatter map[string]any) (string, bool, error) { - // Parse frontmatter to get raw lines - frontmatterLines, markdown, err := parseFrontmatterLines(content) - if err != nil { - return content, false, err - } + return applyFrontmatterLineTransform(content, func(lines []string) ([]string, bool) { + var modified bool + result := make([]string, len(lines)) - var modified bool - result := make([]string, len(frontmatterLines)) + for i, line := range lines { + trimmedLine := strings.TrimSpace(line) + originalLine := line - for i, line := range frontmatterLines { - trimmedLine := strings.TrimSpace(line) - originalLine := line - - // Skip if not a cron or schedule line - if !strings.Contains(trimmedLine, "cron:") && !strings.Contains(trimmedLine, "schedule:") { - result[i] = originalLine - continue - } - - // Extract leading whitespace to preserve indentation - leadingSpace := getIndentation(line) - - // Check if this is a list item (starts with - after whitespace) - restAfterSpace := strings.TrimLeft(line, " \t") - var listMarker string - if strings.HasPrefix(restAfterSpace, "-") { - // This is a list item, preserve the dash - listMarker = "- " - } + // Skip if not a cron or schedule line + if !strings.Contains(trimmedLine, "cron:") && !strings.Contains(trimmedLine, "schedule:") { + result[i] = originalLine + continue + } - // Extract the schedule value (after "cron:" or "schedule:") - var scheduleValue string - var fieldName string + // Extract leading whitespace to preserve indentation + leadingSpace := getIndentation(line) - if strings.Contains(trimmedLine, "cron:") { - parts := strings.SplitN(trimmedLine, "cron:", 2) - if len(parts) == 2 { - fieldName = "cron" - scheduleValue = strings.TrimSpace(parts[1]) + // Check if this is a list item (starts with - after whitespace) + restAfterSpace := strings.TrimLeft(line, " \t") + var listMarker string + if strings.HasPrefix(restAfterSpace, "-") { + // This is a list item, preserve the dash + listMarker = "- " } - } else if strings.Contains(trimmedLine, "schedule:") { - parts := strings.SplitN(trimmedLine, "schedule:", 2) - if len(parts) == 2 { - fieldName = "schedule" - scheduleValue = strings.TrimSpace(parts[1]) - } - } - if scheduleValue == "" { - result[i] = originalLine - continue - } + // Extract the schedule value (after "cron:" or "schedule:") + var scheduleValue string + var fieldName string - // Remove quotes if present - scheduleValue = strings.Trim(scheduleValue, "\"'") + if strings.Contains(trimmedLine, "cron:") { + parts := strings.SplitN(trimmedLine, "cron:", 2) + if len(parts) == 2 { + fieldName = "cron" + scheduleValue = strings.TrimSpace(parts[1]) + } + } else if strings.Contains(trimmedLine, "schedule:") { + parts := strings.SplitN(trimmedLine, "schedule:", 2) + if len(parts) == 2 { + fieldName = "schedule" + scheduleValue = strings.TrimSpace(parts[1]) + } + } - // Pattern 1: daily at TIME (not "daily around" or "daily between") - if strings.HasPrefix(scheduleValue, "daily at") && !strings.Contains(scheduleValue, "around") && !strings.Contains(scheduleValue, "between") { - newSchedule := strings.Replace(scheduleValue, "daily at", "daily around", 1) - result[i] = fmt.Sprintf("%s%s%s: %s", leadingSpace, listMarker, fieldName, newSchedule) - modified = true - scheduleCodemodLog.Printf("Converted 'daily at' to 'daily around' on line %d: %s -> %s", i+1, scheduleValue, newSchedule) - continue - } + if scheduleValue == "" { + result[i] = originalLine + continue + } - // Pattern 2: weekly on DAY at TIME - if strings.Contains(scheduleValue, "weekly on") && strings.Contains(scheduleValue, " at ") && !strings.Contains(scheduleValue, "around") { - newSchedule := strings.Replace(scheduleValue, " at ", " around ", 1) - result[i] = fmt.Sprintf("%s%s%s: %s", leadingSpace, listMarker, fieldName, newSchedule) - modified = true - scheduleCodemodLog.Printf("Converted 'weekly on DAY at' to 'weekly on DAY around' on line %d: %s -> %s", i+1, scheduleValue, newSchedule) - continue - } + // Remove quotes if present + scheduleValue = strings.Trim(scheduleValue, "\"'") - // Pattern 3: monthly on N [at TIME] - convert to cron - if strings.HasPrefix(scheduleValue, "monthly on") { - // Extract day number - var day string - var cronExpr string - - monthlyParts := strings.Fields(scheduleValue) - for idx, part := range monthlyParts { - if part == "on" && idx+1 < len(monthlyParts) { - day = monthlyParts[idx+1] - break - } + // Pattern 1: daily at TIME (not "daily around" or "daily between") + if strings.HasPrefix(scheduleValue, "daily at") && !strings.Contains(scheduleValue, "around") && !strings.Contains(scheduleValue, "between") { + newSchedule := strings.Replace(scheduleValue, "daily at", "daily around", 1) + result[i] = fmt.Sprintf("%s%s%s: %s", leadingSpace, listMarker, fieldName, newSchedule) + modified = true + scheduleCodemodLog.Printf("Converted 'daily at' to 'daily around' on line %d: %s -> %s", i+1, scheduleValue, newSchedule) + continue } - if day != "" { - // Check if there's a time specification - if strings.Contains(scheduleValue, " at ") { - // Has time - default to 09:00 as example since we can't parse arbitrary times in codemod - // The user should manually adjust the hour/minute if needed - cronExpr = fmt.Sprintf("0 9 %s * *", day) - } else { - // No time - suggest midnight - cronExpr = fmt.Sprintf("0 0 %s * *", day) - } - - // Replace with cron and add explanatory comment - result[i] = fmt.Sprintf("%s%s%s: \"%s\" # Converted from '%s' (adjust time as needed)", leadingSpace, listMarker, fieldName, cronExpr, scheduleValue) + // Pattern 2: weekly on DAY at TIME + if strings.Contains(scheduleValue, "weekly on") && strings.Contains(scheduleValue, " at ") && !strings.Contains(scheduleValue, "around") { + newSchedule := strings.Replace(scheduleValue, " at ", " around ", 1) + result[i] = fmt.Sprintf("%s%s%s: %s", leadingSpace, listMarker, fieldName, newSchedule) modified = true - scheduleCodemodLog.Printf("Converted 'monthly on' to cron on line %d: %s -> %s", i+1, scheduleValue, cronExpr) + scheduleCodemodLog.Printf("Converted 'weekly on DAY at' to 'weekly on DAY around' on line %d: %s -> %s", i+1, scheduleValue, newSchedule) continue } - } - result[i] = originalLine - } + // Pattern 3: monthly on N [at TIME] - convert to cron + if strings.HasPrefix(scheduleValue, "monthly on") { + // Extract day number + var day string + var cronExpr string + + monthlyParts := strings.Fields(scheduleValue) + for idx, part := range monthlyParts { + if part == "on" && idx+1 < len(monthlyParts) { + day = monthlyParts[idx+1] + break + } + } + + if day != "" { + // Check if there's a time specification + if strings.Contains(scheduleValue, " at ") { + // Has time - default to 09:00 as example since we can't parse arbitrary times in codemod + // The user should manually adjust the hour/minute if needed + cronExpr = fmt.Sprintf("0 9 %s * *", day) + } else { + // No time - suggest midnight + cronExpr = fmt.Sprintf("0 0 %s * *", day) + } + + // Replace with cron and add explanatory comment + result[i] = fmt.Sprintf("%s%s%s: \"%s\" # Converted from '%s' (adjust time as needed)", leadingSpace, listMarker, fieldName, cronExpr, scheduleValue) + modified = true + scheduleCodemodLog.Printf("Converted 'monthly on' to cron on line %d: %s -> %s", i+1, scheduleValue, cronExpr) + continue + } + } - if !modified { - return content, false, nil - } + result[i] = originalLine + } - // Reconstruct the content - newContent := reconstructContent(result, markdown) - scheduleCodemodLog.Print("Applied schedule 'at' to 'around' migration") - return newContent, true, nil + if modified { + scheduleCodemodLog.Print("Applied schedule 'at' to 'around' migration") + } + return result, modified + }) }, } } diff --git a/pkg/cli/codemod_slash_command.go b/pkg/cli/codemod_slash_command.go index dd04abab58..99db468e3f 100644 --- a/pkg/cli/codemod_slash_command.go +++ b/pkg/cli/codemod_slash_command.go @@ -1,6 +1,8 @@ package cli import ( + "strings" + "github.com/github/gh-aw/pkg/logger" ) @@ -31,102 +33,49 @@ func getCommandToSlashCommandCodemod() Codemod { return content, false, nil } - // Parse frontmatter to get raw lines - frontmatterLines, markdown, err := parseFrontmatterLines(content) - if err != nil { - return content, false, err - } - - // Find and replace the command line within the on: block - var modified bool - var inOnBlock bool - var onIndent string - - result := make([]string, len(frontmatterLines)) - - for i, line := range frontmatterLines { - trimmedLine := getTrimmedLine(line) - - // Track if we're in the on block - if startsWithKey(trimmedLine, "on") { - inOnBlock = true - onIndent = getIndentation(line) - result[i] = line - continue - } + newContent, applied, err := applyFrontmatterLineTransform(content, func(lines []string) ([]string, bool) { + var modified bool + var inOnBlock bool + var onIndent string + result := make([]string, len(lines)) + for i, line := range lines { + trimmedLine := strings.TrimSpace(line) + + // Track if we're in the on block + if strings.HasPrefix(trimmedLine, "on:") { + inOnBlock = true + onIndent = getIndentation(line) + result[i] = line + continue + } - // Check if we've left the on block - if inOnBlock && len(trimmedLine) > 0 && !isComment(trimmedLine) { - if hasExitedBlock(line, onIndent) { - inOnBlock = false + // Check if we've left the on block + if inOnBlock && len(trimmedLine) > 0 && !strings.HasPrefix(trimmedLine, "#") { + if hasExitedBlock(line, onIndent) { + inOnBlock = false + } } - } - // Replace command with slash_command if in on block - if inOnBlock && startsWithKey(trimmedLine, "command") { - replacedLine, didReplace := findAndReplaceInLine(line, "command", "slash_command") - if didReplace { - result[i] = replacedLine - modified = true - slashCommandCodemodLog.Printf("Replaced on.command with on.slash_command on line %d", i+1) + // Replace command with slash_command if in on block + if inOnBlock && strings.HasPrefix(trimmedLine, "command:") { + replacedLine, didReplace := findAndReplaceInLine(line, "command", "slash_command") + if didReplace { + result[i] = replacedLine + modified = true + slashCommandCodemodLog.Printf("Replaced on.command with on.slash_command on line %d", i+1) + } else { + result[i] = line + } } else { result[i] = line } - } else { - result[i] = line } + return result, modified + }) + if applied { + slashCommandCodemodLog.Print("Applied on.command to on.slash_command migration") } - - if !modified { - return content, false, nil - } - - // Reconstruct the content - newContent := reconstructContent(result, markdown) - slashCommandCodemodLog.Print("Applied on.command to on.slash_command migration") - return newContent, true, nil + return newContent, applied, err }, } } - -// Helper functions for better readability -func getTrimmedLine(line string) string { - return trimSpace(line) -} - -func startsWithKey(line, key string) bool { - return startsWithPrefix(line, key+":") -} - -func isComment(line string) bool { - return startsWithPrefix(line, "#") -} - -func startsWithPrefix(s, prefix string) bool { - return len(s) >= len(prefix) && s[:len(prefix)] == prefix -} - -func trimSpace(s string) string { - start := 0 - end := len(s) - - // Trim leading whitespace - for start < end { - c := s[start] - if c != ' ' && c != '\t' && c != '\n' && c != '\r' { - break - } - start++ - } - - // Trim trailing whitespace - for end > start { - c := s[end-1] - if c != ' ' && c != '\t' && c != '\n' && c != '\r' { - break - } - end-- - } - - return s[start:end] -} diff --git a/pkg/cli/codemod_timeout_minutes.go b/pkg/cli/codemod_timeout_minutes.go index 7840f9f72e..62c651c533 100644 --- a/pkg/cli/codemod_timeout_minutes.go +++ b/pkg/cli/codemod_timeout_minutes.go @@ -20,34 +20,25 @@ func getTimeoutMinutesCodemod() Codemod { return content, false, nil } - // Parse frontmatter to get raw lines - frontmatterLines, markdown, err := parseFrontmatterLines(content) - if err != nil { - return content, false, err - } - - // Replace the field in raw lines while preserving formatting - var modified bool - result := make([]string, len(frontmatterLines)) - for i, line := range frontmatterLines { - replacedLine, didReplace := findAndReplaceInLine(line, "timeout_minutes", "timeout-minutes") - if didReplace { - result[i] = replacedLine - modified = true - timeoutMinutesCodemodLog.Printf("Replaced timeout_minutes with timeout-minutes on line %d", i+1) - } else { - result[i] = line + newContent, applied, err := applyFrontmatterLineTransform(content, func(lines []string) ([]string, bool) { + result := make([]string, len(lines)) + var modified bool + for i, line := range lines { + replacedLine, didReplace := findAndReplaceInLine(line, "timeout_minutes", "timeout-minutes") + if didReplace { + result[i] = replacedLine + modified = true + timeoutMinutesCodemodLog.Printf("Replaced timeout_minutes with timeout-minutes on line %d", i+1) + } else { + result[i] = line + } } + return result, modified + }) + if applied { + timeoutMinutesCodemodLog.Printf("Applied timeout_minutes migration (value: %v)", value) } - - if !modified { - return content, false, nil - } - - // Reconstruct the content - newContent := reconstructContent(result, markdown) - timeoutMinutesCodemodLog.Printf("Applied timeout_minutes migration (value: %v)", value) - return newContent, true, nil + return newContent, applied, err }, } } diff --git a/pkg/cli/codemod_upload_assets.go b/pkg/cli/codemod_upload_assets.go index cbddfb151b..0909bb3d94 100644 --- a/pkg/cli/codemod_upload_assets.go +++ b/pkg/cli/codemod_upload_assets.go @@ -33,60 +33,49 @@ func getUploadAssetsCodemod() Codemod { return content, false, nil } - // Parse frontmatter to get raw lines - frontmatterLines, markdown, err := parseFrontmatterLines(content) - if err != nil { - return content, false, err - } - - // Find and replace upload-assets with upload-asset within the safe-outputs block - var modified bool - var inSafeOutputsBlock bool - var safeOutputsIndent string - - result := make([]string, len(frontmatterLines)) + newContent, applied, err := applyFrontmatterLineTransform(content, func(lines []string) ([]string, bool) { + var modified bool + var inSafeOutputsBlock bool + var safeOutputsIndent string + result := make([]string, len(lines)) + for i, line := range lines { + trimmedLine := strings.TrimSpace(line) - for i, line := range frontmatterLines { - trimmedLine := strings.TrimSpace(line) - - // Track if we're in the safe-outputs block - if strings.HasPrefix(trimmedLine, "safe-outputs:") { - inSafeOutputsBlock = true - safeOutputsIndent = getIndentation(line) - result[i] = line - continue - } + // Track if we're in the safe-outputs block + if strings.HasPrefix(trimmedLine, "safe-outputs:") { + inSafeOutputsBlock = true + safeOutputsIndent = getIndentation(line) + result[i] = line + continue + } - // Check if we've left the safe-outputs block - if inSafeOutputsBlock && len(trimmedLine) > 0 && !strings.HasPrefix(trimmedLine, "#") { - if hasExitedBlock(line, safeOutputsIndent) { - inSafeOutputsBlock = false + // Check if we've left the safe-outputs block + if inSafeOutputsBlock && len(trimmedLine) > 0 && !strings.HasPrefix(trimmedLine, "#") { + if hasExitedBlock(line, safeOutputsIndent) { + inSafeOutputsBlock = false + } } - } - // Replace upload-assets with upload-asset if in safe-outputs block - if inSafeOutputsBlock && strings.HasPrefix(trimmedLine, "upload-assets:") { - replacedLine, didReplace := findAndReplaceInLine(line, "upload-assets", "upload-asset") - if didReplace { - result[i] = replacedLine - modified = true - uploadAssetsCodemodLog.Printf("Replaced safe-outputs.upload-assets with safe-outputs.upload-asset on line %d", i+1) + // Replace upload-assets with upload-asset if in safe-outputs block + if inSafeOutputsBlock && strings.HasPrefix(trimmedLine, "upload-assets:") { + replacedLine, didReplace := findAndReplaceInLine(line, "upload-assets", "upload-asset") + if didReplace { + result[i] = replacedLine + modified = true + uploadAssetsCodemodLog.Printf("Replaced safe-outputs.upload-assets with safe-outputs.upload-asset on line %d", i+1) + } else { + result[i] = line + } } else { result[i] = line } - } else { - result[i] = line } + return result, modified + }) + if applied { + uploadAssetsCodemodLog.Print("Applied upload-assets to upload-asset migration") } - - if !modified { - return content, false, nil - } - - // Reconstruct the content - newContent := reconstructContent(result, markdown) - uploadAssetsCodemodLog.Print("Applied upload-assets to upload-asset migration") - return newContent, true, nil + return newContent, applied, err }, } } diff --git a/pkg/cli/codemod_yaml_utils.go b/pkg/cli/codemod_yaml_utils.go index 9ae89fd31e..2a818d75d6 100644 --- a/pkg/cli/codemod_yaml_utils.go +++ b/pkg/cli/codemod_yaml_utils.go @@ -91,6 +91,24 @@ func findAndReplaceInLine(line, oldKey, newKey string) (string, bool) { return fmt.Sprintf("%s%s:%s", leadingSpace, newKey, valueAndComment), true } +// applyFrontmatterLineTransform parses frontmatter from content, applies a transform +// function to the frontmatter lines, and reconstructs the content if any changes were made. +// The transform function receives the frontmatter lines and returns the modified lines +// and a boolean indicating whether any changes were made. +func applyFrontmatterLineTransform(content string, transform func([]string) ([]string, bool)) (string, bool, error) { + frontmatterLines, markdown, err := parseFrontmatterLines(content) + if err != nil { + return content, false, err + } + + result, modified := transform(frontmatterLines) + if !modified { + return content, false, nil + } + + return reconstructContent(result, markdown), true, nil +} + // removeFieldFromBlock removes a field and its nested content from a YAML block // Returns the modified lines and whether any changes were made func removeFieldFromBlock(lines []string, fieldName string, parentBlock string) ([]string, bool) { diff --git a/pkg/cli/codemod_yaml_utils_test.go b/pkg/cli/codemod_yaml_utils_test.go index 5c502d1fc8..163e4f7207 100644 --- a/pkg/cli/codemod_yaml_utils_test.go +++ b/pkg/cli/codemod_yaml_utils_test.go @@ -479,3 +479,98 @@ func TestRemoveFieldFromBlock_PreservesComments(t *testing.T) { assert.Contains(t, strings.Join(result, "\n"), "allowed:", "Other network fields should be preserved") assert.Contains(t, strings.Join(result, "\n"), "permissions:", "Other top-level fields should be preserved") } + +func TestApplyFrontmatterLineTransform(t *testing.T) { + tests := []struct { + name string + content string + transform func([]string) ([]string, bool) + wantApplied bool + wantContent string + wantErr bool + }{ + { + name: "transform applied", + content: `--- +on: workflow_dispatch +timeout_minutes: 30 +--- + +# Test`, + transform: func(lines []string) ([]string, bool) { + result := make([]string, len(lines)) + modified := false + for i, line := range lines { + if strings.Contains(line, "timeout_minutes") { + result[i] = strings.ReplaceAll(line, "timeout_minutes", "timeout-minutes") + modified = true + } else { + result[i] = line + } + } + return result, modified + }, + wantApplied: true, + wantContent: "timeout-minutes: 30", + }, + { + name: "no change returns original content", + content: `--- +on: workflow_dispatch +--- + +# Test`, + transform: func(lines []string) ([]string, bool) { + return lines, false + }, + wantApplied: false, + }, + { + name: "content with no frontmatter is handled gracefully", + content: "not valid frontmatter at all", + transform: func(lines []string) ([]string, bool) { + // transform returns false because there are no lines to modify + return lines, false + }, + wantApplied: false, + }, + { + name: "markdown body preserved", + content: `--- +on: workflow_dispatch +--- + +# My Workflow + +Some description here.`, + transform: func(lines []string) ([]string, bool) { + result := make([]string, len(lines)) + copy(result, lines) + result[0] = "on: push" + return result, true + }, + wantApplied: true, + wantContent: "# My Workflow", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, applied, err := applyFrontmatterLineTransform(tt.content, tt.transform) + + if tt.wantErr { + assert.Error(t, err, "Expected error") + return + } + + require.NoError(t, err, "Should not return an error") + assert.Equal(t, tt.wantApplied, applied, "Applied status should match") + + if tt.wantApplied { + assert.Contains(t, result, tt.wantContent, "Transformed content should be present") + } else { + assert.Equal(t, tt.content, result, "Original content should be returned when not applied") + } + }) + } +}