diff --git a/.github/workflows/cli-version-checker.lock.yml b/.github/workflows/cli-version-checker.lock.yml index 294506cfce3..2b8f9094a22 100644 --- a/.github/workflows/cli-version-checker.lock.yml +++ b/.github/workflows/cli-version-checker.lock.yml @@ -3013,19 +3013,26 @@ jobs: script: | const fs = require("fs"); const crypto = require("crypto"); + function generatePatchPreview(patchContent) { + if (!patchContent || !patchContent.trim()) { + return ""; + } + const lines = patchContent.split("\n"); + const maxLines = 500; + const maxChars = 2000; + let preview = lines.length <= maxLines ? patchContent : lines.slice(0, maxLines).join("\n"); + const lineTruncated = lines.length > maxLines; + const charTruncated = preview.length > maxChars; + if (charTruncated) { + preview = preview.slice(0, maxChars); + } + const truncated = lineTruncated || charTruncated; + const summary = truncated + ? `Show patch preview (${Math.min(maxLines, lines.length)} of ${lines.length} lines)` + : `Show patch (${lines.length} lines)`; + return `\n\n
${summary}\n\n\`\`\`diff\n${preview}${truncated ? "\n... (truncated)" : ""}\n\`\`\`\n\n
`; + } async function main() { - function generatePatchPreview(patchContent) { - if (!patchContent || !patchContent.trim()) { - return ""; - } - const lines = patchContent.split("\n"); - const maxLines = 500; - if (lines.length <= maxLines) { - return `\n\n
Show patch (${lines.length} lines)\n\n\`\`\`diff\n${patchContent}\n\`\`\`\n\n
`; - } - const preview = lines.slice(0, maxLines).join("\n"); - return `\n\n
Show patch preview (${maxLines} of ${lines.length} lines)\n\n\`\`\`diff\n${preview}\n... (truncated)\n\`\`\`\n\n
`; - } const isStaged = process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true"; const workflowId = process.env.GITHUB_AW_WORKFLOW_ID; if (!workflowId) { diff --git a/.github/workflows/poem-bot.lock.yml b/.github/workflows/poem-bot.lock.yml index a6e468fa508..b036d5c6240 100644 --- a/.github/workflows/poem-bot.lock.yml +++ b/.github/workflows/poem-bot.lock.yml @@ -4524,19 +4524,26 @@ jobs: script: | const fs = require("fs"); const crypto = require("crypto"); - async function main() { - function generatePatchPreview(patchContent) { - if (!patchContent || !patchContent.trim()) { - return ""; - } - const lines = patchContent.split("\n"); - const maxLines = 500; - if (lines.length <= maxLines) { - return `\n\n
Show patch (${lines.length} lines)\n\n\`\`\`diff\n${patchContent}\n\`\`\`\n\n
`; - } - const preview = lines.slice(0, maxLines).join("\n"); - return `\n\n
Show patch preview (${maxLines} of ${lines.length} lines)\n\n\`\`\`diff\n${preview}\n... (truncated)\n\`\`\`\n\n
`; + function generatePatchPreview(patchContent) { + if (!patchContent || !patchContent.trim()) { + return ""; } + const lines = patchContent.split("\n"); + const maxLines = 500; + const maxChars = 2000; + let preview = lines.length <= maxLines ? patchContent : lines.slice(0, maxLines).join("\n"); + const lineTruncated = lines.length > maxLines; + const charTruncated = preview.length > maxChars; + if (charTruncated) { + preview = preview.slice(0, maxChars); + } + const truncated = lineTruncated || charTruncated; + const summary = truncated + ? `Show patch preview (${Math.min(maxLines, lines.length)} of ${lines.length} lines)` + : `Show patch (${lines.length} lines)`; + return `\n\n
${summary}\n\n\`\`\`diff\n${preview}${truncated ? "\n... (truncated)" : ""}\n\`\`\`\n\n
`; + } + async function main() { const isStaged = process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true"; const workflowId = process.env.GITHUB_AW_WORKFLOW_ID; if (!workflowId) { diff --git a/.github/workflows/security-fix-pr.lock.yml b/.github/workflows/security-fix-pr.lock.yml index 4aabaa1ace1..113104e0792 100644 --- a/.github/workflows/security-fix-pr.lock.yml +++ b/.github/workflows/security-fix-pr.lock.yml @@ -3101,19 +3101,26 @@ jobs: script: | const fs = require("fs"); const crypto = require("crypto"); + function generatePatchPreview(patchContent) { + if (!patchContent || !patchContent.trim()) { + return ""; + } + const lines = patchContent.split("\n"); + const maxLines = 500; + const maxChars = 2000; + let preview = lines.length <= maxLines ? patchContent : lines.slice(0, maxLines).join("\n"); + const lineTruncated = lines.length > maxLines; + const charTruncated = preview.length > maxChars; + if (charTruncated) { + preview = preview.slice(0, maxChars); + } + const truncated = lineTruncated || charTruncated; + const summary = truncated + ? `Show patch preview (${Math.min(maxLines, lines.length)} of ${lines.length} lines)` + : `Show patch (${lines.length} lines)`; + return `\n\n
${summary}\n\n\`\`\`diff\n${preview}${truncated ? "\n... (truncated)" : ""}\n\`\`\`\n\n
`; + } async function main() { - function generatePatchPreview(patchContent) { - if (!patchContent || !patchContent.trim()) { - return ""; - } - const lines = patchContent.split("\n"); - const maxLines = 500; - if (lines.length <= maxLines) { - return `\n\n
Show patch (${lines.length} lines)\n\n\`\`\`diff\n${patchContent}\n\`\`\`\n\n
`; - } - const preview = lines.slice(0, maxLines).join("\n"); - return `\n\n
Show patch preview (${maxLines} of ${lines.length} lines)\n\n\`\`\`diff\n${preview}\n... (truncated)\n\`\`\`\n\n
`; - } const isStaged = process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true"; const workflowId = process.env.GITHUB_AW_WORKFLOW_ID; if (!workflowId) { diff --git a/.github/workflows/technical-doc-writer.lock.yml b/.github/workflows/technical-doc-writer.lock.yml index f05ea1fb8c4..feaffdc3e8a 100644 --- a/.github/workflows/technical-doc-writer.lock.yml +++ b/.github/workflows/technical-doc-writer.lock.yml @@ -3478,19 +3478,26 @@ jobs: script: | const fs = require("fs"); const crypto = require("crypto"); - async function main() { - function generatePatchPreview(patchContent) { - if (!patchContent || !patchContent.trim()) { - return ""; - } - const lines = patchContent.split("\n"); - const maxLines = 500; - if (lines.length <= maxLines) { - return `\n\n
Show patch (${lines.length} lines)\n\n\`\`\`diff\n${patchContent}\n\`\`\`\n\n
`; - } - const preview = lines.slice(0, maxLines).join("\n"); - return `\n\n
Show patch preview (${maxLines} of ${lines.length} lines)\n\n\`\`\`diff\n${preview}\n... (truncated)\n\`\`\`\n\n
`; + function generatePatchPreview(patchContent) { + if (!patchContent || !patchContent.trim()) { + return ""; } + const lines = patchContent.split("\n"); + const maxLines = 500; + const maxChars = 2000; + let preview = lines.length <= maxLines ? patchContent : lines.slice(0, maxLines).join("\n"); + const lineTruncated = lines.length > maxLines; + const charTruncated = preview.length > maxChars; + if (charTruncated) { + preview = preview.slice(0, maxChars); + } + const truncated = lineTruncated || charTruncated; + const summary = truncated + ? `Show patch preview (${Math.min(maxLines, lines.length)} of ${lines.length} lines)` + : `Show patch (${lines.length} lines)`; + return `\n\n
${summary}\n\n\`\`\`diff\n${preview}${truncated ? "\n... (truncated)" : ""}\n\`\`\`\n\n
`; + } + async function main() { const isStaged = process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true"; const workflowId = process.env.GITHUB_AW_WORKFLOW_ID; if (!workflowId) { diff --git a/.github/workflows/tidy.lock.yml b/.github/workflows/tidy.lock.yml index db9557c0945..32b70668b0a 100644 --- a/.github/workflows/tidy.lock.yml +++ b/.github/workflows/tidy.lock.yml @@ -3493,19 +3493,26 @@ jobs: script: | const fs = require("fs"); const crypto = require("crypto"); - async function main() { - function generatePatchPreview(patchContent) { - if (!patchContent || !patchContent.trim()) { - return ""; - } - const lines = patchContent.split("\n"); - const maxLines = 500; - if (lines.length <= maxLines) { - return `\n\n
Show patch (${lines.length} lines)\n\n\`\`\`diff\n${patchContent}\n\`\`\`\n\n
`; - } - const preview = lines.slice(0, maxLines).join("\n"); - return `\n\n
Show patch preview (${maxLines} of ${lines.length} lines)\n\n\`\`\`diff\n${preview}\n... (truncated)\n\`\`\`\n\n
`; + function generatePatchPreview(patchContent) { + if (!patchContent || !patchContent.trim()) { + return ""; } + const lines = patchContent.split("\n"); + const maxLines = 500; + const maxChars = 2000; + let preview = lines.length <= maxLines ? patchContent : lines.slice(0, maxLines).join("\n"); + const lineTruncated = lines.length > maxLines; + const charTruncated = preview.length > maxChars; + if (charTruncated) { + preview = preview.slice(0, maxChars); + } + const truncated = lineTruncated || charTruncated; + const summary = truncated + ? `Show patch preview (${Math.min(maxLines, lines.length)} of ${lines.length} lines)` + : `Show patch (${lines.length} lines)`; + return `\n\n
${summary}\n\n\`\`\`diff\n${preview}${truncated ? "\n... (truncated)" : ""}\n\`\`\`\n\n
`; + } + async function main() { const isStaged = process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true"; const workflowId = process.env.GITHUB_AW_WORKFLOW_ID; if (!workflowId) { diff --git a/.github/workflows/unbloat-docs.lock.yml b/.github/workflows/unbloat-docs.lock.yml index 501ef36a8a4..bf70d08ebab 100644 --- a/.github/workflows/unbloat-docs.lock.yml +++ b/.github/workflows/unbloat-docs.lock.yml @@ -3715,19 +3715,26 @@ jobs: script: | const fs = require("fs"); const crypto = require("crypto"); - async function main() { - function generatePatchPreview(patchContent) { - if (!patchContent || !patchContent.trim()) { - return ""; - } - const lines = patchContent.split("\n"); - const maxLines = 500; - if (lines.length <= maxLines) { - return `\n\n
Show patch (${lines.length} lines)\n\n\`\`\`diff\n${patchContent}\n\`\`\`\n\n
`; - } - const preview = lines.slice(0, maxLines).join("\n"); - return `\n\n
Show patch preview (${maxLines} of ${lines.length} lines)\n\n\`\`\`diff\n${preview}\n... (truncated)\n\`\`\`\n\n
`; + function generatePatchPreview(patchContent) { + if (!patchContent || !patchContent.trim()) { + return ""; } + const lines = patchContent.split("\n"); + const maxLines = 500; + const maxChars = 2000; + let preview = lines.length <= maxLines ? patchContent : lines.slice(0, maxLines).join("\n"); + const lineTruncated = lines.length > maxLines; + const charTruncated = preview.length > maxChars; + if (charTruncated) { + preview = preview.slice(0, maxChars); + } + const truncated = lineTruncated || charTruncated; + const summary = truncated + ? `Show patch preview (${Math.min(maxLines, lines.length)} of ${lines.length} lines)` + : `Show patch (${lines.length} lines)`; + return `\n\n
${summary}\n\n\`\`\`diff\n${preview}${truncated ? "\n... (truncated)" : ""}\n\`\`\`\n\n
`; + } + async function main() { const isStaged = process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true"; const workflowId = process.env.GITHUB_AW_WORKFLOW_ID; if (!workflowId) { diff --git a/pkg/cli/add_command.go b/pkg/cli/add_command.go index 1b92107c885..1f44c806fef 100644 --- a/pkg/cli/add_command.go +++ b/pkg/cli/add_command.go @@ -804,6 +804,42 @@ func addSourceToWorkflow(content, source string) (string, error) { return "", fmt.Errorf("failed to parse frontmatter: %w", err) } + // Try to preserve original frontmatter formatting by manually inserting the source field + if len(result.FrontmatterLines) > 0 { + // Check if source field already exists + if result.Frontmatter != nil { + if _, exists := result.Frontmatter["source"]; exists { + // Source field exists, replace it by parsing and re-marshaling (fallback behavior) + return addSourceToWorkflowFallback(result, source) + } + } + + // Source field doesn't exist, insert it manually to preserve formatting + frontmatterLines := make([]string, len(result.FrontmatterLines)) + copy(frontmatterLines, result.FrontmatterLines) + + // Add source field at the end of the frontmatter, preserving original formatting + sourceField := fmt.Sprintf("source: %s", source) + frontmatterLines = append(frontmatterLines, sourceField) + + // Reconstruct the file with preserved formatting + var lines []string + lines = append(lines, "---") + lines = append(lines, frontmatterLines...) + lines = append(lines, "---") + if result.Markdown != "" { + lines = append(lines, result.Markdown) + } + + return strings.Join(lines, "\n"), nil + } + + // Fallback to original behavior if no frontmatter lines are available + return addSourceToWorkflowFallback(result, source) +} + +// addSourceToWorkflowFallback implements the original behavior as a fallback +func addSourceToWorkflowFallback(result *parser.FrontmatterResult, source string) (string, error) { // Initialize frontmatter if it doesn't exist if result.Frontmatter == nil { result.Frontmatter = make(map[string]any) diff --git a/pkg/cli/add_source_test.go b/pkg/cli/add_source_test.go index 2b213f62917..739fb5614de 100644 --- a/pkg/cli/add_source_test.go +++ b/pkg/cli/add_source_test.go @@ -79,6 +79,41 @@ This workflow has complex 'on' triggers.`, expectError: false, checkSource: true, }, + { + name: "preserve_formatting_with_comments_and_blank_lines", + content: `--- +on: + workflow_dispatch: + + schedule: + # Run daily at 2am UTC, all days except Saturday and Sunday + - cron: "0 2 * * 1-5" + + stop-after: +48h # workflow will no longer trigger after 48 hours + +timeout_minutes: 30 + +permissions: read-all + +network: defaults + +engine: claude + +tools: + # Web tools for testing + web-search: null + + # Memory cache + cache-memory: true +--- + +# Well Formatted Workflow + +This workflow has proper formatting with comments and blank lines.`, + source: "githubnext/agentics/workflows/formatted.md@v1.0.0", + expectError: false, + checkSource: true, + }, } for _, tt := range tests { @@ -113,11 +148,31 @@ This workflow has complex 'on' triggers.`, if strings.Contains(tt.content, "# Test Workflow") && !strings.Contains(result, "# Test Workflow") { t.Errorf("addSourceToWorkflow() result does not preserve markdown content") } + if strings.Contains(tt.content, "# Well Formatted Workflow") && !strings.Contains(result, "# Well Formatted Workflow") { + t.Errorf("addSourceToWorkflow() result does not preserve markdown content") + } // Verify that "on" keyword is not quoted if strings.Contains(result, `"on":`) { t.Errorf("addSourceToWorkflow() result contains quoted 'on' keyword, should be unquoted. Result:\n%s", result) } + + // For the formatting preservation test, verify that comments and blank lines are preserved + if tt.name == "preserve_formatting_with_comments_and_blank_lines" { + if !strings.Contains(result, "# Run daily at 2am UTC, all days except Saturday and Sunday") { + t.Errorf("addSourceToWorkflow() result does not preserve comments") + } + if !strings.Contains(result, "stop-after: +48h # workflow will no longer trigger") { + t.Errorf("addSourceToWorkflow() result does not preserve inline comments") + } + if !strings.Contains(result, " # Web tools for testing") { + t.Errorf("addSourceToWorkflow() result does not preserve indented comments") + } + // Check that there are still blank lines by checking for consecutive newlines + if !strings.Contains(result, "\n\n") { + t.Errorf("addSourceToWorkflow() result does not preserve blank lines") + } + } } }) } diff --git a/pkg/cli/format_preservation_test.go b/pkg/cli/format_preservation_test.go new file mode 100644 index 00000000000..b64fbff8611 --- /dev/null +++ b/pkg/cli/format_preservation_test.go @@ -0,0 +1,69 @@ +package cli + +import ( + "fmt" + "strings" + "testing" +) + +func TestFormatPreservationManual(t *testing.T) { + content := `--- +on: + workflow_dispatch: + + schedule: + # Daily run at 9 AM UTC on weekdays + - cron: "0 9 * * 1-5" + + # Auto-stop workflow after 2 hours + stop-after: +2h + +timeout_minutes: 45 + +permissions: read-all + +network: defaults + +engine: claude + +tools: + # Enable web search functionality + web-search: null + + # Memory caching for better performance + cache-memory: true + +--- + +# Test Formatting Preservation + +This workflow is designed to test whether the formatting is preserved.` + + result, err := addSourceToWorkflow(content, "test/repo/workflow.md@v1.0.0") + if err != nil { + t.Fatalf("Error adding source: %v", err) + } + + fmt.Printf("=== ORIGINAL ===\n%s\n\n", content) + fmt.Printf("=== RESULT ===\n%s\n", result) + + // Check if comments are preserved + if !strings.Contains(result, "# Daily run at 9 AM UTC on weekdays") { + t.Error("Comments were not preserved") + } + + // Check if blank lines are preserved (look for consecutive newlines) + if !strings.Contains(result, "\n\n") { + t.Error("Blank lines were not preserved") + } + + // Check if inline comments are preserved + if !strings.Contains(result, "stop-after: +2h") { + t.Error("Inline comments were not preserved") + } + + // Check if indentation is preserved (4-space indentation) + if !strings.Contains(result, " workflow_dispatch:") { + t.Error("4-space indentation was not preserved") + } +} diff --git a/pkg/cli/update_command.go b/pkg/cli/update_command.go index 19e5b4119a3..38929e72b36 100644 --- a/pkg/cli/update_command.go +++ b/pkg/cli/update_command.go @@ -2,6 +2,7 @@ package cli import ( "fmt" + "math/rand" "os" "os/exec" "path/filepath" @@ -18,33 +19,38 @@ import ( func NewUpdateCommand(validateEngine func(string) error) *cobra.Command { cmd := &cobra.Command{ Use: "update [workflow-name]...", - Short: "Update workflows from their source repositories", - Long: `Update one or more workflows from their source repositories. + Short: "Update workflows from their source repositories and check for gh-aw updates", + Long: `Update one or more workflows from their source repositories and check for gh-aw updates. -The command uses the 'source' field in the workflow frontmatter to determine -the source repository and version. It then fetches the latest version based on -the current ref: +The command: +1. Checks if a newer version of gh-aw is available +2. Updates workflows using the 'source' field in the workflow frontmatter +3. Recompiles all workflows + +For workflow updates, it fetches the latest version based on the current ref: - If the ref is a tag, it updates to the latest release (use --major for major version updates) - If the ref is a branch, it fetches the latest commit from that branch - Otherwise, it fetches the latest commit from the default branch Examples: - ` + constants.CLIExtensionPrefix + ` update # Update all workflows with source field - ` + constants.CLIExtensionPrefix + ` update ci-doctor # Update specific workflow + ` + constants.CLIExtensionPrefix + ` update # Check gh-aw updates and update all workflows + ` + constants.CLIExtensionPrefix + ` update ci-doctor # Check gh-aw updates and update specific workflow ` + constants.CLIExtensionPrefix + ` update ci-doctor --major # Allow major version updates + ` + constants.CLIExtensionPrefix + ` update --pr # Create PR with changes ` + constants.CLIExtensionPrefix + ` update --force # Force update even if no changes`, Run: func(cmd *cobra.Command, args []string) { majorFlag, _ := cmd.Flags().GetBool("major") forceFlag, _ := cmd.Flags().GetBool("force") engineOverride, _ := cmd.Flags().GetString("engine") verbose, _ := cmd.Flags().GetBool("verbose") + prFlag, _ := cmd.Flags().GetBool("pr") if err := validateEngine(engineOverride); err != nil { fmt.Fprintln(os.Stderr, console.FormatErrorMessage(err.Error())) os.Exit(1) } - if err := UpdateWorkflows(args, majorFlag, forceFlag, verbose, engineOverride); err != nil { + if err := UpdateWorkflowsWithExtensionCheck(args, majorFlag, forceFlag, verbose, engineOverride, prFlag); err != nil { fmt.Fprintln(os.Stderr, console.FormatErrorMessage(err.Error())) os.Exit(1) } @@ -54,10 +60,188 @@ Examples: cmd.Flags().Bool("major", false, "Allow major version updates when updating tagged releases") cmd.Flags().Bool("force", false, "Force update even if no changes detected") cmd.Flags().StringP("engine", "a", "", "Override AI engine (claude, codex, copilot)") + cmd.Flags().Bool("pr", false, "Create a pull request with the workflow changes") return cmd } +// checkExtensionUpdate checks if a newer version of gh-aw is available +func checkExtensionUpdate(verbose bool) error { + if verbose { + fmt.Fprintln(os.Stderr, console.FormatVerboseMessage("Checking for gh-aw extension updates...")) + } + + // Run gh extension upgrade --dry-run to check for updates + cmd := exec.Command("gh", "extension", "upgrade", "githubnext/gh-aw", "--dry-run") + output, err := cmd.CombinedOutput() + if err != nil { + if verbose { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to check for extension updates: %v", err))) + } + return nil // Don't fail the whole command if update check fails + } + + outputStr := strings.TrimSpace(string(output)) + if verbose { + fmt.Fprintln(os.Stderr, console.FormatVerboseMessage(fmt.Sprintf("Extension update check output: %s", outputStr))) + } + + // Parse the output to see if an update is available + // Expected format: "[aw]: would have upgraded from v0.14.0 to v0.18.1" + lines := strings.Split(outputStr, "\n") + for _, line := range lines { + if strings.Contains(line, "[aw]: would have upgraded from") { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(line)) + fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Run 'gh extension upgrade githubnext/gh-aw' to update")) + return nil + } + } + + if strings.Contains(outputStr, "✓ Successfully checked extension upgrades") { + if verbose { + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("gh-aw extension is up to date")) + } + } + + return nil +} + +// runCompileWorkflows runs the compile command to recompile all workflows +func runCompileWorkflows(verbose bool, engineOverride string) error { + if verbose { + fmt.Fprintln(os.Stderr, console.FormatVerboseMessage("Compiling workflows...")) + } + + // Create a compiler instance similar to how compile command does it + compiler := workflow.NewCompiler(verbose, engineOverride, GetVersion()) + + // Compile all workflows in the workflows directory + workflowsDir := getWorkflowsDir() + err := compileAllWorkflowFiles(compiler, workflowsDir, verbose) + if err != nil { + return fmt.Errorf("failed to compile workflows: %w", err) + } + + if verbose { + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("Successfully compiled all workflows")) + } + return nil +} + +// UpdateWorkflowsWithExtensionCheck performs the complete update process: +// 1. Check for gh-aw extension updates +// 2. Update workflows from source repositories +// 3. Compile all workflows +// 4. Optionally create a PR +func UpdateWorkflowsWithExtensionCheck(workflowNames []string, allowMajor, force, verbose bool, engineOverride string, createPR bool) error { + // Step 1: Check for gh-aw extension updates + if err := checkExtensionUpdate(verbose); err != nil { + return fmt.Errorf("extension update check failed: %w", err) + } + + // Step 2: Update workflows from source repositories + if err := UpdateWorkflows(workflowNames, allowMajor, force, verbose, engineOverride); err != nil { + return fmt.Errorf("workflow update failed: %w", err) + } + + // Step 3: Compile all workflows + if err := runCompileWorkflows(verbose, engineOverride); err != nil { + return fmt.Errorf("compile failed: %w", err) + } + + // Step 4: Optionally create PR if flag is set + if createPR { + if err := createUpdatePR(verbose); err != nil { + return fmt.Errorf("failed to create PR: %w", err) + } + } + + return nil +} + +// hasGitChanges checks if there are any uncommitted changes +func hasGitChanges() (bool, error) { + cmd := exec.Command("git", "status", "--porcelain") + output, err := cmd.Output() + if err != nil { + return false, fmt.Errorf("failed to check git status: %w", err) + } + + return len(strings.TrimSpace(string(output))) > 0, nil +} + +// runGitCommand runs a git command with the specified arguments +func runGitCommand(args ...string) error { + cmd := exec.Command("git", args...) + if err := cmd.Run(); err != nil { + return fmt.Errorf("git %s failed: %w", strings.Join(args, " "), err) + } + return nil +} + +// createUpdatePR creates a pull request with the workflow changes +func createUpdatePR(verbose bool) error { + // Check if GitHub CLI is available + if !isGHCLIAvailable() { + return fmt.Errorf("GitHub CLI (gh) is required for PR creation but not found in PATH") + } + + // Check if there are any changes to commit + hasChanges, err := hasGitChanges() + if err != nil { + return fmt.Errorf("failed to check git status: %w", err) + } + + if !hasChanges { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage("No changes to create PR for")) + return nil + } + + if verbose { + fmt.Fprintln(os.Stderr, console.FormatVerboseMessage("Creating pull request with workflow updates...")) + } + + // Create a branch name with timestamp + randomNum := rand.Intn(9000) + 1000 // Generate number between 1000-9999 + branchName := fmt.Sprintf("update-workflows-%d", randomNum) + + // Create and checkout new branch + if err := runGitCommand("checkout", "-b", branchName); err != nil { + return fmt.Errorf("failed to create branch: %w", err) + } + + // Add all changes + if err := runGitCommand("add", "."); err != nil { + return fmt.Errorf("failed to add changes: %w", err) + } + + // Commit changes + commitMsg := "Update workflows and recompile" + if err := runGitCommand("commit", "-m", commitMsg); err != nil { + return fmt.Errorf("failed to commit changes: %w", err) + } + + // Push branch + if err := runGitCommand("push", "-u", "origin", branchName); err != nil { + return fmt.Errorf("failed to push branch: %w", err) + } + + // Create PR + cmd := exec.Command("gh", "pr", "create", + "--title", "Update workflows and recompile", + "--body", "This PR updates workflows from their source repositories and recompiles them.\n\nGenerated by `gh aw update --pr`") + + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to create PR: %w\nOutput: %s", err, string(output)) + } + + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("Successfully created pull request")) + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(strings.TrimSpace(string(output)))) + + return nil +} + // UpdateWorkflows updates workflows from their source repositories func UpdateWorkflows(workflowNames []string, allowMajor, force, verbose bool, engineOverride string) error { workflowsDir := getWorkflowsDir()