diff --git a/.changeset/minor-add-pip-golang-dependabot.md b/.changeset/minor-add-pip-golang-dependabot.md new file mode 100644 index 00000000000..65a9c31e051 --- /dev/null +++ b/.changeset/minor-add-pip-golang-dependabot.md @@ -0,0 +1,5 @@ +--- +"gh-aw": minor +--- + +Add pip and golang dependency manifest generation support diff --git a/cmd/gh-aw/main.go b/cmd/gh-aw/main.go index eb466677cc6..55d1b0dda1a 100644 --- a/cmd/gh-aw/main.go +++ b/cmd/gh-aw/main.go @@ -124,11 +124,11 @@ var compileCmd = &cobra.Command{ If no files are specified, all markdown files in .github/workflows will be compiled. -The --dependabot flag generates npm package manifests when npm dependencies are detected: - - Creates package.json with all npm packages used in workflows - - Runs npm install --package-lock-only to generate package-lock.json - - Creates .github/dependabot.yml for automatic dependency updates - - Requires npm to be installed and available in PATH +The --dependabot flag generates dependency manifests when dependencies are detected: + - For npm: Creates package.json and package-lock.json (requires npm in PATH) + - For Python: Creates requirements.txt for pip packages + - For Go: Creates go.mod for go install/get packages + - Creates .github/dependabot.yml with all detected ecosystems - Use --force to overwrite existing dependabot.yml - Cannot be used with specific workflow files or custom --workflows-dir - Only processes workflows in the default .github/workflows directory @@ -141,7 +141,7 @@ Examples: ` + constants.CLIExtensionPrefix + ` compile --workflows-dir custom/workflows # Compile from custom directory ` + constants.CLIExtensionPrefix + ` compile --watch ci-doctor # Watch and auto-compile ` + constants.CLIExtensionPrefix + ` compile --trial --logical-repo owner/repo # Compile for trial mode - ` + constants.CLIExtensionPrefix + ` compile --dependabot # Generate Dependabot manifests for npm dependencies + ` + constants.CLIExtensionPrefix + ` compile --dependabot # Generate Dependabot manifests ` + constants.CLIExtensionPrefix + ` compile --dependabot --force # Force overwrite existing dependabot.yml`, Run: func(cmd *cobra.Command, args []string) { engineOverride, _ := cmd.Flags().GetString("engine") @@ -280,7 +280,7 @@ func init() { compileCmd.Flags().Bool("strict", false, "Enable strict mode: require timeout, refuse write permissions, require network configuration") compileCmd.Flags().Bool("trial", false, "Enable trial mode compilation (modifies workflows for trial execution)") compileCmd.Flags().String("logical-repo", "", "Repository to simulate workflow execution against (for trial mode)") - compileCmd.Flags().Bool("dependabot", false, "Generate npm package manifest/lockfile and Dependabot config when npm dependencies are detected") + compileCmd.Flags().Bool("dependabot", false, "Generate dependency manifests (package.json, requirements.txt, go.mod) and Dependabot config when dependencies are detected") compileCmd.Flags().Bool("force", false, "Force overwrite of existing files (e.g., dependabot.yml)") rootCmd.AddCommand(compileCmd) diff --git a/pkg/workflow/dependabot.go b/pkg/workflow/dependabot.go index b8b149bf804..5c433e3100e 100644 --- a/pkg/workflow/dependabot.go +++ b/pkg/workflow/dependabot.go @@ -46,48 +46,106 @@ type NpmDependency struct { Version string // semver range or specific version } -// GenerateDependabotManifests generates package.json and dependabot.yml if npm dependencies are found +// PipDependency represents a parsed pip package with version +type PipDependency struct { + Name string + Version string // version specifier (e.g., ==1.0.0, >=2.0.0) +} + +// GoDependency represents a parsed Go package +type GoDependency struct { + Path string // import path (e.g., github.com/user/repo) + Version string // version or pseudo-version +} + +// GenerateDependabotManifests generates manifest files and dependabot.yml for detected dependencies func (c *Compiler) GenerateDependabotManifests(workflowDataList []*WorkflowData, workflowDir string, forceOverwrite bool) error { dependabotLog.Print("Starting Dependabot manifest generation") - // Collect all npm dependencies from all workflows - allDeps := c.collectNpmDependencies(workflowDataList) - if len(allDeps) == 0 { - dependabotLog.Print("No npm dependencies found, skipping manifest generation") + // Track which ecosystems have dependencies + ecosystems := make(map[string]bool) + + // Collect npm dependencies + npmDeps := c.collectNpmDependencies(workflowDataList) + if len(npmDeps) > 0 { + ecosystems["npm"] = true + dependabotLog.Printf("Found %d unique npm dependencies", len(npmDeps)) if c.verbose { - fmt.Fprintln(os.Stderr, console.FormatInfoMessage("No npm dependencies detected in workflows, skipping Dependabot manifest generation")) + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Found %d npm dependencies in workflows", len(npmDeps)))) + } + + // Generate package.json + packageJSONPath := filepath.Join(workflowDir, "package.json") + if err := c.generatePackageJSON(packageJSONPath, npmDeps, forceOverwrite); err != nil { + if c.strictMode { + return fmt.Errorf("failed to generate package.json: %w", err) + } + c.IncrementWarningCount() + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to generate package.json: %v", err))) + } else { + // Generate package-lock.json + if err := c.generatePackageLock(workflowDir); err != nil { + if c.strictMode { + return fmt.Errorf("failed to generate package-lock.json: %w", err) + } + c.IncrementWarningCount() + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to generate package-lock.json: %v", err))) + } } - return nil } - dependabotLog.Printf("Found %d unique npm dependencies", len(allDeps)) - if c.verbose { - fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Found %d npm dependencies in workflows", len(allDeps)))) + // Collect pip dependencies + pipDeps := c.collectPipDependencies(workflowDataList) + if len(pipDeps) > 0 { + ecosystems["pip"] = true + dependabotLog.Printf("Found %d unique pip dependencies", len(pipDeps)) + if c.verbose { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Found %d pip dependencies in workflows", len(pipDeps)))) + } + + // Generate requirements.txt + requirementsPath := filepath.Join(workflowDir, "requirements.txt") + if err := c.generateRequirementsTxt(requirementsPath, pipDeps, forceOverwrite); err != nil { + if c.strictMode { + return fmt.Errorf("failed to generate requirements.txt: %w", err) + } + c.IncrementWarningCount() + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to generate requirements.txt: %v", err))) + } } - // Generate package.json - packageJSONPath := filepath.Join(workflowDir, "package.json") - if err := c.generatePackageJSON(packageJSONPath, allDeps, forceOverwrite); err != nil { - if c.strictMode { - return fmt.Errorf("failed to generate package.json: %w", err) + // Collect go dependencies + goDeps := c.collectGoDependencies(workflowDataList) + if len(goDeps) > 0 { + ecosystems["gomod"] = true + dependabotLog.Printf("Found %d unique go dependencies", len(goDeps)) + if c.verbose { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Found %d go dependencies in workflows", len(goDeps)))) + } + + // Generate go.mod + goModPath := filepath.Join(workflowDir, "go.mod") + if err := c.generateGoMod(goModPath, goDeps, forceOverwrite); err != nil { + if c.strictMode { + return fmt.Errorf("failed to generate go.mod: %w", err) + } + c.IncrementWarningCount() + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to generate go.mod: %v", err))) } - c.IncrementWarningCount() - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to generate package.json: %v", err))) - return nil } - // Generate package-lock.json - if err := c.generatePackageLock(workflowDir); err != nil { - if c.strictMode { - return fmt.Errorf("failed to generate package-lock.json: %w", err) + // If no dependencies found at all, skip + if len(ecosystems) == 0 { + dependabotLog.Print("No dependencies found, skipping manifest generation") + if c.verbose { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage("No dependencies detected in workflows, skipping Dependabot manifest generation")) } - c.IncrementWarningCount() - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to generate package-lock.json: %v", err))) + return nil } - // Generate dependabot.yml + // Generate dependabot.yml with all detected ecosystems dependabotPath := filepath.Join(filepath.Dir(workflowDir), "dependabot.yml") - if err := c.generateDependabotConfig(dependabotPath, forceOverwrite); err != nil { + if err := c.generateDependabotConfig(dependabotPath, ecosystems, forceOverwrite); err != nil { if c.strictMode { return fmt.Errorf("failed to generate dependabot.yml: %w", err) } @@ -283,7 +341,7 @@ func (c *Compiler) generatePackageLock(workflowDir string) error { } // generateDependabotConfig creates or updates .github/dependabot.yml -func (c *Compiler) generateDependabotConfig(path string, forceOverwrite bool) error { +func (c *Compiler) generateDependabotConfig(path string, ecosystems map[string]bool, forceOverwrite bool) error { dependabotLog.Printf("Generating dependabot.yml at %s", path) var config DependabotConfig @@ -308,32 +366,23 @@ func (c *Compiler) generateDependabotConfig(path string, forceOverwrite bool) er config = DependabotConfig{Version: 2} } - // Check if npm ecosystem already exists for .github/workflows - npmExists := false - for _, update := range config.Updates { - if update.PackageEcosystem == "npm" && update.Directory == "/.github/workflows" { - npmExists = true - break - } - } - - // Add npm ecosystem if it doesn't exist - if !npmExists { - npmUpdate := DependabotUpdateEntry{ - PackageEcosystem: "npm", - Directory: "/.github/workflows", + // Add ecosystems that don't already exist for .github/workflows + for ecosystem := range ecosystems { + exists := false + for _, update := range config.Updates { + if update.PackageEcosystem == ecosystem && update.Directory == "/.github/workflows" { + exists = true + break + } } - npmUpdate.Schedule.Interval = "weekly" - config.Updates = append(config.Updates, npmUpdate) - dependabotLog.Print("Added npm ecosystem entry to dependabot.yml") - if c.verbose { - fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Added npm ecosystem to dependabot.yml")) - } - } else { - dependabotLog.Print("npm ecosystem already exists in dependabot.yml") - if c.verbose { - fmt.Fprintln(os.Stderr, console.FormatInfoMessage("npm ecosystem already configured in dependabot.yml")) + if !exists { + entry := DependabotUpdateEntry{ + PackageEcosystem: ecosystem, + Directory: "/.github/workflows", + } + entry.Schedule.Interval = "weekly" + config.Updates = append(config.Updates, entry) } } @@ -359,3 +408,298 @@ func (c *Compiler) generateDependabotConfig(path string, forceOverwrite bool) er return nil } + +// collectPipDependencies collects all pip dependencies from workflow data +func (c *Compiler) collectPipDependencies(workflowDataList []*WorkflowData) []PipDependency { + dependabotLog.Print("Collecting pip dependencies from workflows") + + depMap := make(map[string]string) // package name -> version (last seen) + + for _, workflowData := range workflowDataList { + packages := extractPipPackages(workflowData) + for _, pkg := range packages { + dep := parsePipPackage(pkg) + depMap[dep.Name] = dep.Version + } + } + + // Convert map to sorted slice + var deps []PipDependency + for name, version := range depMap { + deps = append(deps, PipDependency{ + Name: name, + Version: version, + }) + } + + // Sort by name for deterministic output + sort.Slice(deps, func(i, j int) bool { + return deps[i].Name < deps[j].Name + }) + + dependabotLog.Printf("Collected %d unique pip dependencies", len(deps)) + return deps +} + +// parsePipPackage parses a pip package string like "requests==2.28.0" into name and version +func parsePipPackage(pkg string) PipDependency { + // Handle version specifiers (==, >=, <=, >, <, !=, ~=) + for _, sep := range []string{"==", ">=", "<=", "!=", "~=", ">", "<"} { + if idx := strings.Index(pkg, sep); idx > 0 { + return PipDependency{ + Name: pkg[:idx], + Version: pkg[idx:], // Include the separator + } + } + } + + // No version specified + return PipDependency{ + Name: pkg, + Version: "", + } +} + +// generateRequirementsTxt creates or updates requirements.txt with dependencies +func (c *Compiler) generateRequirementsTxt(path string, deps []PipDependency, forceOverwrite bool) error { + dependabotLog.Printf("Generating requirements.txt at %s", path) + + // Build requirements map for merging + reqMap := make(map[string]string) + for _, dep := range deps { + if dep.Version != "" { + reqMap[dep.Name] = dep.Version + } else { + reqMap[dep.Name] = "" + } + } + + // Check if requirements.txt already exists + if _, err := os.Stat(path); err == nil { + // File exists - merge dependencies + dependabotLog.Print("Existing requirements.txt found, merging dependencies") + + existingData, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("failed to read existing requirements.txt: %w", err) + } + + // Parse existing requirements + lines := strings.Split(string(existingData), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + dep := parsePipPackage(line) + // Only add if not already in our new deps + if _, exists := reqMap[dep.Name]; !exists { + if dep.Version != "" { + reqMap[dep.Name] = dep.Version + } else { + reqMap[dep.Name] = "" + } + } + } + + if c.verbose { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Merging with existing requirements.txt")) + } + } else { + dependabotLog.Print("Creating new requirements.txt") + } + + // Sort dependencies by name + var sortedNames []string + for name := range reqMap { + sortedNames = append(sortedNames, name) + } + sort.Strings(sortedNames) + + // Build requirements.txt content + var lines []string + for _, name := range sortedNames { + version := reqMap[name] + if version != "" { + lines = append(lines, name+version) + } else { + lines = append(lines, name) + } + } + + content := strings.Join(lines, "\n") + "\n" + + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + return fmt.Errorf("failed to write requirements.txt: %w", err) + } + + dependabotLog.Printf("Successfully wrote requirements.txt with %d dependencies", len(reqMap)) + if c.verbose { + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Generated requirements.txt with %d dependencies", len(reqMap)))) + } + + // Track the created file + if c.fileTracker != nil { + c.fileTracker.TrackCreated(path) + } + + return nil +} + +// collectGoDependencies collects all Go dependencies from workflow data +func (c *Compiler) collectGoDependencies(workflowDataList []*WorkflowData) []GoDependency { + dependabotLog.Print("Collecting Go dependencies from workflows") + + depMap := make(map[string]string) // package path -> version (last seen) + + for _, workflowData := range workflowDataList { + packages := extractGoPackages(workflowData) + for _, pkg := range packages { + dep := parseGoPackage(pkg) + depMap[dep.Path] = dep.Version + } + } + + // Convert map to sorted slice + var deps []GoDependency + for path, version := range depMap { + deps = append(deps, GoDependency{ + Path: path, + Version: version, + }) + } + + // Sort by path for deterministic output + sort.Slice(deps, func(i, j int) bool { + return deps[i].Path < deps[j].Path + }) + + dependabotLog.Printf("Collected %d unique Go dependencies", len(deps)) + return deps +} + +// parseGoPackage parses a Go package string like "github.com/user/repo@v1.2.3" into path and version +func parseGoPackage(pkg string) GoDependency { + // Handle version separator @ + if idx := strings.Index(pkg, "@"); idx > 0 { + return GoDependency{ + Path: pkg[:idx], + Version: pkg[idx+1:], + } + } + + // No version specified - will use latest + return GoDependency{ + Path: pkg, + Version: "latest", + } +} + +// extractGoPackages extracts Go package paths from workflow data +func extractGoPackages(workflowData *WorkflowData) []string { + return collectPackagesFromWorkflow(workflowData, extractGoFromCommands, "") +} + +// extractGoFromCommands extracts Go package paths from command strings +func extractGoFromCommands(commands string) []string { + var packages []string + lines := strings.Split(commands, "\n") + + for _, line := range lines { + // Look for "go install " or "go get " patterns + words := strings.Fields(line) + for i, word := range words { + if word == "go" && i+1 < len(words) { + cmd := words[i+1] + if cmd == "install" || cmd == "get" { + // Find the package path + for j := i + 2; j < len(words); j++ { + pkg := words[j] + pkg = strings.TrimRight(pkg, "&|;") + // Skip flags (start with - or --) + if !strings.HasPrefix(pkg, "-") { + packages = append(packages, pkg) + break + } + } + } + } + } + } + + return packages +} + +// generateGoMod creates or updates go.mod with dependencies +func (c *Compiler) generateGoMod(path string, deps []GoDependency, forceOverwrite bool) error { + dependabotLog.Printf("Generating go.mod at %s", path) + + // Build module content + var lines []string + + // Check if go.mod already exists + if _, err := os.Stat(path); err == nil { + // File exists - read and preserve module declaration + dependabotLog.Print("Existing go.mod found, merging dependencies") + + existingData, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("failed to read existing go.mod: %w", err) + } + + existingLines := strings.Split(string(existingData), "\n") + // Keep module declaration and go version + for _, line := range existingLines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "module ") || strings.HasPrefix(trimmed, "go ") { + lines = append(lines, line) + } + } + + if c.verbose { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Merging with existing go.mod")) + } + } else { + // New go.mod + dependabotLog.Print("Creating new go.mod") + lines = append(lines, "module github.com/githubnext/gh-aw-workflows-deps") + lines = append(lines, "") + lines = append(lines, "go 1.21") + } + + // Add require section if we have dependencies + if len(deps) > 0 { + lines = append(lines, "") + lines = append(lines, "require (") + for _, dep := range deps { + version := dep.Version + if version == "latest" || version == "" { + // Skip dependencies without explicit versions - they should be added manually + // or resolved using 'go get' or 'go mod tidy'. Using v0.0.0 as a placeholder + // can cause issues with Go module resolution. + dependabotLog.Printf("Skipping %s: no version specified (use 'go get %s@latest' to resolve)", dep.Path, dep.Path) + continue + } + lines = append(lines, fmt.Sprintf("\t%s %s", dep.Path, version)) + } + lines = append(lines, ")") + } + + content := strings.Join(lines, "\n") + "\n" + + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + return fmt.Errorf("failed to write go.mod: %w", err) + } + + dependabotLog.Printf("Successfully wrote go.mod with %d dependencies", len(deps)) + if c.verbose { + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Generated go.mod with %d dependencies", len(deps)))) + } + + // Track the created file + if c.fileTracker != nil { + c.fileTracker.TrackCreated(path) + } + + return nil +} diff --git a/pkg/workflow/dependabot_test.go b/pkg/workflow/dependabot_test.go index d265cfbe2c7..77613009d19 100644 --- a/pkg/workflow/dependabot_test.go +++ b/pkg/workflow/dependabot_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "os" "path/filepath" + "strings" "testing" "github.com/goccy/go-yaml" @@ -259,8 +260,10 @@ func TestGenerateDependabotConfig(t *testing.T) { tempDir := t.TempDir() dependabotPath := filepath.Join(tempDir, "dependabot.yml") + ecosystems := map[string]bool{"npm": true} + // Test creating new dependabot.yml - err := compiler.generateDependabotConfig(dependabotPath, false) + err := compiler.generateDependabotConfig(dependabotPath, ecosystems, false) if err != nil { t.Fatalf("failed to generate dependabot.yml: %v", err) } @@ -320,8 +323,10 @@ func TestGenerateDependabotConfig_PreserveExisting(t *testing.T) { existingData, _ := yaml.Marshal(&existingConfig) os.WriteFile(dependabotPath, existingData, 0644) + ecosystems := map[string]bool{"npm": true} + // Try to generate without force - should preserve - err := compiler.generateDependabotConfig(dependabotPath, false) + err := compiler.generateDependabotConfig(dependabotPath, ecosystems, false) if err != nil { t.Fatalf("failed to check existing dependabot.yml: %v", err) } @@ -421,3 +426,438 @@ func TestGenerateDependabotManifests_StrictMode(t *testing.T) { } } } + +// Tests for Python (pip) support + +func TestParsePipPackage(t *testing.T) { + tests := []struct { + name string + pkg string + expectedName string + expectedVersion string + }{ + { + name: "package with == version", + pkg: "requests==2.28.0", + expectedName: "requests", + expectedVersion: "==2.28.0", + }, + { + name: "package with >= version", + pkg: "django>=3.2.0", + expectedName: "django", + expectedVersion: ">=3.2.0", + }, + { + name: "package with ~= version", + pkg: "flask~=2.0.0", + expectedName: "flask", + expectedVersion: "~=2.0.0", + }, + { + name: "package without version", + pkg: "numpy", + expectedName: "numpy", + expectedVersion: "", + }, + { + name: "package with != version", + pkg: "pytest!=7.0.0", + expectedName: "pytest", + expectedVersion: "!=7.0.0", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dep := parsePipPackage(tt.pkg) + if dep.Name != tt.expectedName { + t.Errorf("expected name %q, got %q", tt.expectedName, dep.Name) + } + if dep.Version != tt.expectedVersion { + t.Errorf("expected version %q, got %q", tt.expectedVersion, dep.Version) + } + }) + } +} + +func TestCollectPipDependencies(t *testing.T) { + compiler := NewCompiler(false, "", "test") + + tests := []struct { + name string + workflows []*WorkflowData + expectedDeps []PipDependency + }{ + { + name: "single workflow with pip dependencies", + workflows: []*WorkflowData{ + { + CustomSteps: "pip install requests==2.28.0", + }, + }, + expectedDeps: []PipDependency{ + {Name: "requests", Version: "==2.28.0"}, + }, + }, + { + name: "multiple workflows with different dependencies", + workflows: []*WorkflowData{ + { + CustomSteps: "pip install requests==2.28.0", + }, + { + CustomSteps: "pip3 install django>=3.2.0", + }, + }, + expectedDeps: []PipDependency{ + {Name: "django", Version: ">=3.2.0"}, + {Name: "requests", Version: "==2.28.0"}, + }, + }, + { + name: "duplicate dependencies use last version", + workflows: []*WorkflowData{ + { + CustomSteps: "pip install requests==2.27.0", + }, + { + CustomSteps: "pip install requests==2.28.0", + }, + }, + expectedDeps: []PipDependency{ + {Name: "requests", Version: "==2.28.0"}, + }, + }, + { + name: "no pip dependencies", + workflows: []*WorkflowData{}, + expectedDeps: []PipDependency{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + deps := compiler.collectPipDependencies(tt.workflows) + if len(deps) != len(tt.expectedDeps) { + t.Errorf("expected %d dependencies, got %d", len(tt.expectedDeps), len(deps)) + } + for i, dep := range deps { + if i >= len(tt.expectedDeps) { + break + } + expected := tt.expectedDeps[i] + if dep.Name != expected.Name { + t.Errorf("dependency %d: expected name %q, got %q", i, expected.Name, dep.Name) + } + if dep.Version != expected.Version { + t.Errorf("dependency %d: expected version %q, got %q", i, expected.Version, dep.Version) + } + } + }) + } +} + +func TestGenerateRequirementsTxt(t *testing.T) { + compiler := NewCompiler(false, "", "test") + tempDir := t.TempDir() + requirementsPath := filepath.Join(tempDir, "requirements.txt") + + deps := []PipDependency{ + {Name: "requests", Version: "==2.28.0"}, + {Name: "django", Version: ">=3.2.0"}, + } + + // Test creating new requirements.txt + err := compiler.generateRequirementsTxt(requirementsPath, deps, false) + if err != nil { + t.Fatalf("failed to generate requirements.txt: %v", err) + } + + // Verify file was created + if _, err := os.Stat(requirementsPath); os.IsNotExist(err) { + t.Fatal("requirements.txt was not created") + } + + // Read and verify content + data, err := os.ReadFile(requirementsPath) + if err != nil { + t.Fatalf("failed to read requirements.txt: %v", err) + } + + content := string(data) + if !strings.Contains(content, "django>=3.2.0") { + t.Error("requirements.txt should contain django>=3.2.0") + } + if !strings.Contains(content, "requests==2.28.0") { + t.Error("requirements.txt should contain requests==2.28.0") + } +} + +// Tests for Golang support + +func TestParseGoPackage(t *testing.T) { + tests := []struct { + name string + pkg string + expectedPath string + expectedVersion string + }{ + { + name: "package with version", + pkg: "github.com/user/repo@v1.2.3", + expectedPath: "github.com/user/repo", + expectedVersion: "v1.2.3", + }, + { + name: "package without version", + pkg: "github.com/user/repo", + expectedPath: "github.com/user/repo", + expectedVersion: "latest", + }, + { + name: "package with pseudo-version", + pkg: "golang.org/x/tools@v0.1.12-0.20220713141851-7464d2807d88", + expectedPath: "golang.org/x/tools", + expectedVersion: "v0.1.12-0.20220713141851-7464d2807d88", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dep := parseGoPackage(tt.pkg) + if dep.Path != tt.expectedPath { + t.Errorf("expected path %q, got %q", tt.expectedPath, dep.Path) + } + if dep.Version != tt.expectedVersion { + t.Errorf("expected version %q, got %q", tt.expectedVersion, dep.Version) + } + }) + } +} + +func TestCollectGoDependencies(t *testing.T) { + compiler := NewCompiler(false, "", "test") + + tests := []struct { + name string + workflows []*WorkflowData + expectedDeps []GoDependency + }{ + { + name: "single workflow with go install", + workflows: []*WorkflowData{ + { + CustomSteps: "go install github.com/user/tool@v1.0.0", + }, + }, + expectedDeps: []GoDependency{ + {Path: "github.com/user/tool", Version: "v1.0.0"}, + }, + }, + { + name: "multiple workflows with different dependencies", + workflows: []*WorkflowData{ + { + CustomSteps: "go install github.com/user/tool@v1.0.0", + }, + { + CustomSteps: "go get golang.org/x/tools@latest", + }, + }, + expectedDeps: []GoDependency{ + {Path: "github.com/user/tool", Version: "v1.0.0"}, + {Path: "golang.org/x/tools", Version: "latest"}, + }, + }, + { + name: "duplicate dependencies use last version", + workflows: []*WorkflowData{ + { + CustomSteps: "go install github.com/user/tool@v1.0.0", + }, + { + CustomSteps: "go install github.com/user/tool@v2.0.0", + }, + }, + expectedDeps: []GoDependency{ + {Path: "github.com/user/tool", Version: "v2.0.0"}, + }, + }, + { + name: "no go dependencies", + workflows: []*WorkflowData{}, + expectedDeps: []GoDependency{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + deps := compiler.collectGoDependencies(tt.workflows) + if len(deps) != len(tt.expectedDeps) { + t.Errorf("expected %d dependencies, got %d", len(tt.expectedDeps), len(deps)) + } + for i, dep := range deps { + if i >= len(tt.expectedDeps) { + break + } + expected := tt.expectedDeps[i] + if dep.Path != expected.Path { + t.Errorf("dependency %d: expected path %q, got %q", i, expected.Path, dep.Path) + } + if dep.Version != expected.Version { + t.Errorf("dependency %d: expected version %q, got %q", i, expected.Version, dep.Version) + } + } + }) + } +} + +func TestGenerateGoMod(t *testing.T) { + compiler := NewCompiler(false, "", "test") + tempDir := t.TempDir() + goModPath := filepath.Join(tempDir, "go.mod") + + deps := []GoDependency{ + {Path: "github.com/user/tool", Version: "v1.0.0"}, + {Path: "golang.org/x/tools", Version: "v0.1.0"}, + } + + // Test creating new go.mod + err := compiler.generateGoMod(goModPath, deps, false) + if err != nil { + t.Fatalf("failed to generate go.mod: %v", err) + } + + // Verify file was created + if _, err := os.Stat(goModPath); os.IsNotExist(err) { + t.Fatal("go.mod was not created") + } + + // Read and verify content + data, err := os.ReadFile(goModPath) + if err != nil { + t.Fatalf("failed to read go.mod: %v", err) + } + + content := string(data) + if !strings.Contains(content, "module github.com/githubnext/gh-aw-workflows-deps") { + t.Error("go.mod should contain module declaration") + } + if !strings.Contains(content, "require (") { + t.Error("go.mod should contain require section") + } + if !strings.Contains(content, "github.com/user/tool v1.0.0") { + t.Error("go.mod should contain github.com/user/tool v1.0.0") + } + if !strings.Contains(content, "golang.org/x/tools v0.1.0") { + t.Error("go.mod should contain golang.org/x/tools v0.1.0") + } +} + +// Tests for multi-ecosystem support + +func TestGenerateDependabotConfig_MultipleEcosystems(t *testing.T) { + compiler := NewCompiler(false, "", "test") + tempDir := t.TempDir() + dependabotPath := filepath.Join(tempDir, "dependabot.yml") + + ecosystems := map[string]bool{ + "npm": true, + "pip": true, + "gomod": true, + } + + // Test creating new dependabot.yml with multiple ecosystems + err := compiler.generateDependabotConfig(dependabotPath, ecosystems, false) + if err != nil { + t.Fatalf("failed to generate dependabot.yml: %v", err) + } + + // Verify file was created + if _, err := os.Stat(dependabotPath); os.IsNotExist(err) { + t.Fatal("dependabot.yml was not created") + } + + // Read and verify content + data, err := os.ReadFile(dependabotPath) + if err != nil { + t.Fatalf("failed to read dependabot.yml: %v", err) + } + + var config DependabotConfig + if err := yaml.Unmarshal(data, &config); err != nil { + t.Fatalf("failed to parse dependabot.yml: %v", err) + } + + // Verify structure + if config.Version != 2 { + t.Errorf("expected version 2, got %d", config.Version) + } + if len(config.Updates) != 3 { + t.Fatalf("expected 3 update entries, got %d", len(config.Updates)) + } + + // Check that all ecosystems are present + ecosystemsFound := make(map[string]bool) + for _, update := range config.Updates { + ecosystemsFound[update.PackageEcosystem] = true + if update.Directory != "/.github/workflows" { + t.Errorf("expected directory '/.github/workflows', got %q", update.Directory) + } + if update.Schedule.Interval != "weekly" { + t.Errorf("expected interval 'weekly', got %q", update.Schedule.Interval) + } + } + + for ecosystem := range ecosystems { + if !ecosystemsFound[ecosystem] { + t.Errorf("ecosystem %q not found in dependabot.yml", ecosystem) + } + } +} + +func TestGenerateDependabotManifests_AllEcosystems(t *testing.T) { + compiler := NewCompiler(true, "", "test") + tempDir := t.TempDir() + workflowDir := filepath.Join(tempDir, ".github", "workflows") + os.MkdirAll(workflowDir, 0755) + + // Workflow with npm, pip, and go dependencies + workflows := []*WorkflowData{ + { + CustomSteps: ` +npx @playwright/mcp@latest +pip install requests==2.28.0 +go install github.com/user/tool@v1.0.0 +`, + }, + } + + // This will skip npm install (no npm in test env), but should generate manifest files + _ = compiler.GenerateDependabotManifests(workflows, workflowDir, false) + + // Check that package.json was created + packageJSONPath := filepath.Join(workflowDir, "package.json") + if _, err := os.Stat(packageJSONPath); os.IsNotExist(err) { + t.Error("package.json should be created") + } + + // Check that requirements.txt was created + requirementsPath := filepath.Join(workflowDir, "requirements.txt") + if _, err := os.Stat(requirementsPath); os.IsNotExist(err) { + t.Error("requirements.txt should be created") + } + + // Check that go.mod was created + goModPath := filepath.Join(workflowDir, "go.mod") + if _, err := os.Stat(goModPath); os.IsNotExist(err) { + t.Error("go.mod should be created") + } + + // Check dependabot.yml + dependabotPath := filepath.Join(tempDir, ".github", "dependabot.yml") + if _, err := os.Stat(dependabotPath); os.IsNotExist(err) { + t.Error("dependabot.yml should be created") + } +}