From 24b179cf3a856a9036926459a26dfcbdbb2ba88b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Oct 2025 21:28:01 +0000 Subject: [PATCH 1/5] Initial plan From 9a3ffb081c4faf4d16bfcf568700ede4e12d582e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Oct 2025 21:42:05 +0000 Subject: [PATCH 2/5] Add source field tracking and update command implementation Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- cmd/gh-aw/main.go | 1 + pkg/cli/commands.go | 109 ++++++++++ pkg/cli/update_command.go | 425 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 535 insertions(+) create mode 100644 pkg/cli/update_command.go diff --git a/cmd/gh-aw/main.go b/cmd/gh-aw/main.go index 3ca55f57f66..b3e5ed04a29 100644 --- a/cmd/gh-aw/main.go +++ b/cmd/gh-aw/main.go @@ -339,6 +339,7 @@ func init() { rootCmd.AddCommand(listCmd) rootCmd.AddCommand(newCmd) rootCmd.AddCommand(initCmd) + rootCmd.AddCommand(cli.NewUpdateCommand(&verbose)) rootCmd.AddCommand(installCmd) rootCmd.AddCommand(uninstallCmd) rootCmd.AddCommand(compileCmd) diff --git a/pkg/cli/commands.go b/pkg/cli/commands.go index ce094bfc439..1e07207ca39 100644 --- a/pkg/cli/commands.go +++ b/pkg/cli/commands.go @@ -525,6 +525,24 @@ func AddWorkflowWithTracking(workflow string, number int, verbose bool, engineOv content = updateWorkflowTitle(content, i) } + // Add source field to the frontmatter if the workflow is from a package + if sourceInfo.IsPackage { + sourceField, err := generateSourceField(sourceInfo, workflowPath, verbose) + if err != nil { + if verbose { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to generate source field: %v", err))) + } + } else { + // Add source field to frontmatter + content, err = addSourceToWorkflow(content, sourceField, verbose) + if err != nil { + if verbose { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to add source field: %v", err))) + } + } + } + } + // Track the file based on whether it existed before (if tracker is available) if tracker != nil { if fileExists { @@ -1484,6 +1502,97 @@ func extractWorkflowNameFromFile(filePath string) (string, error) { return strings.Join(words, " "), nil } +// generateSourceField generates the source field value for a workflow from a package +// Format: org/repo path/to/workflow.md +func generateSourceField(sourceInfo *WorkflowSourceInfo, workflowPath string, verbose bool) (string, error) { + if !sourceInfo.IsPackage { + return "", fmt.Errorf("workflow is not from a package") + } + + // Extract org/repo from PackagePath + // PackagePath is typically: ~/.aw/packages/org/repo or .aw/packages/org/repo + packagesDir, err := getPackagesDir(false) // Try global first + if err != nil { + return "", err + } + + relPath, err := filepath.Rel(packagesDir, sourceInfo.PackagePath) + if err != nil { + // Try local packages directory + packagesDir, err = getPackagesDir(true) + if err != nil { + return "", err + } + relPath, err = filepath.Rel(packagesDir, sourceInfo.PackagePath) + if err != nil { + return "", fmt.Errorf("failed to determine relative package path: %w", err) + } + } + + // relPath should be org/repo + orgRepo := filepath.ToSlash(relPath) + + // Get the commit SHA from metadata + commitSHA := readCommitSHAFromMetadata(sourceInfo.PackagePath) + if commitSHA == "" { + commitSHA = "main" // fallback to main if no commit SHA + } + + // Get the relative path to the workflow file within the package + workflowRelPath, err := filepath.Rel(sourceInfo.PackagePath, sourceInfo.SourcePath) + if err != nil { + // Use just the filename as fallback + workflowRelPath = filepath.Base(workflowPath) + } + workflowRelPath = filepath.ToSlash(workflowRelPath) + + // Format: org/repo path/to/workflow.md + source := fmt.Sprintf("%s %s %s", orgRepo, commitSHA, workflowRelPath) + + if verbose { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Generated source field: %s", source))) + } + + return source, nil +} + +// addSourceToWorkflow adds the source field to a workflow's frontmatter +func addSourceToWorkflow(content, source string, verbose bool) (string, error) { + // Parse the frontmatter + result, err := parser.ExtractFrontmatterFromContent(content) + if err != nil { + return "", fmt.Errorf("failed to parse frontmatter: %w", err) + } + + // Ensure frontmatter map exists + if result.Frontmatter == nil { + result.Frontmatter = make(map[string]any) + } + + // Add source field + result.Frontmatter["source"] = source + + // Convert back to YAML + frontmatterYAML, err := yaml.Marshal(result.Frontmatter) + if err != nil { + return "", fmt.Errorf("failed to marshal frontmatter: %w", err) + } + + // Reconstruct the workflow file + var lines []string + lines = append(lines, "---") + frontmatterStr := strings.TrimSuffix(string(frontmatterYAML), "\n") + if frontmatterStr != "" { + lines = append(lines, strings.Split(frontmatterStr, "\n")...) + } + lines = append(lines, "---") + if result.Markdown != "" { + lines = append(lines, result.Markdown) + } + + return strings.Join(lines, "\n"), nil +} + func updateWorkflowTitle(content string, number int) string { // Find and update the first H1 header lines := strings.Split(content, "\n") diff --git a/pkg/cli/update_command.go b/pkg/cli/update_command.go new file mode 100644 index 00000000000..674d258dfab --- /dev/null +++ b/pkg/cli/update_command.go @@ -0,0 +1,425 @@ +package cli + +import ( + "fmt" + "math/rand" + "os" + "path/filepath" + "strings" + + "github.com/githubnext/gh-aw/pkg/console" + "github.com/githubnext/gh-aw/pkg/constants" + "github.com/githubnext/gh-aw/pkg/parser" + "github.com/goccy/go-yaml" + "github.com/spf13/cobra" +) + +// NewUpdateCommand creates the update command +func NewUpdateCommand(verbosePtr *bool) *cobra.Command { + cmd := &cobra.Command{ + Use: "update [workflow]...", + Short: "Update workflows that have source information", + Long: `Update one or more workflows from their source repositories. + +Only workflows with a 'source' field in their frontmatter can be updated. +The command will fetch the latest version from the source and merge changes +while preserving local modifications. + +Examples: + ` + constants.CLIExtensionPrefix + ` update # Update all workflows with source + ` + constants.CLIExtensionPrefix + ` update weekly-research # Update specific workflow + ` + constants.CLIExtensionPrefix + ` update --pr # Create PR with updates`, + Run: func(cmd *cobra.Command, args []string) { + verbose := false + if verbosePtr != nil { + verbose = *verbosePtr + } + prFlag, _ := cmd.Flags().GetBool("pr") + if err := UpdateWorkflows(args, verbose, prFlag); err != nil { + fmt.Fprintln(os.Stderr, console.FormatErrorMessage(err.Error())) + os.Exit(1) + } + }, + } + + cmd.Flags().Bool("pr", false, "Create a pull request with the workflow changes") + + return cmd +} + +// UpdateWorkflows updates workflows from their source repositories +func UpdateWorkflows(workflows []string, verbose bool, createPR bool) error { + gitRoot, err := findGitRoot() + if err != nil { + return fmt.Errorf("update requires being in a git repository: %w", err) + } + + githubWorkflowsDir := filepath.Join(gitRoot, ".github/workflows") + if _, err := os.Stat(githubWorkflowsDir); os.IsNotExist(err) { + return fmt.Errorf(".github/workflows directory not found") + } + + // Find all workflows with source field + workflowsToUpdate, err := findWorkflowsWithSource(githubWorkflowsDir, workflows, verbose) + if err != nil { + return err + } + + if len(workflowsToUpdate) == 0 { + if len(workflows) == 0 { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage("No workflows with source field found.")) + fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Workflows need a 'source' field in their frontmatter to be updated.")) + } else { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage("No matching workflows with source field found.")) + } + return nil + } + + // Handle PR creation workflow + if createPR { + return updateWorkflowsWithPR(workflowsToUpdate, verbose) + } + + // Handle normal update without PR + return updateWorkflowsNormal(workflowsToUpdate, verbose) +} + +// updateWorkflowsNormal handles normal workflow update without PR creation +func updateWorkflowsNormal(workflowsToUpdate []WorkflowWithSource, verbose bool) error { + // Create file tracker for all operations + tracker, err := NewFileTracker() + if err != nil { + // If we can't create a tracker (e.g., not in git repo), fall back to non-tracking behavior + if verbose { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Could not create file tracker: %v", err))) + } + tracker = nil + } + + // Update each workflow + updated := 0 + failed := 0 + for _, wf := range workflowsToUpdate { + if verbose { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Updating workflow: %s", wf.Name))) + } + + if err := updateSingleWorkflow(wf, verbose, tracker); err != nil { + fmt.Fprintln(os.Stderr, console.FormatErrorMessage(fmt.Sprintf("Failed to update %s: %v", wf.Name, err))) + failed++ + } else { + updated++ + } + } + + // Report results + if updated > 0 { + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Successfully updated %d workflow(s)", updated))) + } + if failed > 0 { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to update %d workflow(s)", failed))) + } + + if failed > 0 { + return fmt.Errorf("some workflows failed to update") + } + + return nil +} + +// updateWorkflowsWithPR handles workflow update with PR creation +func updateWorkflowsWithPR(workflowsToUpdate []WorkflowWithSource, verbose bool) error { + // Get current branch for restoration later + currentBranch, err := getCurrentBranch() + if err != nil { + return fmt.Errorf("failed to get current branch: %w", err) + } + + // Create temporary branch + branchName := fmt.Sprintf("update-workflows-%04d", rand.Intn(9000)+1000) + + if err := createAndSwitchBranch(branchName, verbose); err != nil { + return fmt.Errorf("failed to create branch %s: %w", branchName, err) + } + + // Create file tracker for rollback capability + tracker, err := NewFileTracker() + if err != nil { + return fmt.Errorf("failed to create file tracker: %w", err) + } + + // Ensure we switch back to original branch on exit + defer func() { + if switchErr := switchBranch(currentBranch, verbose); switchErr != nil && verbose { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to switch back to branch %s: %v", currentBranch, switchErr))) + } + }() + + // Update workflows using the normal function logic + if err := updateWorkflowsNormal(workflowsToUpdate, verbose); err != nil { + // Rollback on error + if rollbackErr := tracker.RollbackAllFiles(verbose); rollbackErr != nil && verbose { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to rollback files: %v", rollbackErr))) + } + return fmt.Errorf("failed to update workflows: %w", err) + } + + // Stage all files before creating PR + if err := tracker.StageAllFiles(verbose); err != nil { + if rollbackErr := tracker.RollbackAllFiles(verbose); rollbackErr != nil && verbose { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to rollback files: %v", rollbackErr))) + } + return fmt.Errorf("failed to stage workflow files: %w", err) + } + + // Update .gitattributes and stage it if modified + if err := stageGitAttributesIfChanged(); err != nil && verbose { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to stage .gitattributes: %v", err))) + } + + // Commit changes + var commitMessage, prTitle, prBody string + if len(workflowsToUpdate) == 1 { + commitMessage = fmt.Sprintf("Update workflow: %s", workflowsToUpdate[0].Name) + prTitle = fmt.Sprintf("Update workflow: %s", workflowsToUpdate[0].Name) + prBody = fmt.Sprintf("Automatically created PR to update workflow: %s\n\nSource: %s", workflowsToUpdate[0].Name, workflowsToUpdate[0].SourceSpec) + } else { + var workflowNames []string + for _, wf := range workflowsToUpdate { + workflowNames = append(workflowNames, wf.Name) + } + commitMessage = fmt.Sprintf("Update workflows: %s", strings.Join(workflowNames, ", ")) + prTitle = fmt.Sprintf("Update %d workflows from source", len(workflowsToUpdate)) + prBody = fmt.Sprintf("Automatically created PR to update workflows from their source repositories.\n\nUpdated workflows:\n") + for _, wf := range workflowsToUpdate { + prBody += fmt.Sprintf("- %s (source: %s)\n", wf.Name, wf.SourceSpec) + } + } + + if err := commitChanges(commitMessage, verbose); err != nil { + if rollbackErr := tracker.RollbackAllFiles(verbose); rollbackErr != nil && verbose { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to rollback files: %v", rollbackErr))) + } + return fmt.Errorf("failed to commit files: %w", err) + } + + // Push branch + if err := pushBranch(branchName, verbose); err != nil { + if rollbackErr := tracker.RollbackAllFiles(verbose); rollbackErr != nil && verbose { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to rollback files: %v", rollbackErr))) + } + return fmt.Errorf("failed to push branch %s: %w", branchName, err) + } + + // Create PR + if err := createPR(branchName, prTitle, prBody, verbose); err != nil { + if rollbackErr := tracker.RollbackAllFiles(verbose); rollbackErr != nil && verbose { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to rollback files: %v", rollbackErr))) + } + return fmt.Errorf("failed to create PR: %w", err) + } + + // Success + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("Successfully created pull request with workflow updates")) + + return nil +} + +// WorkflowWithSource represents a workflow file with source information +type WorkflowWithSource struct { + Name string // Base filename without .md extension + FilePath string // Full path to the workflow file + SourceSpec string // The source field value (e.g., "org/repo sha path.md") +} + +// findWorkflowsWithSource finds all workflows with source field +func findWorkflowsWithSource(workflowsDir string, filter []string, verbose bool) ([]WorkflowWithSource, error) { + var result []WorkflowWithSource + + // Read all .md files in the workflows directory + entries, err := os.ReadDir(workflowsDir) + if err != nil { + return nil, fmt.Errorf("failed to read workflows directory: %w", err) + } + + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") { + continue + } + + // Skip .lock.yml files + if strings.HasSuffix(entry.Name(), ".lock.yml") { + continue + } + + filePath := filepath.Join(workflowsDir, entry.Name()) + baseName := strings.TrimSuffix(entry.Name(), ".md") + + // If filter is specified, check if this workflow matches + if len(filter) > 0 { + matches := false + for _, f := range filter { + if baseName == f || baseName == strings.TrimSuffix(f, ".md") { + matches = true + break + } + } + if !matches { + continue + } + } + + // Read and parse the workflow to check for source field + content, err := os.ReadFile(filePath) + if err != nil { + if verbose { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to read %s: %v", entry.Name(), err))) + } + continue + } + + frontmatter, err := parser.ExtractFrontmatterFromContent(string(content)) + if err != nil { + if verbose { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to parse %s: %v", entry.Name(), err))) + } + continue + } + + // Check if source field exists + if source, ok := frontmatter.Frontmatter["source"].(string); ok && source != "" { + result = append(result, WorkflowWithSource{ + Name: baseName, + FilePath: filePath, + SourceSpec: source, + }) + } + } + + return result, nil +} + +// updateSingleWorkflow updates a single workflow from its source +func updateSingleWorkflow(wf WorkflowWithSource, verbose bool, tracker *FileTracker) error { + // Parse the source spec: "org/repo ref path.md" + parts := strings.Fields(wf.SourceSpec) + if len(parts) != 3 { + return fmt.Errorf("invalid source format: expected 'org/repo ref path.md', got '%s'", wf.SourceSpec) + } + + repo := parts[0] + ref := parts[1] + workflowPath := parts[2] + + if verbose { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Source: %s @ %s (%s)", repo, ref, workflowPath))) + } + + // Install or update the package to get the latest version + // Note: This will update the package in the packages directory + if err := InstallPackage(repo+"@"+ref, false, verbose); err != nil { + return fmt.Errorf("failed to install/update package: %w", err) + } + + // Now read the workflow from the package + packagesDir, err := getPackagesDir(false) + if err != nil { + return err + } + + packagePath := filepath.Join(packagesDir, repo) + sourceFilePath := filepath.Join(packagePath, workflowPath) + + sourceContent, err := os.ReadFile(sourceFilePath) + if err != nil { + return fmt.Errorf("failed to read source workflow: %w", err) + } + + // Read the current local workflow + localContent, err := os.ReadFile(wf.FilePath) + if err != nil { + return fmt.Errorf("failed to read local workflow: %w", err) + } + + // Parse both frontmatter and markdown + sourceFrontmatter, err := parser.ExtractFrontmatterFromContent(string(sourceContent)) + if err != nil { + return fmt.Errorf("failed to parse source frontmatter: %w", err) + } + + localFrontmatter, err := parser.ExtractFrontmatterFromContent(string(localContent)) + if err != nil { + return fmt.Errorf("failed to parse local frontmatter: %w", err) + } + + // Merge strategy: + // 1. Keep local markdown content (user may have customized it) + // 2. Update frontmatter from source, but preserve the source field and any local customizations + // 3. If frontmatter has conflicts, prefer source values but warn user + + // Start with source frontmatter + mergedFrontmatter := make(map[string]any) + for k, v := range sourceFrontmatter.Frontmatter { + mergedFrontmatter[k] = v + } + + // Preserve the source field from local (it may have been updated) + if source, ok := localFrontmatter.Frontmatter["source"].(string); ok { + mergedFrontmatter["source"] = source + } + + // Use local markdown content + mergedMarkdown := localFrontmatter.Markdown + + // Reconstruct the workflow + frontmatterYAML, err := yaml.Marshal(mergedFrontmatter) + if err != nil { + return fmt.Errorf("failed to marshal merged frontmatter: %w", err) + } + + var lines []string + lines = append(lines, "---") + frontmatterStr := strings.TrimSuffix(string(frontmatterYAML), "\n") + if frontmatterStr != "" { + lines = append(lines, strings.Split(frontmatterStr, "\n")...) + } + lines = append(lines, "---") + if mergedMarkdown != "" { + lines = append(lines, mergedMarkdown) + } + + updatedContent := strings.Join(lines, "\n") + + // Check if content actually changed + if string(localContent) == updatedContent { + if verbose { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("No changes needed for %s", wf.Name))) + } + return nil + } + + // Write the updated content + if err := os.WriteFile(wf.FilePath, []byte(updatedContent), 0644); err != nil { + return fmt.Errorf("failed to write updated workflow: %w", err) + } + + // Track the modification if tracker is available + if tracker != nil { + tracker.TrackModified(wf.FilePath) + } + + // Compile the updated workflow + if tracker != nil { + if err := compileWorkflowWithTracking(wf.FilePath, verbose, "", tracker); err != nil { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to compile updated workflow: %v", err))) + } + } else { + if err := compileWorkflow(wf.FilePath, verbose, ""); err != nil { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to compile updated workflow: %v", err))) + } + } + + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Updated %s", wf.Name))) + + return nil +} From 02f782aa5bd263c43ad252e9812c513f4621fc44 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Oct 2025 21:45:15 +0000 Subject: [PATCH 3/5] Enhance status command to show source and update availability Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/cli/commands.go | 116 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 108 insertions(+), 8 deletions(-) diff --git a/pkg/cli/commands.go b/pkg/cli/commands.go index 1e07207ca39..d97d9fa5061 100644 --- a/pkg/cli/commands.go +++ b/pkg/cli/commands.go @@ -1339,7 +1339,7 @@ func StatusWorkflows(pattern string, verbose bool) error { } // Build table configuration - headers := []string{"Name", "Installed", "Up-to-date", "Status", "Time Remaining"} + headers := []string{"Name", "Installed", "Up-to-date", "Status", "Source", "Update Available"} var rows [][]string for _, file := range mdFiles { @@ -1355,7 +1355,6 @@ func StatusWorkflows(pattern string, verbose bool) error { lockFile := strings.TrimSuffix(file, ".md") + ".lock.yml" compiled := "No" upToDate := "N/A" - timeRemaining := "N/A" if _, err := os.Stat(lockFile); err == nil { compiled = "Yes" @@ -1368,11 +1367,6 @@ func StatusWorkflows(pattern string, verbose bool) error { } else { upToDate = "Yes" } - - // Extract stop-time from lock file - if stopTime := extractStopTimeFromLockFile(lockFile); stopTime != "" { - timeRemaining = calculateTimeRemaining(stopTime) - } } // Get GitHub workflow status @@ -1385,8 +1379,30 @@ func StatusWorkflows(pattern string, verbose bool) error { } } + // Check for source field and update availability + sourceInfo := "N/A" + updateAvailable := "N/A" + + content, err := os.ReadFile(file) + if err == nil { + frontmatter, err := parser.ExtractFrontmatterFromContent(string(content)) + if err == nil { + if source, ok := frontmatter.Frontmatter["source"].(string); ok && source != "" { + // Show first 30 chars of source to keep table readable + if len(source) > 30 { + sourceInfo = source[:27] + "..." + } else { + sourceInfo = source + } + + // Check if update is available + updateAvailable = checkUpdateAvailable(file, source, verbose) + } + } + } + // Build row data - row := []string{name, compiled, upToDate, status, timeRemaining} + row := []string{name, compiled, upToDate, status, sourceInfo, updateAvailable} rows = append(rows, row) } @@ -1463,6 +1479,90 @@ func calculateTimeRemaining(stopTimeStr string) string { } } +// checkUpdateAvailable checks if an update is available for a workflow with source +func checkUpdateAvailable(workflowPath string, sourceSpec string, verbose bool) string { + // Parse the source spec: "org/repo ref path.md" + parts := strings.Fields(sourceSpec) + if len(parts) != 3 { + return "Invalid" + } + + repo := parts[0] + currentRef := parts[1] + workflowFile := parts[2] + + // Get packages directory + packagesDir, err := getPackagesDir(false) + if err != nil { + return "Unknown" + } + + packagePath := filepath.Join(packagesDir, repo) + + // Check if package is installed + if _, err := os.Stat(packagePath); os.IsNotExist(err) { + return "Not installed" + } + + // Get current commit SHA from package metadata + packageCommitSHA := readCommitSHAFromMetadata(packagePath) + if packageCommitSHA == "" { + return "Unknown" + } + + // Compare with source ref + if currentRef == packageCommitSHA || currentRef == packageCommitSHA[:8] { + // Check if the workflow content is different + sourceFilePath := filepath.Join(packagePath, workflowFile) + sourceContent, err := os.ReadFile(sourceFilePath) + if err != nil { + return "Unknown" + } + + localContent, err := os.ReadFile(workflowPath) + if err != nil { + return "Unknown" + } + + // Parse both frontmatter + sourceFrontmatter, err := parser.ExtractFrontmatterFromContent(string(sourceContent)) + if err != nil { + return "Unknown" + } + + localFrontmatter, err := parser.ExtractFrontmatterFromContent(string(localContent)) + if err != nil { + return "Unknown" + } + + // Compare frontmatter (excluding source field) + localCopy := make(map[string]any) + for k, v := range localFrontmatter.Frontmatter { + if k != "source" { + localCopy[k] = v + } + } + + sourceCopy := make(map[string]any) + for k, v := range sourceFrontmatter.Frontmatter { + sourceCopy[k] = v + } + + // Convert to YAML for comparison + localYAML, _ := yaml.Marshal(localCopy) + sourceYAML, _ := yaml.Marshal(sourceCopy) + + if string(localYAML) != string(sourceYAML) { + return "Yes" + } + + return "No" + } + + // Ref is different, update available + return "Yes" +} + // Helper functions func extractWorkflowNameFromFile(filePath string) (string, error) { From e10f43f9b6657a9e1df3e82a08325bfc41a7d9a3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Oct 2025 21:48:22 +0000 Subject: [PATCH 4/5] Add unit tests for source field functionality Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/cli/commands.go | 8 +- pkg/cli/source_field_test.go | 244 +++++++++++++++++++++++++++++++++++ 2 files changed, 248 insertions(+), 4 deletions(-) create mode 100644 pkg/cli/source_field_test.go diff --git a/pkg/cli/commands.go b/pkg/cli/commands.go index d97d9fa5061..21d5a1bba9c 100644 --- a/pkg/cli/commands.go +++ b/pkg/cli/commands.go @@ -1382,7 +1382,7 @@ func StatusWorkflows(pattern string, verbose bool) error { // Check for source field and update availability sourceInfo := "N/A" updateAvailable := "N/A" - + content, err := os.ReadFile(file) if err == nil { frontmatter, err := parser.ExtractFrontmatterFromContent(string(content)) @@ -1394,7 +1394,7 @@ func StatusWorkflows(pattern string, verbose bool) error { } else { sourceInfo = source } - + // Check if update is available updateAvailable = checkUpdateAvailable(file, source, verbose) } @@ -1498,7 +1498,7 @@ func checkUpdateAvailable(workflowPath string, sourceSpec string, verbose bool) } packagePath := filepath.Join(packagesDir, repo) - + // Check if package is installed if _, err := os.Stat(packagePath); os.IsNotExist(err) { return "Not installed" @@ -1615,7 +1615,7 @@ func generateSourceField(sourceInfo *WorkflowSourceInfo, workflowPath string, ve if err != nil { return "", err } - + relPath, err := filepath.Rel(packagesDir, sourceInfo.PackagePath) if err != nil { // Try local packages directory diff --git a/pkg/cli/source_field_test.go b/pkg/cli/source_field_test.go new file mode 100644 index 00000000000..09ca1907f6b --- /dev/null +++ b/pkg/cli/source_field_test.go @@ -0,0 +1,244 @@ +package cli + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/githubnext/gh-aw/pkg/parser" +) + +func TestGenerateSourceField(t *testing.T) { + tests := []struct { + name string + sourceInfo *WorkflowSourceInfo + workflowPath string + expectedFormat string // Pattern to check (not exact match) + expectError bool + }{ + { + name: "valid package source", + sourceInfo: &WorkflowSourceInfo{ + IsPackage: true, + PackagePath: filepath.Join(os.TempDir(), ".aw", "packages", "githubnext", "agentics"), + SourcePath: filepath.Join(os.TempDir(), ".aw", "packages", "githubnext", "agentics", "researcher.md"), + }, + workflowPath: "researcher.md", + expectedFormat: "githubnext/agentics", + expectError: false, + }, + { + name: "non-package source", + sourceInfo: &WorkflowSourceInfo{ + IsPackage: false, + }, + workflowPath: "test.md", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create temporary package directory if needed + if tt.sourceInfo.IsPackage { + err := os.MkdirAll(tt.sourceInfo.PackagePath, 0755) + if err != nil { + t.Fatalf("Failed to create temp package dir: %v", err) + } + defer os.RemoveAll(filepath.Join(os.TempDir(), ".aw")) + + // Create metadata file + metadataFile := filepath.Join(tt.sourceInfo.PackagePath, ".aw-metadata") + metadataContent := "commit_sha=abc123def456\n" + err = os.WriteFile(metadataFile, []byte(metadataContent), 0644) + if err != nil { + t.Fatalf("Failed to create metadata file: %v", err) + } + } + + result, err := generateSourceField(tt.sourceInfo, tt.workflowPath, false) + + if tt.expectError { + if err == nil { + t.Errorf("Expected error but got none") + } + return + } + + if err != nil { + t.Errorf("Unexpected error: %v", err) + return + } + + if !strings.Contains(result, tt.expectedFormat) { + t.Errorf("Expected source field to contain '%s', got: %s", tt.expectedFormat, result) + } + + // Check format: should be "org/repo ref path.md" + parts := strings.Fields(result) + if len(parts) != 3 { + t.Errorf("Expected source field to have 3 parts, got %d: %s", len(parts), result) + } + }) + } +} + +func TestAddSourceToWorkflow(t *testing.T) { + tests := []struct { + name string + content string + source string + expectError bool + }{ + { + name: "add source to workflow with frontmatter", + content: `--- +on: push +--- + +# Test Workflow + +This is a test.`, + source: "githubnext/agentics abc123 researcher.md", + expectError: false, + }, + { + name: "add source to workflow without frontmatter", + content: `# Test Workflow + +This is a test.`, + source: "githubnext/agentics abc123 researcher.md", + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := addSourceToWorkflow(tt.content, tt.source, false) + + if tt.expectError { + if err == nil { + t.Errorf("Expected error but got none") + } + return + } + + if err != nil { + t.Errorf("Unexpected error: %v", err) + return + } + + // Parse the result to verify source was added + frontmatter, err := parser.ExtractFrontmatterFromContent(result) + if err != nil { + t.Errorf("Failed to parse result: %v", err) + return + } + + source, ok := frontmatter.Frontmatter["source"].(string) + if !ok { + t.Errorf("Source field not found in frontmatter") + return + } + + if source != tt.source { + t.Errorf("Expected source '%s', got '%s'", tt.source, source) + } + }) + } +} + +func TestFindWorkflowsWithSource(t *testing.T) { + // Create temporary workflows directory + tempDir := t.TempDir() + + // Create workflows with and without source + workflows := []struct { + name string + hasSource bool + sourceSpec string + }{ + {name: "with-source.md", hasSource: true, sourceSpec: "githubnext/agentics abc123 test.md"}, + {name: "without-source.md", hasSource: false}, + {name: "another-with-source.md", hasSource: true, sourceSpec: "org/repo def456 workflow.md"}, + } + + for _, wf := range workflows { + content := "---\non: push\n" + if wf.hasSource { + content += "source: " + wf.sourceSpec + "\n" + } + content += "---\n\n# Test Workflow" + + filePath := filepath.Join(tempDir, wf.name) + err := os.WriteFile(filePath, []byte(content), 0644) + if err != nil { + t.Fatalf("Failed to create test workflow: %v", err) + } + } + + // Test finding all workflows with source + result, err := findWorkflowsWithSource(tempDir, nil, false) + if err != nil { + t.Fatalf("Failed to find workflows: %v", err) + } + + expectedCount := 2 // Two workflows have source + if len(result) != expectedCount { + t.Errorf("Expected %d workflows with source, got %d", expectedCount, len(result)) + } + + // Test finding specific workflow + result, err = findWorkflowsWithSource(tempDir, []string{"with-source"}, false) + if err != nil { + t.Fatalf("Failed to find workflows: %v", err) + } + + if len(result) != 1 { + t.Errorf("Expected 1 workflow, got %d", len(result)) + } + + if len(result) > 0 && result[0].SourceSpec != "githubnext/agentics abc123 test.md" { + t.Errorf("Expected source 'githubnext/agentics abc123 test.md', got '%s'", result[0].SourceSpec) + } +} + +func TestCheckUpdateAvailable(t *testing.T) { + tests := []struct { + name string + sourceSpec string + expectedResult string + }{ + { + name: "invalid source format", + sourceSpec: "invalid", + expectedResult: "Invalid", + }, + { + name: "package not installed", + sourceSpec: "org/repo abc123 test.md", + expectedResult: "Not installed", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tempDir := t.TempDir() + workflowPath := filepath.Join(tempDir, "test.md") + + // Create a test workflow file + content := "---\non: push\nsource: " + tt.sourceSpec + "\n---\n\n# Test" + err := os.WriteFile(workflowPath, []byte(content), 0644) + if err != nil { + t.Fatalf("Failed to create test workflow: %v", err) + } + + result := checkUpdateAvailable(workflowPath, tt.sourceSpec, false) + + if result != tt.expectedResult { + t.Errorf("Expected '%s', got '%s'", tt.expectedResult, result) + } + }) + } +} From 699ca955d3def31127c131548f2b8bdee9d1d95f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Oct 2025 21:52:29 +0000 Subject: [PATCH 5/5] Fix linting issue in update command Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/cli/update_command.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cli/update_command.go b/pkg/cli/update_command.go index 674d258dfab..a7969d03b41 100644 --- a/pkg/cli/update_command.go +++ b/pkg/cli/update_command.go @@ -190,7 +190,7 @@ func updateWorkflowsWithPR(workflowsToUpdate []WorkflowWithSource, verbose bool) } commitMessage = fmt.Sprintf("Update workflows: %s", strings.Join(workflowNames, ", ")) prTitle = fmt.Sprintf("Update %d workflows from source", len(workflowsToUpdate)) - prBody = fmt.Sprintf("Automatically created PR to update workflows from their source repositories.\n\nUpdated workflows:\n") + prBody = "Automatically created PR to update workflows from their source repositories.\n\nUpdated workflows:\n" for _, wf := range workflowsToUpdate { prBody += fmt.Sprintf("- %s (source: %s)\n", wf.Name, wf.SourceSpec) }