From 49e33d6f7259d1efb990a2d8555c2bcbe14bb763 Mon Sep 17 00:00:00 2001 From: Ian Bentley Date: Tue, 23 Sep 2025 09:17:20 -0400 Subject: [PATCH 01/42] add new register custom code command --- cmd/registercustomcode.go | 46 +++++++++++++++++++++++++++++++++++++++ cmd/root.go | 2 ++ 2 files changed, 48 insertions(+) create mode 100644 cmd/registercustomcode.go diff --git a/cmd/registercustomcode.go b/cmd/registercustomcode.go new file mode 100644 index 000000000..cdf913636 --- /dev/null +++ b/cmd/registercustomcode.go @@ -0,0 +1,46 @@ +package cmd + +import ( + "context" + + "github.com/speakeasy-api/openapi-generation/v2/pkg/generate" + "github.com/speakeasy-api/speakeasy/internal/model" + "github.com/speakeasy-api/speakeasy/internal/model/flag" +) + +type RegisterCustomCodeFlags struct { + OutDir string `json:"out-dir"` +} + +var registerCustomCodeCmd = &model.ExecutableCommand[RegisterCustomCodeFlags]{ + Usage: "registercustomcode", + Short: "Register custom code with the OpenAPI generation system.", + Long: `Register custom code with the OpenAPI generation system.`, + Run: registerCustomCode, + Flags: []flag.Flag{ + flag.StringFlag{ + Name: "out-dir", + Shorthand: "o", + Description: "output directory for the registercustomcode command", + }, + }, +} + +func registerCustomCode(ctx context.Context, flags RegisterCustomCodeFlags) error { + outDir := flags.OutDir + if outDir == "" { + outDir = "." + } + + // Create generator options + generatorOpts := []generate.GeneratorOptions{} + + // Create generator instance + g, err := generate.New(generatorOpts...) + if err != nil { + return err + } + + // Call the registercustomcode functionality + return g.RegisterCustomCode(ctx, outDir) +} \ No newline at end of file diff --git a/cmd/root.go b/cmd/root.go index 7954b81c4..b0bbaa45f 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -97,6 +97,8 @@ func Init(version, artifactArch string) { addCommand(rootCmd, reproCmd) addCommand(rootCmd, orphanedFilesCmd) pullInit() + addCommand(rootCmd, pullCmd) + addCommand(rootCmd, registerCustomCodeCmd) } func addCommand(cmd *cobra.Command, command model.Command) { From 8df96f5778a52715a60d50651c59580cd25b93d9 Mon Sep 17 00:00:00 2001 From: Ian Bentley Date: Tue, 23 Sep 2025 17:02:16 -0400 Subject: [PATCH 02/42] add commands for registercustomcode --- cmd/registercustomcode.go | 28 +++++++++++++++++++++++++--- cmd/root.go | 2 +- go.mod | 2 ++ internal/run/source.go | 1 + internal/studio/progressUpdates.go | 2 ++ 5 files changed, 31 insertions(+), 4 deletions(-) diff --git a/cmd/registercustomcode.go b/cmd/registercustomcode.go index cdf913636..ea338ac35 100644 --- a/cmd/registercustomcode.go +++ b/cmd/registercustomcode.go @@ -9,7 +9,9 @@ import ( ) type RegisterCustomCodeFlags struct { - OutDir string `json:"out-dir"` + OutDir string `json:"out-dir"` + List bool `json:"list"` + Resolve bool `json:"resolve"` } var registerCustomCodeCmd = &model.ExecutableCommand[RegisterCustomCodeFlags]{ @@ -23,10 +25,20 @@ var registerCustomCodeCmd = &model.ExecutableCommand[RegisterCustomCodeFlags]{ Shorthand: "o", Description: "output directory for the registercustomcode command", }, + flag.BooleanFlag{ + Name: "list", + Shorthand: "l", + Description: "list custom code patches", + }, + flag.BooleanFlag{ + Name: "resolve", + Shorthand: "r", + Description: "resolve custom code conflicts", + }, }, } -func registerCustomCode(ctx context.Context, flags RegisterCustomCodeFlags) error { +func registerCustomCode(_ context.Context, flags RegisterCustomCodeFlags) error { outDir := flags.OutDir if outDir == "" { outDir = "." @@ -41,6 +53,16 @@ func registerCustomCode(ctx context.Context, flags RegisterCustomCodeFlags) erro return err } + // If --list flag is provided, call ListCustomCodePatches + if flags.List { + return g.ListCustomCodePatches(outDir) + } + + // If --resolve flag is provided, call ResolveCustomCodeConflicts + if flags.Resolve { + return g.ResolveCustomCodeConflicts(outDir) + } + // Call the registercustomcode functionality - return g.RegisterCustomCode(ctx, outDir) + return g.RegisterCustomCode(outDir) } \ No newline at end of file diff --git a/cmd/root.go b/cmd/root.go index b0bbaa45f..8001a949a 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -97,7 +97,7 @@ func Init(version, artifactArch string) { addCommand(rootCmd, reproCmd) addCommand(rootCmd, orphanedFilesCmd) pullInit() - addCommand(rootCmd, pullCmd) + // addCommand(rootCmd, pullCmd) addCommand(rootCmd, registerCustomCodeCmd) } diff --git a/go.mod b/go.mod index d7879c1e4..868ba1fb5 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,8 @@ replace github.com/pb33f/doctor => github.com/speakeasy-api/doctor v0.20.0-fixva replace github.com/pb33f/libopenapi => github.com/speakeasy-api/libopenapi v0.21.9-fixhiddencomps-fixed +replace github.com/speakeasy-api/openapi-generation/v2 => ../openapi-generation + require ( github.com/AlekSi/pointer v1.2.0 github.com/KimMachineGun/automemlimit v0.7.1 diff --git a/internal/run/source.go b/internal/run/source.go index 137ea77ab..d031cd525 100644 --- a/internal/run/source.go +++ b/internal/run/source.go @@ -41,6 +41,7 @@ const ( // Generator steps SourceStepStart SourceStepID = "Started" SourceStepGenerate SourceStepID = "Generating SDK" + SourceStepApplyCodeChanges SourceStepID = "Applying Code Changes" SourceStepCompile SourceStepID = "Compiling SDK" SourceStepComplete SourceStepID = "Completed" SourceStepCancel SourceStepID = "Cancelling" diff --git a/internal/studio/progressUpdates.go b/internal/studio/progressUpdates.go index 4d99610e7..e575d7cf2 100644 --- a/internal/studio/progressUpdates.go +++ b/internal/studio/progressUpdates.go @@ -34,6 +34,8 @@ func (h *StudioHandlers) enableGenerationProgressUpdates(w http.ResponseWriter, switch progressUpdate.Step.ID { case generate.ProgressStepGenSDK: step = run.SourceStepGenerate + case generate.ProgressStepApplyCustomCode: + step = run.SourceStepApplyCodeChanges case generate.ProgressStepCompileSDK: step = run.SourceStepCompile case generate.ProgressStepCancel: From 38fe3e5d31e7f5242783b3ef8d17e52c9d16d38d Mon Sep 17 00:00:00 2001 From: Ian Bentley Date: Wed, 24 Sep 2025 15:01:54 -0400 Subject: [PATCH 03/42] pass sufficient context to support generation --- cmd/registercustomcode.go | 90 +++++++++++++++++++++++++++++++-------- internal/model/command.go | 3 +- internal/sdkgen/sdkgen.go | 1 + 3 files changed, 76 insertions(+), 18 deletions(-) diff --git a/cmd/registercustomcode.go b/cmd/registercustomcode.go index ea338ac35..91d5a3a8e 100644 --- a/cmd/registercustomcode.go +++ b/cmd/registercustomcode.go @@ -2,24 +2,32 @@ package cmd import ( "context" + "fmt" "github.com/speakeasy-api/openapi-generation/v2/pkg/generate" "github.com/speakeasy-api/speakeasy/internal/model" "github.com/speakeasy-api/speakeasy/internal/model/flag" + "github.com/speakeasy-api/speakeasy/internal/utils" ) type RegisterCustomCodeFlags struct { + Target string `json:"target"` OutDir string `json:"out-dir"` List bool `json:"list"` Resolve bool `json:"resolve"` } var registerCustomCodeCmd = &model.ExecutableCommand[RegisterCustomCodeFlags]{ - Usage: "registercustomcode", - Short: "Register custom code with the OpenAPI generation system.", - Long: `Register custom code with the OpenAPI generation system.`, - Run: registerCustomCode, - Flags: []flag.Flag{ + Usage: "registercustomcode", + Short: "Register custom code with the OpenAPI generation system.", + Long: `Register custom code with the OpenAPI generation system.`, + Run: registerCustomCode, + Flags: []flag.Flag{ + flag.StringFlag{ + Name: "target", + Shorthand: "t", + Description: "target to run. specify 'all' to run all targets", + }, flag.StringFlag{ Name: "out-dir", Shorthand: "o", @@ -38,31 +46,79 @@ var registerCustomCodeCmd = &model.ExecutableCommand[RegisterCustomCodeFlags]{ }, } -func registerCustomCode(_ context.Context, flags RegisterCustomCodeFlags) error { +func registerCustomCode(ctx context.Context, flags RegisterCustomCodeFlags) error { outDir := flags.OutDir if outDir == "" { outDir = "." } - // Create generator options - generatorOpts := []generate.GeneratorOptions{} + // Load workflow to get target and schemaPath + wf, _, err := utils.GetWorkflowAndDir() + if err != nil { + return fmt.Errorf("failed to load workflow: %w", err) + } + + // Get the target from the command flag or use the single available target + var target string + var schemaPath string + if flags.Target != "" { + target = flags.Target + } else if len(wf.Targets) == 1 { + // If no target specified but there's exactly one target, use it + for tid := range wf.Targets { + target = tid + break + } + } + + if target == "" { + return fmt.Errorf("no target specified and no targets found in workflow") + } + + // Get the target configuration + targetConfig, exists := wf.Targets[target] + if !exists { + return fmt.Errorf("target '%s' not found in workflow", target) + } + + // Get the schema path from the target's source + source, sourcePath, err := wf.GetTargetSource(target) + if err != nil { + return fmt.Errorf("failed to get target source: %w", err) + } + + if source != nil { + // Source is defined in workflow, use the source inputs + for _, input := range source.Inputs { + if input.Location != "" { + schemaPath = string(input.Location) + break + } + } + } else if sourcePath != "" { + // Direct source path specified + schemaPath = sourcePath + } else { + // Use the target source as the schema path + schemaPath = targetConfig.Source + } + + if schemaPath == "" { + return fmt.Errorf("could not determine schema path for target '%s'", target) + } + // Create generator instance - g, err := generate.New(generatorOpts...) + g, err := generate.New() if err != nil { return err } - // If --list flag is provided, call ListCustomCodePatches + // If --list flag is provided, call ListCustomCodePatch if flags.List { - return g.ListCustomCodePatches(outDir) - } - - // If --resolve flag is provided, call ResolveCustomCodeConflicts - if flags.Resolve { - return g.ResolveCustomCodeConflicts(outDir) + return g.ListCustomCodePatch(outDir) } // Call the registercustomcode functionality - return g.RegisterCustomCode(outDir) + return g.RegisterCustomCode(outDir, targetConfig.Target, schemaPath) } \ No newline at end of file diff --git a/internal/model/command.go b/internal/model/command.go index fc41cb2a8..ecf4d6c07 100644 --- a/internal/model/command.go +++ b/internal/model/command.go @@ -343,7 +343,8 @@ func runWithVersionFromWorkflowFile(cmd *cobra.Command) error { if lockfileVersion != "" && lockfileVersion != desiredVersion { logger.PrintfStyled(styles.DimmedItalic, "Rerunning with previous successful version") - return runWithVersion(cmd, artifactArch, lockfileVersion, false) + // force to run local version in dev by giving malformed "latest" version + return runWithVersion(cmd, artifactArch, "latest", false) } } diff --git a/internal/sdkgen/sdkgen.go b/internal/sdkgen/sdkgen.go index 80017b359..0cec948f2 100644 --- a/internal/sdkgen/sdkgen.go +++ b/internal/sdkgen/sdkgen.go @@ -148,6 +148,7 @@ func Generate(ctx context.Context, opts GenerateOptions) (*GenerationAccess, err generate.WithCLIVersion(opts.CLIVersion), generate.WithForceGeneration(), generate.WithChangelogReleaseNotes(opts.ReleaseNotes), + generate.WithApplyCustomCode(), } if opts.Verbose { From f61a7d1061235650ae51bdf4c1a24069389cff92 Mon Sep 17 00:00:00 2001 From: Ivan Gorshkov Date: Fri, 26 Sep 2025 18:04:37 +0100 Subject: [PATCH 04/42] feat: implement register custom code functionality Add comprehensive support for registering custom code with: - New registercustomcode command and internal package - Integration with quickstart workflow - Target configuration updates for custom code handling - SDK generation enhancements to support custom code registration --- cmd/quickstart.go | 4 + cmd/registercustomcode.go | 12 +- .../registercustomcode/registercustomcode.go | 536 ++++++++++++++++++ internal/run/target.go | 1 + internal/sdkgen/sdkgen.go | 6 +- 5 files changed, 549 insertions(+), 10 deletions(-) create mode 100644 internal/registercustomcode/registercustomcode.go diff --git a/cmd/quickstart.go b/cmd/quickstart.go index 3fb486da3..e26633e2b 100644 --- a/cmd/quickstart.go +++ b/cmd/quickstart.go @@ -484,6 +484,10 @@ func retryWithSampleSpec(ctx context.Context, workflowFile *workflow.Workflow, i run.WithTarget(initialTarget), run.WithShouldCompile(!skipCompile), ) + if err != nil { + return false, err + } + wf.FromQuickstart = true if err != nil { return false, fmt.Errorf("failed to parse workflow: %w", err) diff --git a/cmd/registercustomcode.go b/cmd/registercustomcode.go index 91d5a3a8e..b050fe330 100644 --- a/cmd/registercustomcode.go +++ b/cmd/registercustomcode.go @@ -4,9 +4,9 @@ import ( "context" "fmt" - "github.com/speakeasy-api/openapi-generation/v2/pkg/generate" "github.com/speakeasy-api/speakeasy/internal/model" "github.com/speakeasy-api/speakeasy/internal/model/flag" + "github.com/speakeasy-api/speakeasy/internal/registercustomcode" "github.com/speakeasy-api/speakeasy/internal/utils" ) @@ -108,17 +108,11 @@ func registerCustomCode(ctx context.Context, flags RegisterCustomCodeFlags) erro return fmt.Errorf("could not determine schema path for target '%s'", target) } - // Create generator instance - g, err := generate.New() - if err != nil { - return err - } - // If --list flag is provided, call ListCustomCodePatch if flags.List { - return g.ListCustomCodePatch(outDir) + return registercustomcode.ListCustomCodePatch(outDir) } // Call the registercustomcode functionality - return g.RegisterCustomCode(outDir, targetConfig.Target, schemaPath) + return registercustomcode.RegisterCustomCode(ctx, outDir, targetConfig.Target, schemaPath) } \ No newline at end of file diff --git a/internal/registercustomcode/registercustomcode.go b/internal/registercustomcode/registercustomcode.go new file mode 100644 index 000000000..93a6ba112 --- /dev/null +++ b/internal/registercustomcode/registercustomcode.go @@ -0,0 +1,536 @@ +package registercustomcode + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + config "github.com/speakeasy-api/sdk-gen-config" + "github.com/speakeasy-api/speakeasy/internal/log" + "github.com/speakeasy-api/speakeasy/internal/sdkgen" + "go.uber.org/zap" + "gopkg.in/yaml.v3" +) + +// RegisterCustomCode registers custom code changes by capturing them as patches in gen.lock +func RegisterCustomCode(ctx context.Context, outDir, target, schemaPath string) error { + logger := log.From(ctx).With(zap.String("method", "RegisterCustomCode")) + + // Step 1: Verify main is up to date with origin/main + if err := verifyMainUpToDate(ctx); err != nil { + return fmt.Errorf("main branch verification failed: %w", err) + } + + // Step 2: Check changeset doesn't include .speakeasy directory changes + if err := checkNoSpeakeasyChanges(ctx); err != nil { + return fmt.Errorf("changeset validation failed: %w", err) + } + + // Step 3: Check if workflow.yaml references local openapi spec and validate no spec changes + if err := checkNoLocalSpecChanges(ctx, outDir); err != nil { + return fmt.Errorf("openapi spec validation failed: %w", err) + } + + // Step 4: Capture patchset with git diff for custom code changes + customCodeDiff, err := captureCustomCodeDiff() + if err != nil { + return fmt.Errorf("failed to capture custom code diff: %w", err) + } + + // If no custom code changes detected, return early + if customCodeDiff == "" { + logger.Info("No custom code changes detected, nothing to register") + return nil + } + + // Step 5: Generate clean SDK (without custom code) on main branch + if err := generateCleanSDK(ctx, outDir, target, schemaPath); err != nil { + return fmt.Errorf("failed to generate clean SDK: %w", err) + } + + // Step 6: Apply existing custom code patch from gen.lock + if err := applyCustomCodePatch(outDir); err != nil { + return fmt.Errorf("failed to apply existing patch: %w", err) + } + + // Step 7: Stage all changes after applying existing patch + if err := stageAllChanges(); err != nil { + return fmt.Errorf("failed to stage changes after applying existing patch: %w", err) + } + + // Step 8: Pause for user inspection + if err := pauseForUserInspection(ctx); err != nil { + return fmt.Errorf("user inspection interrupted: %w", err) + } + + // Step 9: Apply the new custom code diff + if customCodeDiff != "" { + // Emit the new patch before applying it + if err := emitNewPatch(ctx, customCodeDiff); err != nil { + logger.Warn("Failed to emit new patch", zap.Error(err)) + } + + if err := applyNewPatch(customCodeDiff); err != nil { + logger.Warn("Conflicts detected when applying new patch") + return fmt.Errorf("conflicts detected when applying new patch: %w", err) + } + } + + // Step 10: Capture the full combined diff (existing patch + new changes) + fullCustomCodeDiff, err := captureCustomCodeDiff() + if err != nil { + return fmt.Errorf("failed to capture full custom code diff: %w", err) + } + + // Step 11: Reset to clean state and regenerate clean SDK + if err := resetToCleanState(ctx); err != nil { + return fmt.Errorf("failed to reset to clean state: %w", err) + } + + // TODO: compile and lint + + // Step 12: Update gen.lock with full combined patch + if err := updateGenLockWithPatch(outDir, fullCustomCodeDiff); err != nil { + return fmt.Errorf("failed to update gen.lock: %w", err) + } + + // Step 13: Commit just gen.lock with new patch + if err := commitGenLock(); err != nil { + return fmt.Errorf("failed to commit gen.lock: %w", err) + } + + // Step 14: Emit/output the full patch for visibility + if err := emitFullPatch(ctx, fullCustomCodeDiff); err != nil { + logger.Warn("Failed to emit full patch", zap.Error(err)) + } + + logger.Info("Successfully registered custom code changes") + return nil +} + +// ListCustomCodePatch displays the custom code patch stored in the gen.lock file +func ListCustomCodePatch(outDir string) error { + cfg, err := config.Load(outDir) + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + customCodePatch, exists := cfg.LockFile.Management.AdditionalProperties["customCodePatch"] + if !exists { + fmt.Println("No custom code patch found in gen.lock") + return nil + } + + patchStr, ok := customCodePatch.(string) + if !ok || patchStr == "" { + fmt.Println("No custom code patch found in gen.lock") + return nil + } + + fmt.Println("Found custom code patch:") + fmt.Println("----------------------") + fmt.Printf("%s\n", patchStr) + + return nil +} + +// Git validation helpers +func verifyMainUpToDate(ctx context.Context) error { + logger := log.From(ctx) + logger.Info("Verifying main branch is up to date with origin/main") + + // Fetch origin/main + cmd := exec.Command("git", "fetch", "origin", "main") + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to fetch origin/main: %w\nOutput: %s", err, string(output)) + } + + // Check if main is up to date with origin/main + cmd = exec.Command("git", "rev-list", "--count", "main..origin/main") + output, err := cmd.Output() + if err != nil { + return fmt.Errorf("failed to check main status: %w", err) + } + + count := strings.TrimSpace(string(output)) + if count != "0" { + return fmt.Errorf("main is not up to date with origin/main (%s commits behind)", count) + } + + logger.Info("Main branch is up to date with origin/main") + return nil +} + +func checkNoSpeakeasyChanges(ctx context.Context) error { + logger := log.From(ctx) + logger.Info("Checking that changeset doesn't include .speakeasy directory changes") + + cmd := exec.Command("git", "diff", "--name-only", "main") + output, err := cmd.Output() + if err != nil { + return fmt.Errorf("failed to get changed files: %w", err) + } + + files := strings.Split(strings.TrimSpace(string(output)), "\n") + speakeasyFiles := []string{} + + for _, file := range files { + if file != "" && strings.Contains(file, ".speakeasy/") { + speakeasyFiles = append(speakeasyFiles, file) + } + } + + if len(speakeasyFiles) > 0 { + return fmt.Errorf("changeset contains .speakeasy directory changes: %s", strings.Join(speakeasyFiles, ", ")) + } + + logger.Info("No .speakeasy directory changes found in changeset") + return nil +} + +func checkNoLocalSpecChanges(ctx context.Context, outDir string) error { + logger := log.From(ctx) + logger.Info("Checking if workflow.yaml references local OpenAPI specs and validating no spec changes") + + // Look for workflow.yaml to determine if there's a local openapi spec + workflowPath := filepath.Join(outDir, ".speakeasy", "workflow.yaml") + if _, err := os.Stat(workflowPath); os.IsNotExist(err) { + logger.Info("No workflow.yaml found, skipping local spec validation") + return nil + } + + // Read workflow.yaml to find local spec references + workflowData, err := os.ReadFile(workflowPath) + if err != nil { + return fmt.Errorf("failed to read workflow.yaml: %w", err) + } + + var workflow map[string]interface{} + if err := yaml.Unmarshal(workflowData, &workflow); err != nil { + return fmt.Errorf("failed to parse workflow.yaml: %w", err) + } + + // Extract local spec paths from workflow + localSpecPaths := extractLocalSpecPaths(workflow) + if len(localSpecPaths) == 0 { + logger.Info("No local OpenAPI specs referenced in workflow.yaml") + return nil + } + + logger.Info("Found local OpenAPI spec paths", zap.Strings("paths", localSpecPaths)) + + // Check if any of the local spec files have changes + cmd := exec.Command("git", "diff", "--name-only", "main") + output, err := cmd.Output() + if err != nil { + return fmt.Errorf("failed to get changed files: %w", err) + } + + changedFiles := strings.Split(strings.TrimSpace(string(output)), "\n") + conflictingFiles := []string{} + + for _, specPath := range localSpecPaths { + for _, changedFile := range changedFiles { + if changedFile == specPath { + conflictingFiles = append(conflictingFiles, specPath) + } + } + } + + if len(conflictingFiles) > 0 { + return fmt.Errorf("changeset contains local openapi spec changes: %s", strings.Join(conflictingFiles, ", ")) + } + + logger.Info("No local OpenAPI spec changes found in changeset") + return nil +} + +func extractLocalSpecPaths(workflow map[string]interface{}) []string { + var paths []string + + // Handle different workflow.yaml formats + // Format 1: sources -> source_name -> inputs -> location + if sources, ok := workflow["sources"].(map[string]interface{}); ok { + for _, source := range sources { + if sourceMap, ok := source.(map[string]interface{}); ok { + if inputs, ok := sourceMap["inputs"].([]interface{}); ok { + for _, input := range inputs { + if inputMap, ok := input.(map[string]interface{}); ok { + if location, ok := inputMap["location"].(string); ok { + paths = append(paths, extractLocalPath(location)...) + } + } + } + } + } + } + } + + // Format 2: targets -> target_name -> source -> inputs -> location + if targets, ok := workflow["targets"].(map[string]interface{}); ok { + for _, target := range targets { + if targetMap, ok := target.(map[string]interface{}); ok { + if source, ok := targetMap["source"].(map[string]interface{}); ok { + if inputs, ok := source["inputs"].([]interface{}); ok { + for _, input := range inputs { + if inputMap, ok := input.(map[string]interface{}); ok { + if location, ok := inputMap["location"].(string); ok { + paths = append(paths, extractLocalPath(location)...) + } + } + } + } + } + } + } + } + + return paths +} + +func extractLocalPath(location string) []string { + var paths []string + + // Check if this is a local file path (not a URL or registry path) + if !strings.HasPrefix(location, "http") && !strings.Contains(location, "registry.") && !strings.HasPrefix(location, "git+") { + // Handle relative paths and absolute paths + if strings.HasPrefix(location, "./") || strings.HasPrefix(location, "../") || strings.HasPrefix(location, "/") || (!strings.Contains(location, "://") && !strings.Contains(location, "@")) { + paths = append(paths, location) + } + } + + return paths +} + +func generateCleanSDK(ctx context.Context, outDir, target, schemaPath string) error { + logger := log.From(ctx) + logger.Info("Generating clean SDK (without custom code)", zap.String("target", target), zap.String("schemaPath", schemaPath), zap.String("outDir", outDir)) + + // Use sdkgen.Generate with SkipCustomCode option + _, err := sdkgen.Generate(ctx, sdkgen.GenerateOptions{ + Language: target, + SchemaPath: schemaPath, + OutDir: outDir, + SkipVersioning: true, + SkipCustomCode: true, // This is the key difference from normal generation + Compile: false, + }) + + if err != nil { + return fmt.Errorf("failed to generate SDK: %w", err) + } + + logger.Info("Clean SDK generation completed successfully") + return nil +} + +// Git operations +func captureCustomCodeDiff() (string, error) { + cmd := exec.Command("git", "diff", "HEAD") + output, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("failed to capture git diff: %w", err) + } + + return string(output), nil +} + +func stageAllChanges() error { + // Add all changes + addCmd := exec.Command("git", "add", ".") + if output, err := addCmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to add changes: %w\nOutput: %s", err, string(output)) + } + + return nil +} + +func unstageAllChanges() error { + resetCmd := exec.Command("git", "reset") + if output, err := resetCmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to reset changes: %w\nOutput: %s", err, string(output)) + } + + return nil +} + +func resetToCleanState(ctx context.Context) error { + logger := log.From(ctx) + logger.Info("Resetting to clean state") + + // Reset all changes to get back to a clean state + cmd := exec.Command("git", "reset", "--hard", "HEAD") + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to reset to clean state: %w\nOutput: %s", err, string(output)) + } + + logger.Info("Successfully reset to clean state") + return nil +} + +func commitGenLock() error { + // Add only the gen.lock file + cmd := exec.Command("git", "add", ".speakeasy/gen.lock") + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to add gen.lock: %w", err) + } + + // Commit with a descriptive message + commitMsg := "Register custom code changes" + cmd = exec.Command("git", "commit", "-m", commitMsg) + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to commit gen.lock: %w", err) + } + + return nil +} + +// Patch management +func applyCustomCodePatch(outDir string) error { + // Load the current configuration and lock file + cfg, err := config.Load(outDir) + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + // Add and commit changes before applying custom code patch + if err := stageAllChanges(); err != nil { + return fmt.Errorf("failed to add changes: %w", err) + } + + // Check if there's a custom code patch in the management section + if customCodePatch, exists := cfg.LockFile.Management.AdditionalProperties["customCodePatch"]; exists { + if patchStr, ok := customCodePatch.(string); ok && patchStr != "" { + // Create a temporary patch file + patchFile := filepath.Join(outDir, ".speakeasy", "temp_patch.patch") + if err := os.WriteFile(patchFile, []byte(patchStr), 0644); err != nil { + return fmt.Errorf("failed to write patch file: %w", err) + } + defer os.Remove(patchFile) + + // Apply the patch with 3-way merge + cmd := exec.Command("git", "apply", "-3", patchFile) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to apply patch: %w\nOutput: %s", err, string(output)) + } + } + } + + if err := unstageAllChanges(); err != nil { + return fmt.Errorf("failed to reset changes: %w", err) + } + + return nil +} + +func applyNewPatch(customCodeDiff string) error { + if customCodeDiff == "" { + return nil + } + + // Create a temporary patch file + patchFile := ".speakeasy/temp_new_patch.patch" + if err := os.WriteFile(patchFile, []byte(customCodeDiff), 0644); err != nil { + return fmt.Errorf("failed to write new patch file: %w", err) + } + defer os.Remove(patchFile) + + // Apply the patch with 3-way merge + cmd := exec.Command("git", "apply", "-3", "--theirs", patchFile) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to apply new patch: %w\nOutput: %s", err, string(output)) + } + + return nil +} + +func updateGenLockWithPatch(outDir, patchset string) error { + // Load the current configuration and lock file + cfg, err := config.Load(outDir) + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + // Initialize AdditionalProperties if nil + if cfg.LockFile.Management.AdditionalProperties == nil { + cfg.LockFile.Management.AdditionalProperties = make(map[string]any) + } + + // Store single patch (replaces any existing patch) + if patchset != "" { + cfg.LockFile.Management.AdditionalProperties["customCodePatch"] = patchset + } else { + // Remove the patch if empty + delete(cfg.LockFile.Management.AdditionalProperties, "customCodePatch") + } + + // Save the updated gen.lock + if err := config.SaveLockFile(outDir, cfg.LockFile); err != nil { + return fmt.Errorf("failed to save gen.lock: %w", err) + } + + return nil +} + +// User interaction +func pauseForUserInspection(ctx context.Context) error { + logger := log.From(ctx) + logger.Info("Pausing for user inspection") + + fmt.Println("\n" + strings.Repeat("*", 80)) + fmt.Println("PAUSED: Existing patch has been applied and changes staged.") + fmt.Println("You can now inspect the applied changes before the new patch is applied.") + fmt.Println("Press any key to continue...") + fmt.Println(strings.Repeat("*", 80)) + + // Read a single byte from stdin (user pressing any key) + var input [1]byte + _, err := os.Stdin.Read(input[:]) + if err != nil { + return fmt.Errorf("failed to read user input: %w", err) + } + + fmt.Println("Continuing with new patch application...") + return nil +} + +func emitNewPatch(ctx context.Context, newPatch string) error { + logger := log.From(ctx) + logger.Info("Emitting new custom code patch") + + if newPatch == "" { + fmt.Println("No new custom code changes to apply.") + return nil + } + + fmt.Println("\n" + strings.Repeat("-", 80)) + fmt.Println("NEW CUSTOM CODE PATCH (about to apply)") + fmt.Println(strings.Repeat("-", 80)) + fmt.Println(newPatch) + fmt.Println(strings.Repeat("-", 80)) + fmt.Println("") + + return nil +} + +func emitFullPatch(ctx context.Context, fullPatch string) error { + logger := log.From(ctx) + logger.Info("Emitting full custom code patch") + + if fullPatch == "" { + fmt.Println("No custom code changes detected.") + return nil + } + + fmt.Println("\n" + strings.Repeat("=", 80)) + fmt.Println("FULL CUSTOM CODE PATCH") + fmt.Println(strings.Repeat("=", 80)) + fmt.Println(fullPatch) + fmt.Println(strings.Repeat("=", 80)) + fmt.Println("") + + return nil +} \ No newline at end of file diff --git a/internal/run/target.go b/internal/run/target.go index 6fb3eb673..bce65546e 100644 --- a/internal/run/target.go +++ b/internal/run/target.go @@ -222,6 +222,7 @@ func (w *Workflow) runTarget(ctx context.Context, target string) (*SourceResult, Compile: w.ShouldCompile, TargetName: target, SkipVersioning: w.SkipVersioning, + SkipCustomCode: w.FromQuickstart, CancellableGeneration: w.CancellableGeneration, StreamableGeneration: w.StreamableGeneration, ReleaseNotes: changelogContent, diff --git a/internal/sdkgen/sdkgen.go b/internal/sdkgen/sdkgen.go index 0cec948f2..2676eadb4 100644 --- a/internal/sdkgen/sdkgen.go +++ b/internal/sdkgen/sdkgen.go @@ -68,6 +68,7 @@ type GenerateOptions struct { Compile bool TargetName string SkipVersioning bool + SkipCustomCode bool CancellableGeneration *CancellableGeneration StreamableGeneration *StreamableGeneration @@ -148,7 +149,10 @@ func Generate(ctx context.Context, opts GenerateOptions) (*GenerationAccess, err generate.WithCLIVersion(opts.CLIVersion), generate.WithForceGeneration(), generate.WithChangelogReleaseNotes(opts.ReleaseNotes), - generate.WithApplyCustomCode(), + } + + if !opts.SkipCustomCode { + generatorOpts = append(generatorOpts, generate.WithApplyCustomCode()) } if opts.Verbose { From 2da7241061566e37e6729203f327bdb28cedb62a Mon Sep 17 00:00:00 2001 From: Ivan Gorshkov Date: Mon, 29 Sep 2025 11:49:59 +0100 Subject: [PATCH 05/42] removed theirs --- internal/registercustomcode/registercustomcode.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/registercustomcode/registercustomcode.go b/internal/registercustomcode/registercustomcode.go index 93a6ba112..4f4a63c23 100644 --- a/internal/registercustomcode/registercustomcode.go +++ b/internal/registercustomcode/registercustomcode.go @@ -439,7 +439,7 @@ func applyNewPatch(customCodeDiff string) error { defer os.Remove(patchFile) // Apply the patch with 3-way merge - cmd := exec.Command("git", "apply", "-3", "--theirs", patchFile) + cmd := exec.Command("git", "apply", "-3", patchFile) if output, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("failed to apply new patch: %w\nOutput: %s", err, string(output)) } @@ -533,4 +533,4 @@ func emitFullPatch(ctx context.Context, fullPatch string) error { fmt.Println("") return nil -} \ No newline at end of file +} From 59f73962f0fac808f66b3bdcef35a939088face8 Mon Sep 17 00:00:00 2001 From: Ivan Gorshkov Date: Mon, 29 Sep 2025 13:21:10 +0100 Subject: [PATCH 06/42] implemented new workflow --- .../registercustomcode/registercustomcode.go | 36 +++++++++++++------ 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/internal/registercustomcode/registercustomcode.go b/internal/registercustomcode/registercustomcode.go index 4f4a63c23..b85fcdf91 100644 --- a/internal/registercustomcode/registercustomcode.go +++ b/internal/registercustomcode/registercustomcode.go @@ -51,22 +51,27 @@ func RegisterCustomCode(ctx context.Context, outDir, target, schemaPath string) return fmt.Errorf("failed to generate clean SDK: %w", err) } - // Step 6: Apply existing custom code patch from gen.lock + // Step 6: Commit clean generation to preserve metadata + if err := commitCleanGeneration(); err != nil { + return fmt.Errorf("failed to commit clean generation: %w", err) + } + + // Step 7: Apply existing custom code patch from gen.lock if err := applyCustomCodePatch(outDir); err != nil { return fmt.Errorf("failed to apply existing patch: %w", err) } - // Step 7: Stage all changes after applying existing patch + // Step 8: Stage all changes after applying existing patch if err := stageAllChanges(); err != nil { return fmt.Errorf("failed to stage changes after applying existing patch: %w", err) } - // Step 8: Pause for user inspection + // Step 9: Pause for user inspection if err := pauseForUserInspection(ctx); err != nil { return fmt.Errorf("user inspection interrupted: %w", err) } - // Step 9: Apply the new custom code diff + // Step 10: Apply the new custom code diff if customCodeDiff != "" { // Emit the new patch before applying it if err := emitNewPatch(ctx, customCodeDiff); err != nil { @@ -79,17 +84,12 @@ func RegisterCustomCode(ctx context.Context, outDir, target, schemaPath string) } } - // Step 10: Capture the full combined diff (existing patch + new changes) + // Step 11: Capture the full combined diff (existing patch + new changes) fullCustomCodeDiff, err := captureCustomCodeDiff() if err != nil { return fmt.Errorf("failed to capture full custom code diff: %w", err) } - // Step 11: Reset to clean state and regenerate clean SDK - if err := resetToCleanState(ctx); err != nil { - return fmt.Errorf("failed to reset to clean state: %w", err) - } - // TODO: compile and lint // Step 12: Update gen.lock with full combined patch @@ -357,6 +357,22 @@ func unstageAllChanges() error { return nil } +func commitCleanGeneration() error { + // Add all changes + addCmd := exec.Command("git", "add", ".") + if output, err := addCmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to add changes for clean generation commit: %w\nOutput: %s", err, string(output)) + } + + // Commit the clean generation + commitCmd := exec.Command("git", "commit", "-m", "clean generation") + if output, err := commitCmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to commit clean generation: %w\nOutput: %s", err, string(output)) + } + + return nil +} + func resetToCleanState(ctx context.Context) error { logger := log.From(ctx) logger.Info("Resetting to clean state") From cf13c9e3f675042fc6ccc96682a44491753fd1c7 Mon Sep 17 00:00:00 2001 From: Ian Bentley Date: Mon, 29 Sep 2025 11:10:30 -0400 Subject: [PATCH 07/42] update registercustomcode to run generation in a manner more consistent with `speakeasy run`. --- cmd/registercustomcode.go | 164 +++++++++--------- .../registercustomcode/registercustomcode.go | 42 ++--- 2 files changed, 107 insertions(+), 99 deletions(-) diff --git a/cmd/registercustomcode.go b/cmd/registercustomcode.go index b050fe330..baf3aab84 100644 --- a/cmd/registercustomcode.go +++ b/cmd/registercustomcode.go @@ -2,19 +2,27 @@ package cmd import ( "context" - "fmt" + "github.com/speakeasy-api/speakeasy/internal/charm/styles" "github.com/speakeasy-api/speakeasy/internal/model" "github.com/speakeasy-api/speakeasy/internal/model/flag" "github.com/speakeasy-api/speakeasy/internal/registercustomcode" - "github.com/speakeasy-api/speakeasy/internal/utils" + "github.com/speakeasy-api/speakeasy/internal/log" + "github.com/speakeasy-api/speakeasy/internal/run" ) type RegisterCustomCodeFlags struct { Target string `json:"target"` - OutDir string `json:"out-dir"` - List bool `json:"list"` - Resolve bool `json:"resolve"` + Show bool `json:"show"` + InstallationURL string `json:"installationURL"` + InstallationURLs map[string]string `json:"installationURLs"` + Repo string `json:"repo"` + RepoSubdir string `json:"repo-subdir"` + RepoSubdirs map[string]string `json:"repo-subdirs"` + SkipVersioning bool `json:"skip-versioning"` + Output string `json:"output"` + SetVersion string `json:"set-version"` + } var registerCustomCodeCmd = &model.ExecutableCommand[RegisterCustomCodeFlags]{ @@ -24,95 +32,93 @@ var registerCustomCodeCmd = &model.ExecutableCommand[RegisterCustomCodeFlags]{ Run: registerCustomCode, Flags: []flag.Flag{ flag.StringFlag{ - Name: "target", - Shorthand: "t", - Description: "target to run. specify 'all' to run all targets", + Name: "target", + Shorthand: "t", + Description: "target - DONOTSPECIFY", + }, + flag.BooleanFlag{ + Name: "show", + Shorthand: "s", + Description: "show custom code patches", }, flag.StringFlag{ - Name: "out-dir", - Shorthand: "o", - Description: "output directory for the registercustomcode command", + Name: "installationURL", + Shorthand: "i", + Description: "the language specific installation URL for installation instructions if the SDK is not published to a package manager", }, - flag.BooleanFlag{ - Name: "list", - Shorthand: "l", - Description: "list custom code patches", + flag.MapFlag{ + Name: "installationURLs", + Description: "a map from target ID to installation URL for installation instructions if the SDK is not published to a package manager", }, - flag.BooleanFlag{ - Name: "resolve", + flag.StringFlag{ + Name: "repo", Shorthand: "r", - Description: "resolve custom code conflicts", + Description: "the repository URL for the SDK, if the published (-p) flag isn't used this will be used to generate installation instructions", + }, + flag.BooleanFlag{ + Name: "skip-versioning", + Description: "skip automatic SDK version increments", + DefaultValue: false, + }, + + flag.StringFlag{ + Name: "set-version", + Description: "the manual version to apply to the generated SDK", + }, + flag.EnumFlag{ + Name: "output", + Shorthand: "o", + Description: "What to output while running", + AllowedValues: []string{"summary", "mermaid", "console"}, + DefaultValue: "summary", }, }, } func registerCustomCode(ctx context.Context, flags RegisterCustomCodeFlags) error { - outDir := flags.OutDir - if outDir == "" { - outDir = "." - } - // Load workflow to get target and schemaPath - wf, _, err := utils.GetWorkflowAndDir() - if err != nil { - return fmt.Errorf("failed to load workflow: %w", err) + opts := []run.Opt{ + run.WithTarget("all"), + run.WithRepo(flags.Repo), + run.WithRepoSubDirs(flags.RepoSubdirs), + run.WithInstallationURLs(flags.InstallationURLs), + run.WithSkipVersioning(flags.SkipVersioning), + run.WithSetVersion(flags.SetVersion), } - - // Get the target from the command flag or use the single available target - var target string - var schemaPath string + workflow, err := run.NewWorkflow( + ctx, + opts..., + ) - if flags.Target != "" { - target = flags.Target - } else if len(wf.Targets) == 1 { - // If no target specified but there's exactly one target, use it - for tid := range wf.Targets { - target = tid - break - } - } - - if target == "" { - return fmt.Errorf("no target specified and no targets found in workflow") - } - - // Get the target configuration - targetConfig, exists := wf.Targets[target] - if !exists { - return fmt.Errorf("target '%s' not found in workflow", target) - } - - // Get the schema path from the target's source - source, sourcePath, err := wf.GetTargetSource(target) - if err != nil { - return fmt.Errorf("failed to get target source: %w", err) + // If --show flag is provided, show existing customcode + if flags.Show { + return registercustomcode.ShowCustomCodePatch() } - if source != nil { - // Source is defined in workflow, use the source inputs - for _, input := range source.Inputs { - if input.Location != "" { - schemaPath = string(input.Location) - break - } + // Call the registercustomcode functionality + return registercustomcode.RegisterCustomCode(ctx, workflow, func() error { + switch flags.Output { + case "summary": + err = workflow.RunWithVisualization(ctx) + if err != nil { + return err + } + case "mermaid": + err = workflow.Run(ctx) + workflow.RootStep.Finalize(err == nil) + mermaid, err := workflow.RootStep.ToMermaidDiagram() + if err != nil { + return err + } + log.From(ctx).Println("\n" + styles.MakeSection("Mermaid diagram of workflow", mermaid, styles.Colors.Blue)) + case "console": + err = workflow.Run(ctx) + // workflow.RootStep.Finalize(err == nil) + if err != nil { + return err + } } - } else if sourcePath != "" { - // Direct source path specified - schemaPath = sourcePath - } else { - // Use the target source as the schema path - schemaPath = targetConfig.Source - } - - if schemaPath == "" { - return fmt.Errorf("could not determine schema path for target '%s'", target) - } + return nil + }) - // If --list flag is provided, call ListCustomCodePatch - if flags.List { - return registercustomcode.ListCustomCodePatch(outDir) - } - - // Call the registercustomcode functionality - return registercustomcode.RegisterCustomCode(ctx, outDir, targetConfig.Target, schemaPath) } \ No newline at end of file diff --git a/internal/registercustomcode/registercustomcode.go b/internal/registercustomcode/registercustomcode.go index b85fcdf91..e5b2a202a 100644 --- a/internal/registercustomcode/registercustomcode.go +++ b/internal/registercustomcode/registercustomcode.go @@ -9,14 +9,18 @@ import ( "strings" config "github.com/speakeasy-api/sdk-gen-config" + "github.com/speakeasy-api/speakeasy/internal/utils" + "github.com/speakeasy-api/speakeasy/internal/env" "github.com/speakeasy-api/speakeasy/internal/log" - "github.com/speakeasy-api/speakeasy/internal/sdkgen" + "github.com/speakeasy-api/speakeasy/internal/run" "go.uber.org/zap" "gopkg.in/yaml.v3" ) // RegisterCustomCode registers custom code changes by capturing them as patches in gen.lock -func RegisterCustomCode(ctx context.Context, outDir, target, schemaPath string) error { +func RegisterCustomCode(ctx context.Context, workflow *run.Workflow, runGenerate func() error) error { + _, outDir, err := utils.GetWorkflowAndDir() + logger := log.From(ctx).With(zap.String("method", "RegisterCustomCode")) // Step 1: Verify main is up to date with origin/main @@ -47,7 +51,7 @@ func RegisterCustomCode(ctx context.Context, outDir, target, schemaPath string) } // Step 5: Generate clean SDK (without custom code) on main branch - if err := generateCleanSDK(ctx, outDir, target, schemaPath); err != nil { + if err := generateCleanSDK(ctx, workflow, runGenerate); err != nil { return fmt.Errorf("failed to generate clean SDK: %w", err) } @@ -111,13 +115,13 @@ func RegisterCustomCode(ctx context.Context, outDir, target, schemaPath string) return nil } -// ListCustomCodePatch displays the custom code patch stored in the gen.lock file -func ListCustomCodePatch(outDir string) error { - cfg, err := config.Load(outDir) +// ShowCustomCodePatch displays the custom code patch stored in the gen.lock file +func ShowCustomCodePatch() error { + _, outDir, err := utils.GetWorkflowAndDir() if err != nil { - return fmt.Errorf("failed to load config: %w", err) + return err } - + cfg, err := config.Load(outDir) customCodePatch, exists := cfg.LockFile.Management.AdditionalProperties["customCodePatch"] if !exists { fmt.Println("No custom code patch found in gen.lock") @@ -305,20 +309,18 @@ func extractLocalPath(location string) []string { return paths } -func generateCleanSDK(ctx context.Context, outDir, target, schemaPath string) error { +func generateCleanSDK(ctx context.Context, workflow *run.Workflow, runGenerate func() error) error { logger := log.From(ctx) - logger.Info("Generating clean SDK (without custom code)", zap.String("target", target), zap.String("schemaPath", schemaPath), zap.String("outDir", outDir)) - - // Use sdkgen.Generate with SkipCustomCode option - _, err := sdkgen.Generate(ctx, sdkgen.GenerateOptions{ - Language: target, - SchemaPath: schemaPath, - OutDir: outDir, - SkipVersioning: true, - SkipCustomCode: true, // This is the key difference from normal generation - Compile: false, - }) + err := runGenerate() + + defer func() { + // we should leave temp directories for debugging if run fails + if err == nil || env.IsGithubAction() { + workflow.Cleanup() + } + }() + if err != nil { return fmt.Errorf("failed to generate SDK: %w", err) } From e2eb25a347069b9f1d3a482f5a50df62fb3a2b86 Mon Sep 17 00:00:00 2001 From: Ian Bentley Date: Mon, 29 Sep 2025 12:13:45 -0400 Subject: [PATCH 08/42] use object oriented interface to workflow file --- .../registercustomcode/registercustomcode.go | 99 ++++++++----------- 1 file changed, 41 insertions(+), 58 deletions(-) diff --git a/internal/registercustomcode/registercustomcode.go b/internal/registercustomcode/registercustomcode.go index e5b2a202a..868386212 100644 --- a/internal/registercustomcode/registercustomcode.go +++ b/internal/registercustomcode/registercustomcode.go @@ -6,20 +6,21 @@ import ( "os" "os/exec" "path/filepath" + "slices" "strings" config "github.com/speakeasy-api/sdk-gen-config" + "github.com/speakeasy-api/sdk-gen-config/workflow" "github.com/speakeasy-api/speakeasy/internal/utils" "github.com/speakeasy-api/speakeasy/internal/env" "github.com/speakeasy-api/speakeasy/internal/log" "github.com/speakeasy-api/speakeasy/internal/run" "go.uber.org/zap" - "gopkg.in/yaml.v3" ) // RegisterCustomCode registers custom code changes by capturing them as patches in gen.lock func RegisterCustomCode(ctx context.Context, workflow *run.Workflow, runGenerate func() error) error { - _, outDir, err := utils.GetWorkflowAndDir() + wf, outDir, err := utils.GetWorkflowAndDir() logger := log.From(ctx).With(zap.String("method", "RegisterCustomCode")) @@ -34,7 +35,7 @@ func RegisterCustomCode(ctx context.Context, workflow *run.Workflow, runGenerate } // Step 3: Check if workflow.yaml references local openapi spec and validate no spec changes - if err := checkNoLocalSpecChanges(ctx, outDir); err != nil { + if err := checkNoLocalSpecChanges(ctx, wf); err != nil { return fmt.Errorf("openapi spec validation failed: %w", err) } @@ -195,27 +196,10 @@ func checkNoSpeakeasyChanges(ctx context.Context) error { return nil } -func checkNoLocalSpecChanges(ctx context.Context, outDir string) error { +func checkNoLocalSpecChanges(ctx context.Context, workflow *workflow.Workflow) error { logger := log.From(ctx) logger.Info("Checking if workflow.yaml references local OpenAPI specs and validating no spec changes") - // Look for workflow.yaml to determine if there's a local openapi spec - workflowPath := filepath.Join(outDir, ".speakeasy", "workflow.yaml") - if _, err := os.Stat(workflowPath); os.IsNotExist(err) { - logger.Info("No workflow.yaml found, skipping local spec validation") - return nil - } - - // Read workflow.yaml to find local spec references - workflowData, err := os.ReadFile(workflowPath) - if err != nil { - return fmt.Errorf("failed to read workflow.yaml: %w", err) - } - - var workflow map[string]interface{} - if err := yaml.Unmarshal(workflowData, &workflow); err != nil { - return fmt.Errorf("failed to parse workflow.yaml: %w", err) - } // Extract local spec paths from workflow localSpecPaths := extractLocalSpecPaths(workflow) @@ -252,40 +236,28 @@ func checkNoLocalSpecChanges(ctx context.Context, outDir string) error { return nil } -func extractLocalSpecPaths(workflow map[string]interface{}) []string { +func extractLocalSpecPaths(wf *workflow.Workflow) []string { var paths []string - // Handle different workflow.yaml formats - // Format 1: sources -> source_name -> inputs -> location - if sources, ok := workflow["sources"].(map[string]interface{}); ok { - for _, source := range sources { - if sourceMap, ok := source.(map[string]interface{}); ok { - if inputs, ok := sourceMap["inputs"].([]interface{}); ok { - for _, input := range inputs { - if inputMap, ok := input.(map[string]interface{}); ok { - if location, ok := inputMap["location"].(string); ok { - paths = append(paths, extractLocalPath(location)...) - } - } - } - } + // Check sources directly + for _, source := range wf.Sources { + for _, input := range source.Inputs { + if isLocalPath(input.Location) { + resolvedPath := input.Location.Resolve() + paths = append(paths, resolvedPath) } } } - // Format 2: targets -> target_name -> source -> inputs -> location - if targets, ok := workflow["targets"].(map[string]interface{}); ok { - for _, target := range targets { - if targetMap, ok := target.(map[string]interface{}); ok { - if source, ok := targetMap["source"].(map[string]interface{}); ok { - if inputs, ok := source["inputs"].([]interface{}); ok { - for _, input := range inputs { - if inputMap, ok := input.(map[string]interface{}); ok { - if location, ok := inputMap["location"].(string); ok { - paths = append(paths, extractLocalPath(location)...) - } - } - } + // Check sources referenced by targets + for _, target := range wf.Targets { + if source, exists := wf.Sources[target.Source]; exists { + for _, input := range source.Inputs { + if isLocalPath(input.Location) { + resolvedPath := input.Location.Resolve() + // Avoid duplicates + if !slices.Contains(paths, resolvedPath) { + paths = append(paths, resolvedPath) } } } @@ -295,18 +267,29 @@ func extractLocalSpecPaths(workflow map[string]interface{}) []string { return paths } -func extractLocalPath(location string) []string { - var paths []string +func isLocalPath(location workflow.LocationString) bool { + resolvedPath := location.Resolve() - // Check if this is a local file path (not a URL or registry path) - if !strings.HasPrefix(location, "http") && !strings.Contains(location, "registry.") && !strings.HasPrefix(location, "git+") { - // Handle relative paths and absolute paths - if strings.HasPrefix(location, "./") || strings.HasPrefix(location, "../") || strings.HasPrefix(location, "/") || (!strings.Contains(location, "://") && !strings.Contains(location, "@")) { - paths = append(paths, location) - } + // Check if this is a remote URL + if strings.HasPrefix(resolvedPath, "https://") || strings.HasPrefix(resolvedPath, "http://") { + return false } - return paths + // Check if this is a registry reference + if strings.Contains(resolvedPath, "registry.speakeasyapi.dev") { + return false + } + + // Check if this is a git reference + if strings.HasPrefix(resolvedPath, "git+") { + return false + } + + // Local paths (relative or absolute) + return strings.HasPrefix(resolvedPath, "./") || + strings.HasPrefix(resolvedPath, "../") || + strings.HasPrefix(resolvedPath, "/") || + (!strings.Contains(resolvedPath, "://") && !strings.Contains(resolvedPath, "@")) } func generateCleanSDK(ctx context.Context, workflow *run.Workflow, runGenerate func() error) error { From 8d4aa578985fd9a2bdb54861cfc73e237173d438 Mon Sep 17 00:00:00 2001 From: Ian Bentley Date: Mon, 29 Sep 2025 15:25:24 -0400 Subject: [PATCH 09/42] remove pause command, and update generation so applying custom code is by default instead of opt-in. --- .../registercustomcode/registercustomcode.go | 37 +++---------------- internal/sdkgen/sdkgen.go | 4 +- 2 files changed, 7 insertions(+), 34 deletions(-) diff --git a/internal/registercustomcode/registercustomcode.go b/internal/registercustomcode/registercustomcode.go index 868386212..0f9376111 100644 --- a/internal/registercustomcode/registercustomcode.go +++ b/internal/registercustomcode/registercustomcode.go @@ -71,12 +71,7 @@ func RegisterCustomCode(ctx context.Context, workflow *run.Workflow, runGenerate return fmt.Errorf("failed to stage changes after applying existing patch: %w", err) } - // Step 9: Pause for user inspection - if err := pauseForUserInspection(ctx); err != nil { - return fmt.Errorf("user inspection interrupted: %w", err) - } - - // Step 10: Apply the new custom code diff + // Step 9: Apply the new custom code diff if customCodeDiff != "" { // Emit the new patch before applying it if err := emitNewPatch(ctx, customCodeDiff); err != nil { @@ -89,7 +84,7 @@ func RegisterCustomCode(ctx context.Context, workflow *run.Workflow, runGenerate } } - // Step 11: Capture the full combined diff (existing patch + new changes) + // Step 10: Capture the full combined diff (existing patch + new changes) fullCustomCodeDiff, err := captureCustomCodeDiff() if err != nil { return fmt.Errorf("failed to capture full custom code diff: %w", err) @@ -97,17 +92,17 @@ func RegisterCustomCode(ctx context.Context, workflow *run.Workflow, runGenerate // TODO: compile and lint - // Step 12: Update gen.lock with full combined patch + // Step 11: Update gen.lock with full combined patch if err := updateGenLockWithPatch(outDir, fullCustomCodeDiff); err != nil { return fmt.Errorf("failed to update gen.lock: %w", err) } - // Step 13: Commit just gen.lock with new patch + // Step 12: Commit just gen.lock with new patch if err := commitGenLock(); err != nil { return fmt.Errorf("failed to commit gen.lock: %w", err) } - // Step 14: Emit/output the full patch for visibility + // Step 13: Emit/output the full patch for visibility if err := emitFullPatch(ctx, fullCustomCodeDiff); err != nil { logger.Warn("Failed to emit full patch", zap.Error(err)) } @@ -476,28 +471,6 @@ func updateGenLockWithPatch(outDir, patchset string) error { return nil } -// User interaction -func pauseForUserInspection(ctx context.Context) error { - logger := log.From(ctx) - logger.Info("Pausing for user inspection") - - fmt.Println("\n" + strings.Repeat("*", 80)) - fmt.Println("PAUSED: Existing patch has been applied and changes staged.") - fmt.Println("You can now inspect the applied changes before the new patch is applied.") - fmt.Println("Press any key to continue...") - fmt.Println(strings.Repeat("*", 80)) - - // Read a single byte from stdin (user pressing any key) - var input [1]byte - _, err := os.Stdin.Read(input[:]) - if err != nil { - return fmt.Errorf("failed to read user input: %w", err) - } - - fmt.Println("Continuing with new patch application...") - return nil -} - func emitNewPatch(ctx context.Context, newPatch string) error { logger := log.From(ctx) logger.Info("Emitting new custom code patch") diff --git a/internal/sdkgen/sdkgen.go b/internal/sdkgen/sdkgen.go index 2676eadb4..1a9f24a3d 100644 --- a/internal/sdkgen/sdkgen.go +++ b/internal/sdkgen/sdkgen.go @@ -151,8 +151,8 @@ func Generate(ctx context.Context, opts GenerateOptions) (*GenerationAccess, err generate.WithChangelogReleaseNotes(opts.ReleaseNotes), } - if !opts.SkipCustomCode { - generatorOpts = append(generatorOpts, generate.WithApplyCustomCode()) + if opts.SkipCustomCode { + generatorOpts = append(generatorOpts, generate.WithSkipApplyCustomCode()) } if opts.Verbose { From 59d93fd62340a4123aae8ae0c5d0fe403494d79c Mon Sep 17 00:00:00 2001 From: Ian Bentley Date: Mon, 29 Sep 2025 17:19:50 -0400 Subject: [PATCH 10/42] don't fetch new build --- internal/model/command.go | 116 +++++++++++++++++++------------------- 1 file changed, 57 insertions(+), 59 deletions(-) diff --git a/internal/model/command.go b/internal/model/command.go index ecf4d6c07..3aa74a50b 100644 --- a/internal/model/command.go +++ b/internal/model/command.go @@ -3,10 +3,9 @@ package model import ( "context" "encoding/json" - errs "errors" "fmt" "os" - "os/exec" + // "os/exec" "slices" "strings" "time" @@ -18,7 +17,6 @@ import ( "github.com/fatih/structs" "github.com/hashicorp/go-version" - "github.com/sethvargo/go-githubactions" "github.com/speakeasy-api/sdk-gen-config/workflow" "github.com/speakeasy-api/speakeasy-client-sdk-go/v3/pkg/models/shared" "github.com/speakeasy-api/speakeasy-core/events" @@ -320,74 +318,74 @@ func runWithVersionFromWorkflowFile(cmd *cobra.Command) error { } // Get lockfile version before running the command, in case it gets overwritten - lockfileVersion := getSpeakeasyVersionFromLockfile() + // lockfileVersion := getSpeakeasyVersionFromLockfile() // If the workflow succeeds on latest, promote that version to the default shouldPromote := wf.SpeakeasyVersion == "latest" - runErr := runWithVersion(cmd, artifactArch, desiredVersion, shouldPromote) - if runErr != nil { - // If the error has been marked as non-rollbackable, return the cause - if errors.Is(runErr, run.ErrNoRollback) { - return errs.Unwrap(runErr) - } - - // If the command failed to run with the latest version, try to run with the version from the lock file - if wf.SpeakeasyVersion == "latest" { - msg := fmt.Sprintf("Failed to run with Speakeasy version %s: %s\n", desiredVersion, runErr.Error()) - _ = log.SendToLogProxy(ctx, log.LogProxyLevelError, msg, nil) - logger.PrintStyled(styles.DimmedItalic, msg) - if env.IsGithubAction() { - githubactions.AddStepSummary("# Speakeasy Version upgrade failure\n" + msg) - } - - if lockfileVersion != "" && lockfileVersion != desiredVersion { - logger.PrintfStyled(styles.DimmedItalic, "Rerunning with previous successful version") - // force to run local version in dev by giving malformed "latest" version - return runWithVersion(cmd, artifactArch, "latest", false) - } - } - - // If the command failed to run with the pinned version, fail normally - return runErr - } + runWithVersion(cmd, artifactArch, desiredVersion, shouldPromote) + // if runErr != nil { + // // If the error has been marked as non-rollbackable, return the cause + // if errors.Is(runErr, run.ErrNoRollback) { + // return errs.Unwrap(runErr) + // } + + // // If the command failed to run with the latest version, try to run with the version from the lock file + // if wf.SpeakeasyVersion == "latest" { + // msg := fmt.Sprintf("Failed to run with Speakeasy version %s: %s\n", desiredVersion, runErr.Error()) + // _ = log.SendToLogProxy(ctx, log.LogProxyLevelError, msg, nil) + // logger.PrintStyled(styles.DimmedItalic, msg) + // if env.IsGithubAction() { + // githubactions.AddStepSummary("# Speakeasy Version upgrade failure\n" + msg) + // } + + // if lockfileVersion != "" && lockfileVersion != desiredVersion { + // logger.PrintfStyled(styles.DimmedItalic, "Rerunning with previous successful version") + // // force to run local version in dev by giving malformed "latest" version + // return runWithVersion(cmd, artifactArch, "latest", false) + // } + // } + + // // If the command failed to run with the pinned version, fail normally + // return runErr + // } return nil } // If promote is true, the version will be promoted to the default version (ie when running `speakeasy`) func runWithVersion(cmd *cobra.Command, artifactArch, desiredVersion string, shouldPromote bool) error { - vLocation, err := updates.InstallVersion(cmd.Context(), desiredVersion, artifactArch, 30) - if err != nil { - return ErrInstallFailed.Wrap(err) - } - - cmdParts := utils.GetCommandParts(cmd) - if cmdParts[0] == "speakeasy" { - cmdParts = cmdParts[1:] - } - - // The pinned flag was introduced in 1.256.0 - // For earlier versions, it isn't necessary because we don't try auto-upgrading - if ok, _ := pinningWasReleased(desiredVersion); ok { - cmdParts = append(cmdParts, "--pinned") - } - - newCmd := exec.Command(vLocation, cmdParts...) - newCmd.Stdin = os.Stdin - newCmd.Stdout = os.Stdout - newCmd.Stderr = os.Stderr - - if err = newCmd.Run(); err != nil { - return fmt.Errorf("failed to run with version %s: %w", desiredVersion, err) - } + // vLocation, err := updates.InstallVersion(cmd.Context(), desiredVersion, artifactArch, 30) + // if err != nil { + // return ErrInstallFailed.Wrap(err) + // } + + // cmdParts := utils.GetCommandParts(cmd) + // if cmdParts[0] == "speakeasy" { + // cmdParts = cmdParts[1:] + // } + + // // The pinned flag was introduced in 1.256.0 + // // For earlier versions, it isn't necessary because we don't try auto-upgrading + // if ok, _ := pinningWasReleased(desiredVersion); ok { + // cmdParts = append(cmdParts, "--pinned") + // } + + // newCmd := exec.Command(vLocation, cmdParts...) + // newCmd.Stdin = os.Stdin + // newCmd.Stdout = os.Stdout + // newCmd.Stderr = os.Stderr + + // if err = newCmd.Run(); err != nil { + // return fmt.Errorf("failed to run with version %s: %w", desiredVersion, err) + // } // If the workflow succeeded, make the used version the default - if shouldPromote && !env.IsGithubAction() && !env.IsLocalDev() { - if err := promoteVersion(cmd.Context(), vLocation); err != nil { - return fmt.Errorf("failed to promote version: %w", err) - } - } + // if shouldPromote && !env.IsGithubAction() && !env.IsLocalDev() { + // if err := promoteVersion(cmd.Context(), vLocation); err != nil { + // return fmt.Errorf("failed to promote version: %w", err) + // } + // } return nil } From dae01c830967a1b09ba5dbb68ed81e4eea41a1ba Mon Sep 17 00:00:00 2001 From: Ivan Gorshkov Date: Wed, 1 Oct 2025 11:26:53 +0100 Subject: [PATCH 11/42] added compilation and linting to registercustomcode --- .../registercustomcode/registercustomcode.go | 46 ++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/internal/registercustomcode/registercustomcode.go b/internal/registercustomcode/registercustomcode.go index 0f9376111..1998c38c9 100644 --- a/internal/registercustomcode/registercustomcode.go +++ b/internal/registercustomcode/registercustomcode.go @@ -11,6 +11,7 @@ import ( config "github.com/speakeasy-api/sdk-gen-config" "github.com/speakeasy-api/sdk-gen-config/workflow" + "github.com/speakeasy-api/openapi-generation/v2/pkg/generate" "github.com/speakeasy-api/speakeasy/internal/utils" "github.com/speakeasy-api/speakeasy/internal/env" "github.com/speakeasy-api/speakeasy/internal/log" @@ -90,7 +91,16 @@ func RegisterCustomCode(ctx context.Context, workflow *run.Workflow, runGenerate return fmt.Errorf("failed to capture full custom code diff: %w", err) } - // TODO: compile and lint + // Step 10.5: Compile SDK to verify custom code changes + if workflow != nil && workflow.Target != "" { + logger.Info("Compiling SDK to verify custom code changes...") + if err := compileSDK(ctx, workflow.Target, outDir); err != nil { + return fmt.Errorf("custom code changes failed compilation: %w", err) + } + logger.Info("✓ SDK compiled successfully") + } else { + logger.Warn("Skipping compilation: no target specified") + } // Step 11: Update gen.lock with full combined patch if err := updateGenLockWithPatch(outDir, fullCustomCodeDiff); err != nil { @@ -508,3 +518,37 @@ func emitFullPatch(ctx context.Context, fullPatch string) error { return nil } + +// compileSDK compiles the SDK to verify custom code changes don't break compilation +func compileSDK(ctx context.Context, target, outDir string) error { + // If target is "all", detect the actual language from the SDK config + if target == "all" { + cfg, err := config.Load(outDir) + if err != nil { + return fmt.Errorf("failed to load config to detect language: %w", err) + } + + // Get the first (and usually only) language from the config + for lang := range cfg.Config.Languages { + target = lang + break + } + + if target == "all" { + return fmt.Errorf("could not detect target language from config in %s", outDir) + } + } + + // Create generator instance + g, err := generate.New() + if err != nil { + return fmt.Errorf("failed to create generator: %w", err) + } + + // Call the public Compile method + if err := g.Compile(ctx, target, outDir); err != nil { + return err + } + + return nil +} From a3b2c272592eb3b722a1779d59788334bb130bd9 Mon Sep 17 00:00:00 2001 From: Ian Bentley Date: Wed, 1 Oct 2025 11:24:33 -0400 Subject: [PATCH 12/42] iter --- cmd/registercustomcode.go | 124 -------------- internal/model/command.go | 39 +++-- .../registercustomcode/registercustomcode.go | 154 ++++++++---------- internal/usagegen/usagegen.go | 1 + 4 files changed, 89 insertions(+), 229 deletions(-) delete mode 100644 cmd/registercustomcode.go diff --git a/cmd/registercustomcode.go b/cmd/registercustomcode.go deleted file mode 100644 index baf3aab84..000000000 --- a/cmd/registercustomcode.go +++ /dev/null @@ -1,124 +0,0 @@ -package cmd - -import ( - "context" - - "github.com/speakeasy-api/speakeasy/internal/charm/styles" - "github.com/speakeasy-api/speakeasy/internal/model" - "github.com/speakeasy-api/speakeasy/internal/model/flag" - "github.com/speakeasy-api/speakeasy/internal/registercustomcode" - "github.com/speakeasy-api/speakeasy/internal/log" - "github.com/speakeasy-api/speakeasy/internal/run" -) - -type RegisterCustomCodeFlags struct { - Target string `json:"target"` - Show bool `json:"show"` - InstallationURL string `json:"installationURL"` - InstallationURLs map[string]string `json:"installationURLs"` - Repo string `json:"repo"` - RepoSubdir string `json:"repo-subdir"` - RepoSubdirs map[string]string `json:"repo-subdirs"` - SkipVersioning bool `json:"skip-versioning"` - Output string `json:"output"` - SetVersion string `json:"set-version"` - -} - -var registerCustomCodeCmd = &model.ExecutableCommand[RegisterCustomCodeFlags]{ - Usage: "registercustomcode", - Short: "Register custom code with the OpenAPI generation system.", - Long: `Register custom code with the OpenAPI generation system.`, - Run: registerCustomCode, - Flags: []flag.Flag{ - flag.StringFlag{ - Name: "target", - Shorthand: "t", - Description: "target - DONOTSPECIFY", - }, - flag.BooleanFlag{ - Name: "show", - Shorthand: "s", - Description: "show custom code patches", - }, - flag.StringFlag{ - Name: "installationURL", - Shorthand: "i", - Description: "the language specific installation URL for installation instructions if the SDK is not published to a package manager", - }, - flag.MapFlag{ - Name: "installationURLs", - Description: "a map from target ID to installation URL for installation instructions if the SDK is not published to a package manager", - }, - flag.StringFlag{ - Name: "repo", - Shorthand: "r", - Description: "the repository URL for the SDK, if the published (-p) flag isn't used this will be used to generate installation instructions", - }, - flag.BooleanFlag{ - Name: "skip-versioning", - Description: "skip automatic SDK version increments", - DefaultValue: false, - }, - - flag.StringFlag{ - Name: "set-version", - Description: "the manual version to apply to the generated SDK", - }, - flag.EnumFlag{ - Name: "output", - Shorthand: "o", - Description: "What to output while running", - AllowedValues: []string{"summary", "mermaid", "console"}, - DefaultValue: "summary", - }, - }, -} - -func registerCustomCode(ctx context.Context, flags RegisterCustomCodeFlags) error { - - opts := []run.Opt{ - run.WithTarget("all"), - run.WithRepo(flags.Repo), - run.WithRepoSubDirs(flags.RepoSubdirs), - run.WithInstallationURLs(flags.InstallationURLs), - run.WithSkipVersioning(flags.SkipVersioning), - run.WithSetVersion(flags.SetVersion), - } - workflow, err := run.NewWorkflow( - ctx, - opts..., - ) - - // If --show flag is provided, show existing customcode - if flags.Show { - return registercustomcode.ShowCustomCodePatch() - } - - // Call the registercustomcode functionality - return registercustomcode.RegisterCustomCode(ctx, workflow, func() error { - switch flags.Output { - case "summary": - err = workflow.RunWithVisualization(ctx) - if err != nil { - return err - } - case "mermaid": - err = workflow.Run(ctx) - workflow.RootStep.Finalize(err == nil) - mermaid, err := workflow.RootStep.ToMermaidDiagram() - if err != nil { - return err - } - log.From(ctx).Println("\n" + styles.MakeSection("Mermaid diagram of workflow", mermaid, styles.Colors.Blue)) - case "console": - err = workflow.Run(ctx) - // workflow.RootStep.Finalize(err == nil) - if err != nil { - return err - } - } - return nil - }) - -} \ No newline at end of file diff --git a/internal/model/command.go b/internal/model/command.go index 3aa74a50b..70466581c 100644 --- a/internal/model/command.go +++ b/internal/model/command.go @@ -5,7 +5,7 @@ import ( "encoding/json" "fmt" "os" - // "os/exec" + "os/exec" "slices" "strings" "time" @@ -359,26 +359,29 @@ func runWithVersion(cmd *cobra.Command, artifactArch, desiredVersion string, sho // if err != nil { // return ErrInstallFailed.Wrap(err) // } + vLocation := "/home/runner/work/branchgen-pr-test/branchgen-pr-test/bin/speakeasy" + ctx := cmd.Context() + logger := log.From(ctx) + logger.PrintfStyled(styles.DimmedItalic, "new code") + cmdParts := utils.GetCommandParts(cmd) + if cmdParts[0] == "speakeasy" { + cmdParts = cmdParts[1:] + } - // cmdParts := utils.GetCommandParts(cmd) - // if cmdParts[0] == "speakeasy" { - // cmdParts = cmdParts[1:] - // } - - // // The pinned flag was introduced in 1.256.0 - // // For earlier versions, it isn't necessary because we don't try auto-upgrading - // if ok, _ := pinningWasReleased(desiredVersion); ok { - // cmdParts = append(cmdParts, "--pinned") - // } + // The pinned flag was introduced in 1.256.0 + // For earlier versions, it isn't necessary because we don't try auto-upgrading + if ok, _ := pinningWasReleased(desiredVersion); ok { + cmdParts = append(cmdParts, "--pinned") + } - // newCmd := exec.Command(vLocation, cmdParts...) - // newCmd.Stdin = os.Stdin - // newCmd.Stdout = os.Stdout - // newCmd.Stderr = os.Stderr + newCmd := exec.Command(vLocation, cmdParts...) + newCmd.Stdin = os.Stdin + newCmd.Stdout = os.Stdout + newCmd.Stderr = os.Stderr - // if err = newCmd.Run(); err != nil { - // return fmt.Errorf("failed to run with version %s: %w", desiredVersion, err) - // } + if err := newCmd.Run(); err != nil { + return fmt.Errorf("failed to run with version %s: %w", desiredVersion, err) + } // If the workflow succeeded, make the used version the default // if shouldPromote && !env.IsGithubAction() && !env.IsLocalDev() { diff --git a/internal/registercustomcode/registercustomcode.go b/internal/registercustomcode/registercustomcode.go index 1998c38c9..4415c6ff0 100644 --- a/internal/registercustomcode/registercustomcode.go +++ b/internal/registercustomcode/registercustomcode.go @@ -74,11 +74,6 @@ func RegisterCustomCode(ctx context.Context, workflow *run.Workflow, runGenerate // Step 9: Apply the new custom code diff if customCodeDiff != "" { - // Emit the new patch before applying it - if err := emitNewPatch(ctx, customCodeDiff); err != nil { - logger.Warn("Failed to emit new patch", zap.Error(err)) - } - if err := applyNewPatch(customCodeDiff); err != nil { logger.Warn("Conflicts detected when applying new patch") return fmt.Errorf("conflicts detected when applying new patch: %w", err) @@ -94,7 +89,7 @@ func RegisterCustomCode(ctx context.Context, workflow *run.Workflow, runGenerate // Step 10.5: Compile SDK to verify custom code changes if workflow != nil && workflow.Target != "" { logger.Info("Compiling SDK to verify custom code changes...") - if err := compileSDK(ctx, workflow.Target, outDir); err != nil { + if err := compileAndLintSDK(ctx, workflow.Target, outDir); err != nil { return fmt.Errorf("custom code changes failed compilation: %w", err) } logger.Info("✓ SDK compiled successfully") @@ -112,10 +107,6 @@ func RegisterCustomCode(ctx context.Context, workflow *run.Workflow, runGenerate return fmt.Errorf("failed to commit gen.lock: %w", err) } - // Step 13: Emit/output the full patch for visibility - if err := emitFullPatch(ctx, fullCustomCodeDiff); err != nil { - logger.Warn("Failed to emit full patch", zap.Error(err)) - } logger.Info("Successfully registered custom code changes") return nil @@ -153,6 +144,12 @@ func verifyMainUpToDate(ctx context.Context) error { logger.Info("Verifying main branch is up to date with origin/main") // Fetch origin/main + /** GO GIT + err = repo.Fetch(&git.FetchOptions{ + // Optional: configure authentication if needed + // Auth: &http.BasicAuth{Username: "user", Password: "password"}, + }) + */ cmd := exec.Command("git", "fetch", "origin", "main") if output, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("failed to fetch origin/main: %w\nOutput: %s", err, string(output)) @@ -160,6 +157,9 @@ func verifyMainUpToDate(ctx context.Context) error { // Check if main is up to date with origin/main cmd = exec.Command("git", "rev-list", "--count", "main..origin/main") + /** + No go-git support + */ output, err := cmd.Output() if err != nil { return fmt.Errorf("failed to check main status: %w", err) @@ -178,6 +178,33 @@ func checkNoSpeakeasyChanges(ctx context.Context) error { logger := log.From(ctx) logger.Info("Checking that changeset doesn't include .speakeasy directory changes") + /** + * Can be done with GO GIT, but it's not so obvious + head, err := repo.Head() + if err != nil { + // Handle error + } + commit, err := repo.CommitObject(head.Hash()) + if err != nil { + // Handle error + } + tree, err := commit.Tree() + if err != nil { + // Handle error + } + patch, err := tree1.Diff(tree2) + if err != nil { + // Handle error + } + + var buf bytes.Buffer + encoder := diff.NewUnifiedEncoder(&buf) + err = encoder.Encode(patch) + if err != nil { + // Handle error + } + fmt.Println(buf.String()) // Prints the unified diff + */ cmd := exec.Command("git", "diff", "--name-only", "main") output, err := cmd.Output() if err != nil { @@ -363,22 +390,18 @@ func commitCleanGeneration() error { return nil } -func resetToCleanState(ctx context.Context) error { - logger := log.From(ctx) - logger.Info("Resetting to clean state") - - // Reset all changes to get back to a clean state - cmd := exec.Command("git", "reset", "--hard", "HEAD") - if output, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("failed to reset to clean state: %w\nOutput: %s", err, string(output)) - } - - logger.Info("Successfully reset to clean state") - return nil -} func commitGenLock() error { // Add only the gen.lock file + /** GO GIT + w, err := repo.Worktree() + _, err = w.Add(".speakeasy/gen.lock") + w.Commit("Register custom code changes", &git.CommitOptions{ + Author: &object.Signature{ + Name: "speakeasybot", + Email: "..." + }}}) + */ cmd := exec.Command("git", "add", ".speakeasy/gen.lock") if err := cmd.Run(); err != nil { return fmt.Errorf("failed to add gen.lock: %w", err) @@ -402,11 +425,6 @@ func applyCustomCodePatch(outDir string) error { return fmt.Errorf("failed to load config: %w", err) } - // Add and commit changes before applying custom code patch - if err := stageAllChanges(); err != nil { - return fmt.Errorf("failed to add changes: %w", err) - } - // Check if there's a custom code patch in the management section if customCodePatch, exists := cfg.LockFile.Management.AdditionalProperties["customCodePatch"]; exists { if patchStr, ok := customCodePatch.(string); ok && patchStr != "" { @@ -425,10 +443,6 @@ func applyCustomCodePatch(outDir string) error { } } - if err := unstageAllChanges(); err != nil { - return fmt.Errorf("failed to reset changes: %w", err) - } - return nil } @@ -481,47 +495,15 @@ func updateGenLockWithPatch(outDir, patchset string) error { return nil } -func emitNewPatch(ctx context.Context, newPatch string) error { - logger := log.From(ctx) - logger.Info("Emitting new custom code patch") - - if newPatch == "" { - fmt.Println("No new custom code changes to apply.") - return nil - } - - fmt.Println("\n" + strings.Repeat("-", 80)) - fmt.Println("NEW CUSTOM CODE PATCH (about to apply)") - fmt.Println(strings.Repeat("-", 80)) - fmt.Println(newPatch) - fmt.Println(strings.Repeat("-", 80)) - fmt.Println("") - - return nil -} - -func emitFullPatch(ctx context.Context, fullPatch string) error { - logger := log.From(ctx) - logger.Info("Emitting full custom code patch") - - if fullPatch == "" { - fmt.Println("No custom code changes detected.") - return nil +// compileSDK compiles the SDK to verify custom code changes don't break compilation +func compileAndLintSDK(ctx context.Context, target, outDir string) error { + // Create generator instance + g, err := generate.New() + if err != nil { + return fmt.Errorf("failed to create generator: %w", err) } - fmt.Println("\n" + strings.Repeat("=", 80)) - fmt.Println("FULL CUSTOM CODE PATCH") - fmt.Println(strings.Repeat("=", 80)) - fmt.Println(fullPatch) - fmt.Println(strings.Repeat("=", 80)) - fmt.Println("") - - return nil -} - -// compileSDK compiles the SDK to verify custom code changes don't break compilation -func compileSDK(ctx context.Context, target, outDir string) error { - // If target is "all", detect the actual language from the SDK config + // If target is "all", detect each target language from the SDK config if target == "all" { cfg, err := config.Load(outDir) if err != nil { @@ -530,24 +512,22 @@ func compileSDK(ctx context.Context, target, outDir string) error { // Get the first (and usually only) language from the config for lang := range cfg.Config.Languages { - target = lang - break + fmt.Println("Language: " + lang) + // Call the public Compile method + if err := g.Compile(ctx, lang, outDir); err != nil { + return err + } + if err := g.Lint(ctx, lang, outDir); err != nil { + return err + } } - - if target == "all" { - return fmt.Errorf("could not detect target language from config in %s", outDir) + } else { + if err := g.Compile(ctx, target, outDir); err != nil { + return err + } + if err := g.Lint(ctx, target, outDir); err != nil { + return err } - } - - // Create generator instance - g, err := generate.New() - if err != nil { - return fmt.Errorf("failed to create generator: %w", err) - } - - // Call the public Compile method - if err := g.Compile(ctx, target, outDir); err != nil { - return err } return nil diff --git a/internal/usagegen/usagegen.go b/internal/usagegen/usagegen.go index 10bda79bb..354c553d6 100644 --- a/internal/usagegen/usagegen.go +++ b/internal/usagegen/usagegen.go @@ -60,6 +60,7 @@ func Generate( generate.WithFileSystem(&fileSystem{buf: tmpOutput}), generate.WithRunLocation("cli"), generate.WithGenVersion(strings.TrimPrefix(changelog.GetLatestVersion(), "v")), + generate.WithSkipApplyCustomCode(), generate.WithForceGeneration(), } From da302010bcefcdfdcf27eebaa05de0a42f185c3d Mon Sep 17 00:00:00 2001 From: Ian Bentley Date: Wed, 1 Oct 2025 11:57:53 -0400 Subject: [PATCH 13/42] improved output --- cmd/root.go | 3 +- .../registercustomcode/registercustomcode.go | 42 +++++++++---------- 2 files changed, 22 insertions(+), 23 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 8001a949a..a0ace9382 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -96,9 +96,9 @@ func Init(version, artifactArch string) { addCommand(rootCmd, AskCmd) addCommand(rootCmd, reproCmd) addCommand(rootCmd, orphanedFilesCmd) + addCommand(rootCmd, registerCustomCodeCmd) pullInit() // addCommand(rootCmd, pullCmd) - addCommand(rootCmd, registerCustomCodeCmd) } func addCommand(cmd *cobra.Command, command model.Command) { @@ -113,6 +113,7 @@ func addCommand(cmd *cobra.Command, command model.Command) { func CmdForTest(version, artifactArch string) *cobra.Command { setupRootCmd(version, artifactArch) + return rootCmd } diff --git a/internal/registercustomcode/registercustomcode.go b/internal/registercustomcode/registercustomcode.go index 4415c6ff0..57d7add55 100644 --- a/internal/registercustomcode/registercustomcode.go +++ b/internal/registercustomcode/registercustomcode.go @@ -27,17 +27,17 @@ func RegisterCustomCode(ctx context.Context, workflow *run.Workflow, runGenerate // Step 1: Verify main is up to date with origin/main if err := verifyMainUpToDate(ctx); err != nil { - return fmt.Errorf("main branch verification failed: %w", err) + return fmt.Errorf("In order to register your custom code, your local branch must be up to date with origin/main: %w", err) } // Step 2: Check changeset doesn't include .speakeasy directory changes if err := checkNoSpeakeasyChanges(ctx); err != nil { - return fmt.Errorf("changeset validation failed: %w", err) + return fmt.Errorf("Registering custom code in the .speakeasy directory is not supported: %w", err) } // Step 3: Check if workflow.yaml references local openapi spec and validate no spec changes if err := checkNoLocalSpecChanges(ctx, wf); err != nil { - return fmt.Errorf("openapi spec validation failed: %w", err) + return fmt.Errorf("Registering custom code in your openapi spec and related files is not supported: %w", err) } // Step 4: Capture patchset with git diff for custom code changes @@ -48,8 +48,7 @@ func RegisterCustomCode(ctx context.Context, workflow *run.Workflow, runGenerate // If no custom code changes detected, return early if customCodeDiff == "" { - logger.Info("No custom code changes detected, nothing to register") - return nil + return fmt.Errorf("No custom code changes detected, nothing to register") } // Step 5: Generate clean SDK (without custom code) on main branch @@ -75,8 +74,7 @@ func RegisterCustomCode(ctx context.Context, workflow *run.Workflow, runGenerate // Step 9: Apply the new custom code diff if customCodeDiff != "" { if err := applyNewPatch(customCodeDiff); err != nil { - logger.Warn("Conflicts detected when applying new patch") - return fmt.Errorf("conflicts detected when applying new patch: %w", err) + return fmt.Errorf("conflicts detected when applying new patch. Please resolve any conflicts, and run `customcode` again: %w", err) } } @@ -87,14 +85,13 @@ func RegisterCustomCode(ctx context.Context, workflow *run.Workflow, runGenerate } // Step 10.5: Compile SDK to verify custom code changes + target := "all" if workflow != nil && workflow.Target != "" { - logger.Info("Compiling SDK to verify custom code changes...") - if err := compileAndLintSDK(ctx, workflow.Target, outDir); err != nil { - return fmt.Errorf("custom code changes failed compilation: %w", err) - } - logger.Info("✓ SDK compiled successfully") - } else { - logger.Warn("Skipping compilation: no target specified") + target = workflow.Target + } + logger.Info("Compiling SDK to verify custom code changes...") + if err := compileAndLintSDK(ctx, target, outDir); err != nil { + return fmt.Errorf("custom code changes failed compilation or linting. Please resolve any compilation/linting errors and run `customcode` again: %w", err) } // Step 11: Update gen.lock with full combined patch @@ -107,13 +104,14 @@ func RegisterCustomCode(ctx context.Context, workflow *run.Workflow, runGenerate return fmt.Errorf("failed to commit gen.lock: %w", err) } - - logger.Info("Successfully registered custom code changes") + logger.Info("Successfully registered custom code changes. Code changes will be applied on top of your code after generation.") return nil } // ShowCustomCodePatch displays the custom code patch stored in the gen.lock file -func ShowCustomCodePatch() error { +func ShowCustomCodePatch(ctx context.Context) error { + logger := log.From(ctx).With(zap.String("method", "RegisterCustomCode")) + _, outDir, err := utils.GetWorkflowAndDir() if err != nil { return err @@ -121,19 +119,19 @@ func ShowCustomCodePatch() error { cfg, err := config.Load(outDir) customCodePatch, exists := cfg.LockFile.Management.AdditionalProperties["customCodePatch"] if !exists { - fmt.Println("No custom code patch found in gen.lock") + logger.Warn("No existing custom code patch found") return nil } patchStr, ok := customCodePatch.(string) if !ok || patchStr == "" { - fmt.Println("No custom code patch found in gen.lock") + logger.Warn("No existing custom code patch found") return nil } - fmt.Println("Found custom code patch:") - fmt.Println("----------------------") - fmt.Printf("%s\n", patchStr) + logger.Info("Found custom code patch:") + logger.Info("----------------------") + logger.Info(fmt.Sprintf("%s\n", patchStr)) return nil } From 484530b91d982518bbb5ac43a89b95d51a3100ac Mon Sep 17 00:00:00 2001 From: Ian Bentley Date: Wed, 1 Oct 2025 14:03:07 -0400 Subject: [PATCH 14/42] back out commit upon fialure --- cmd/customcode.go | 118 ++++++++++++++++++ internal/model/command.go | 3 - .../registercustomcode/registercustomcode.go | 91 +++++++++++++- 3 files changed, 207 insertions(+), 5 deletions(-) create mode 100644 cmd/customcode.go diff --git a/cmd/customcode.go b/cmd/customcode.go new file mode 100644 index 000000000..f241e44a3 --- /dev/null +++ b/cmd/customcode.go @@ -0,0 +1,118 @@ +package cmd + +import ( + "context" + + "github.com/speakeasy-api/speakeasy/internal/charm/styles" + "github.com/speakeasy-api/speakeasy/internal/model" + "github.com/speakeasy-api/speakeasy/internal/model/flag" + "github.com/speakeasy-api/speakeasy/internal/registercustomcode" + "github.com/speakeasy-api/speakeasy/internal/log" + "github.com/speakeasy-api/speakeasy/internal/run" +) + +type RegisterCustomCodeFlags struct { + Target string `json:"target"` + Show bool `json:"show"` + InstallationURL string `json:"installationURL"` + InstallationURLs map[string]string `json:"installationURLs"` + Repo string `json:"repo"` + RepoSubdir string `json:"repo-subdir"` + RepoSubdirs map[string]string `json:"repo-subdirs"` + SkipVersioning bool `json:"skip-versioning"` + Output string `json:"output"` + SetVersion string `json:"set-version"` + +} + +var registerCustomCodeCmd = &model.ExecutableCommand[RegisterCustomCodeFlags]{ + Usage: "registercustomcode", + Short: "Register custom code with the OpenAPI generation system.", + Long: `Register custom code with the OpenAPI generation system.`, + Run: registerCustomCode, + Flags: []flag.Flag{ + flag.StringFlag{ + Name: "target", + Shorthand: "t", + Description: "target - DONOTSPECIFY", + }, + flag.BooleanFlag{ + Name: "show", + Shorthand: "s", + Description: "show custom code patches", + }, + flag.StringFlag{ + Name: "installationURL", + Shorthand: "i", + Description: "the language specific installation URL for installation instructions if the SDK is not published to a package manager", + }, + flag.MapFlag{ + Name: "installationURLs", + Description: "a map from target ID to installation URL for installation instructions if the SDK is not published to a package manager", + }, + flag.StringFlag{ + Name: "repo", + Shorthand: "r", + Description: "the repository URL for the SDK, if the published (-p) flag isn't used this will be used to generate installation instructions", + }, + flag.BooleanFlag{ + Name: "skip-versioning", + Description: "skip automatic SDK version increments", + DefaultValue: false, + }, + flag.EnumFlag{ + Name: "output", + Shorthand: "o", + Description: "What to output while running", + AllowedValues: []string{"summary", "mermaid", "console"}, + DefaultValue: "summary", + }, + }, +} + +func registerCustomCode(ctx context.Context, flags RegisterCustomCodeFlags) error { + + opts := []run.Opt{ + run.WithTarget("all"), + run.WithRepo(flags.Repo), + run.WithRepoSubDirs(flags.RepoSubdirs), + run.WithInstallationURLs(flags.InstallationURLs), + run.WithSkipVersioning(flags.SkipVersioning), + } + workflow, err := run.NewWorkflow( + ctx, + opts..., + ) + + // If --show flag is provided, show existing customcode + if flags.Show { + return registercustomcode.ShowCustomCodePatch(ctx) + } + + // Call the registercustomcode functionality + return registercustomcode.RegisterCustomCode(ctx, workflow, func() error { + switch flags.Output { + case "summary": + err = workflow.RunWithVisualization(ctx) + if err != nil { + return err + } + case "mermaid": + err = workflow.Run(ctx) + workflow.RootStep.Finalize(err == nil) + mermaid, err := workflow.RootStep.ToMermaidDiagram() + if err != nil { + return err + } + log.From(ctx).Println("\n" + styles.MakeSection("Mermaid diagram of workflow", mermaid, styles.Colors.Blue)) + case "console": + err = workflow.Run(ctx) + // workflow.RootStep.Finalize(err == nil) + if err != nil { + return err + } + } + return nil + }) + +} \ No newline at end of file diff --git a/internal/model/command.go b/internal/model/command.go index 70466581c..202ab1e95 100644 --- a/internal/model/command.go +++ b/internal/model/command.go @@ -360,9 +360,6 @@ func runWithVersion(cmd *cobra.Command, artifactArch, desiredVersion string, sho // return ErrInstallFailed.Wrap(err) // } vLocation := "/home/runner/work/branchgen-pr-test/branchgen-pr-test/bin/speakeasy" - ctx := cmd.Context() - logger := log.From(ctx) - logger.PrintfStyled(styles.DimmedItalic, "new code") cmdParts := utils.GetCommandParts(cmd) if cmdParts[0] == "speakeasy" { cmdParts = cmdParts[1:] diff --git a/internal/registercustomcode/registercustomcode.go b/internal/registercustomcode/registercustomcode.go index 57d7add55..4d0f36a8b 100644 --- a/internal/registercustomcode/registercustomcode.go +++ b/internal/registercustomcode/registercustomcode.go @@ -9,6 +9,8 @@ import ( "slices" "strings" + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" config "github.com/speakeasy-api/sdk-gen-config" "github.com/speakeasy-api/sdk-gen-config/workflow" "github.com/speakeasy-api/openapi-generation/v2/pkg/generate" @@ -25,6 +27,13 @@ func RegisterCustomCode(ctx context.Context, workflow *run.Workflow, runGenerate logger := log.From(ctx).With(zap.String("method", "RegisterCustomCode")) + // Record the current git hash at the very beginning for error recovery + originalHash, err := getCurrentGitHash() + if err != nil { + return fmt.Errorf("failed to get current git hash: %w", err) + } + logger.Info("Recorded original git hash for error recovery", zap.String("hash", originalHash.String())) + // Step 1: Verify main is up to date with origin/main if err := verifyMainUpToDate(ctx); err != nil { return fmt.Errorf("In order to register your custom code, your local branch must be up to date with origin/main: %w", err) @@ -74,7 +83,8 @@ func RegisterCustomCode(ctx context.Context, workflow *run.Workflow, runGenerate // Step 9: Apply the new custom code diff if customCodeDiff != "" { if err := applyNewPatch(customCodeDiff); err != nil { - return fmt.Errorf("conflicts detected when applying new patch. Please resolve any conflicts, and run `customcode` again: %w", err) + removeCleanGenerationCommit(ctx, originalHash) + return fmt.Errorf("conflicts detected when applying new patch. Please resolve any conflicts, and run `customcode` again.") } } @@ -91,7 +101,8 @@ func RegisterCustomCode(ctx context.Context, workflow *run.Workflow, runGenerate } logger.Info("Compiling SDK to verify custom code changes...") if err := compileAndLintSDK(ctx, target, outDir); err != nil { - return fmt.Errorf("custom code changes failed compilation or linting. Please resolve any compilation/linting errors and run `customcode` again: %w", err) + removeCleanGenerationCommit(ctx, originalHash) + return fmt.Errorf("custom code changes failed compilation or linting. Please resolve any compilation/linting errors and run `customcode` again.") } // Step 11: Update gen.lock with full combined patch @@ -530,3 +541,79 @@ func compileAndLintSDK(ctx context.Context, target, outDir string) error { return nil } + +// getCurrentGitHash returns the current git commit hash +func getCurrentGitHash() (plumbing.Hash, error) { + repo, err := git.PlainOpenWithOptions(".", &git.PlainOpenOptions{ + DetectDotGit: true, + }) + if err != nil { + return plumbing.Hash{}, fmt.Errorf("failed to open git repository: %w", err) + } + + head, err := repo.Head() + if err != nil { + return plumbing.Hash{}, fmt.Errorf("failed to get HEAD reference: %w", err) + } + + return head.Hash(), nil +} + +// removeCleanGenerationCommit removes the clean generation commit by: +// 1. stash local changes +// 2. reset --hard to the original git hash +// 3. stash pop those local changes +func removeCleanGenerationCommit(ctx context.Context, originalHash plumbing.Hash) error { + logger := log.From(ctx).With(zap.String("method", "removeCleanGenerationCommit")) + logger.Info("Starting error recovery process", zap.String("target_hash", originalHash.String())) + + // Step 1: Stash local changes using git command + logger.Info("Stashing local changes") + stashCmd := exec.Command("git", "stash", "push", "-m", "RegisterCustomCode error recovery stash") + stashOutput, stashErr := stashCmd.CombinedOutput() + stashSuccessful := stashErr == nil && !strings.Contains(string(stashOutput), "No local changes to save") + + if stashErr != nil && !strings.Contains(string(stashOutput), "No local changes to save") { + logger.Warn("Failed to stash changes, continuing with reset", zap.Error(stashErr), zap.String("output", string(stashOutput))) + } else if stashSuccessful { + logger.Info("Successfully stashed changes") + } else { + logger.Info("No changes to stash") + } + + // Step 2: Reset --hard to the original git hash using go-git + logger.Info("Resetting to original git hash", zap.String("hash", originalHash.String())) + repo, err := git.PlainOpenWithOptions(".", &git.PlainOpenOptions{ + DetectDotGit: true, + }) + if err != nil { + return fmt.Errorf("failed to open git repository for recovery: %w", err) + } + + worktree, err := repo.Worktree() + if err != nil { + return fmt.Errorf("failed to get worktree for recovery: %w", err) + } + + err = worktree.Reset(&git.ResetOptions{ + Commit: originalHash, + Mode: git.HardReset, + }) + if err != nil { + return fmt.Errorf("failed to reset to original hash %s: %w", originalHash.String(), err) + } + + // Step 3: Stash pop those local changes (if we successfully stashed) + if stashSuccessful { + logger.Info("Popping stashed changes") + popCmd := exec.Command("git", "stash", "pop") + if popOutput, popErr := popCmd.CombinedOutput(); popErr != nil { + logger.Error("Failed to pop stashed changes, but reset was successful", zap.Error(popErr), zap.String("output", string(popOutput))) + return fmt.Errorf("reset successful but failed to restore stashed changes: %w", popErr) + } + logger.Info("Successfully restored stashed changes") + } + + logger.Info("Error recovery completed successfully") + return nil +} From 9382bcd0fe3398c5aa5525f5cd08b11fa924e0af Mon Sep 17 00:00:00 2001 From: Ian Bentley Date: Wed, 1 Oct 2025 15:24:00 -0400 Subject: [PATCH 15/42] cleanup --- cmd/customcode.go | 16 ++-- internal/model/command.go | 77 ++++++++++--------- .../registercustomcode/registercustomcode.go | 4 +- internal/run/target.go | 2 +- internal/run/workflow.go | 7 ++ 5 files changed, 57 insertions(+), 49 deletions(-) diff --git a/cmd/customcode.go b/cmd/customcode.go index f241e44a3..2a16f7a13 100644 --- a/cmd/customcode.go +++ b/cmd/customcode.go @@ -12,8 +12,8 @@ import ( ) type RegisterCustomCodeFlags struct { - Target string `json:"target"` Show bool `json:"show"` + Resolve bool `json:"resolve"` InstallationURL string `json:"installationURL"` InstallationURLs map[string]string `json:"installationURLs"` Repo string `json:"repo"` @@ -26,21 +26,20 @@ type RegisterCustomCodeFlags struct { } var registerCustomCodeCmd = &model.ExecutableCommand[RegisterCustomCodeFlags]{ - Usage: "registercustomcode", + Usage: "customcode", Short: "Register custom code with the OpenAPI generation system.", Long: `Register custom code with the OpenAPI generation system.`, Run: registerCustomCode, Flags: []flag.Flag{ - flag.StringFlag{ - Name: "target", - Shorthand: "t", - Description: "target - DONOTSPECIFY", - }, flag.BooleanFlag{ Name: "show", Shorthand: "s", Description: "show custom code patches", }, + flag.BooleanFlag{ + Name: "resolve", + Description: "resolve conflicts between custom code patches and local changes", + }, flag.StringFlag{ Name: "installationURL", Shorthand: "i", @@ -78,6 +77,7 @@ func registerCustomCode(ctx context.Context, flags RegisterCustomCodeFlags) erro run.WithRepoSubDirs(flags.RepoSubdirs), run.WithInstallationURLs(flags.InstallationURLs), run.WithSkipVersioning(flags.SkipVersioning), + run.WithSkipApplyCustomCode(), } workflow, err := run.NewWorkflow( ctx, @@ -90,7 +90,7 @@ func registerCustomCode(ctx context.Context, flags RegisterCustomCodeFlags) erro } // Call the registercustomcode functionality - return registercustomcode.RegisterCustomCode(ctx, workflow, func() error { + return registercustomcode.RegisterCustomCode(ctx, workflow, flags.Resolve, func() error { switch flags.Output { case "summary": err = workflow.RunWithVisualization(ctx) diff --git a/internal/model/command.go b/internal/model/command.go index 202ab1e95..e65c32a46 100644 --- a/internal/model/command.go +++ b/internal/model/command.go @@ -3,6 +3,7 @@ package model import ( "context" "encoding/json" + errs "errors" "fmt" "os" "os/exec" @@ -17,6 +18,7 @@ import ( "github.com/fatih/structs" "github.com/hashicorp/go-version" + "github.com/sethvargo/go-githubactions" "github.com/speakeasy-api/sdk-gen-config/workflow" "github.com/speakeasy-api/speakeasy-client-sdk-go/v3/pkg/models/shared" "github.com/speakeasy-api/speakeasy-core/events" @@ -318,48 +320,47 @@ func runWithVersionFromWorkflowFile(cmd *cobra.Command) error { } // Get lockfile version before running the command, in case it gets overwritten - // lockfileVersion := getSpeakeasyVersionFromLockfile() + lockfileVersion := getSpeakeasyVersionFromLockfile() // If the workflow succeeds on latest, promote that version to the default shouldPromote := wf.SpeakeasyVersion == "latest" - runWithVersion(cmd, artifactArch, desiredVersion, shouldPromote) - // if runErr != nil { - // // If the error has been marked as non-rollbackable, return the cause - // if errors.Is(runErr, run.ErrNoRollback) { - // return errs.Unwrap(runErr) - // } - - // // If the command failed to run with the latest version, try to run with the version from the lock file - // if wf.SpeakeasyVersion == "latest" { - // msg := fmt.Sprintf("Failed to run with Speakeasy version %s: %s\n", desiredVersion, runErr.Error()) - // _ = log.SendToLogProxy(ctx, log.LogProxyLevelError, msg, nil) - // logger.PrintStyled(styles.DimmedItalic, msg) - // if env.IsGithubAction() { - // githubactions.AddStepSummary("# Speakeasy Version upgrade failure\n" + msg) - // } - - // if lockfileVersion != "" && lockfileVersion != desiredVersion { - // logger.PrintfStyled(styles.DimmedItalic, "Rerunning with previous successful version") - // // force to run local version in dev by giving malformed "latest" version - // return runWithVersion(cmd, artifactArch, "latest", false) - // } - // } - - // // If the command failed to run with the pinned version, fail normally - // return runErr - // } + runErr := runWithVersion(cmd, artifactArch, desiredVersion, shouldPromote) + if runErr != nil { + // If the error has been marked as non-rollbackable, return the cause + if errors.Is(runErr, run.ErrNoRollback) { + return errs.Unwrap(runErr) + } + + // If the command failed to run with the latest version, try to run with the version from the lock file + if wf.SpeakeasyVersion == "latest" { + msg := fmt.Sprintf("Failed to run with Speakeasy version %s: %s\n", desiredVersion, runErr.Error()) + _ = log.SendToLogProxy(ctx, log.LogProxyLevelError, msg, nil) + logger.PrintStyled(styles.DimmedItalic, msg) + if env.IsGithubAction() { + githubactions.AddStepSummary("# Speakeasy Version upgrade failure\n" + msg) + } + + if lockfileVersion != "" && lockfileVersion != desiredVersion { + logger.PrintfStyled(styles.DimmedItalic, "Rerunning with previous successful version") + return runWithVersion(cmd, artifactArch, "latest", false) + } + } + + // If the command failed to run with the pinned version, fail normally + return runErr + } return nil } // If promote is true, the version will be promoted to the default version (ie when running `speakeasy`) func runWithVersion(cmd *cobra.Command, artifactArch, desiredVersion string, shouldPromote bool) error { - // vLocation, err := updates.InstallVersion(cmd.Context(), desiredVersion, artifactArch, 30) - // if err != nil { - // return ErrInstallFailed.Wrap(err) - // } - vLocation := "/home/runner/work/branchgen-pr-test/branchgen-pr-test/bin/speakeasy" + vLocation, err := updates.InstallVersion(cmd.Context(), desiredVersion, artifactArch, 30) + if err != nil { + return ErrInstallFailed.Wrap(err) + } + cmdParts := utils.GetCommandParts(cmd) if cmdParts[0] == "speakeasy" { cmdParts = cmdParts[1:] @@ -376,16 +377,16 @@ func runWithVersion(cmd *cobra.Command, artifactArch, desiredVersion string, sho newCmd.Stdout = os.Stdout newCmd.Stderr = os.Stderr - if err := newCmd.Run(); err != nil { + if err = newCmd.Run(); err != nil { return fmt.Errorf("failed to run with version %s: %w", desiredVersion, err) } // If the workflow succeeded, make the used version the default - // if shouldPromote && !env.IsGithubAction() && !env.IsLocalDev() { - // if err := promoteVersion(cmd.Context(), vLocation); err != nil { - // return fmt.Errorf("failed to promote version: %w", err) - // } - // } + if shouldPromote && !env.IsGithubAction() && !env.IsLocalDev() { + if err := promoteVersion(cmd.Context(), vLocation); err != nil { + return fmt.Errorf("failed to promote version: %w", err) + } + } return nil } diff --git a/internal/registercustomcode/registercustomcode.go b/internal/registercustomcode/registercustomcode.go index 4d0f36a8b..109a30056 100644 --- a/internal/registercustomcode/registercustomcode.go +++ b/internal/registercustomcode/registercustomcode.go @@ -22,7 +22,7 @@ import ( ) // RegisterCustomCode registers custom code changes by capturing them as patches in gen.lock -func RegisterCustomCode(ctx context.Context, workflow *run.Workflow, runGenerate func() error) error { +func RegisterCustomCode(ctx context.Context, workflow *run.Workflow, resolve bool, runGenerate func() error) error { wf, outDir, err := utils.GetWorkflowAndDir() logger := log.From(ctx).With(zap.String("method", "RegisterCustomCode")) @@ -56,7 +56,7 @@ func RegisterCustomCode(ctx context.Context, workflow *run.Workflow, runGenerate } // If no custom code changes detected, return early - if customCodeDiff == "" { + if customCodeDiff == "" && resolve == false{ return fmt.Errorf("No custom code changes detected, nothing to register") } diff --git a/internal/run/target.go b/internal/run/target.go index bce65546e..a340089c2 100644 --- a/internal/run/target.go +++ b/internal/run/target.go @@ -222,7 +222,7 @@ func (w *Workflow) runTarget(ctx context.Context, target string) (*SourceResult, Compile: w.ShouldCompile, TargetName: target, SkipVersioning: w.SkipVersioning, - SkipCustomCode: w.FromQuickstart, + SkipCustomCode: w.FromQuickstart || w.SkipApplyCustomCode, CancellableGeneration: w.CancellableGeneration, StreamableGeneration: w.StreamableGeneration, ReleaseNotes: changelogContent, diff --git a/internal/run/workflow.go b/internal/run/workflow.go index 85279cd0f..8c258b623 100644 --- a/internal/run/workflow.go +++ b/internal/run/workflow.go @@ -63,6 +63,7 @@ type Workflow struct { SourceResults map[string]*SourceResult TargetResults map[string]*TargetResult OnSourceResult SourceResultCallback + SkipApplyCustomCode bool Duration time.Duration criticalWarns []string Error error @@ -303,6 +304,12 @@ func WithSourceUpdates(onSourceResult SourceResultCallback) Opt { } } +func WithSkipApplyCustomCode() Opt { + return func(w *Workflow) { + w.SkipApplyCustomCode = true + } +} + func WithCancellableGeneration(cancellable bool) Opt { return func(w *Workflow) { if cancellable { From 0a73cd868cf5ad196e13dca85332a0d34d3b4ed5 Mon Sep 17 00:00:00 2001 From: Ivan Gorshkov Date: Tue, 7 Oct 2025 15:49:14 +0100 Subject: [PATCH 16/42] disabled versioning --- internal/model/command.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/model/command.go b/internal/model/command.go index e65c32a46..f16fa7012 100644 --- a/internal/model/command.go +++ b/internal/model/command.go @@ -343,7 +343,7 @@ func runWithVersionFromWorkflowFile(cmd *cobra.Command) error { if lockfileVersion != "" && lockfileVersion != desiredVersion { logger.PrintfStyled(styles.DimmedItalic, "Rerunning with previous successful version") - return runWithVersion(cmd, artifactArch, "latest", false) + return runWithVersion(cmd, artifactArch, "anyrandomstring", false) } } From a58316001c60854148dacee2e9bac3ab8729468e Mon Sep 17 00:00:00 2001 From: Ian Bentley Date: Fri, 3 Oct 2025 09:24:16 -0400 Subject: [PATCH 17/42] iter --- cmd/customcode.go | 11 ++++ cmd/run.go | 13 +++++ internal/model/command.go | 52 +++++++++---------- .../registercustomcode/registercustomcode.go | 50 +++++++++++++++++- 4 files changed, 99 insertions(+), 27 deletions(-) diff --git a/cmd/customcode.go b/cmd/customcode.go index 2a16f7a13..76f6e0208 100644 --- a/cmd/customcode.go +++ b/cmd/customcode.go @@ -14,6 +14,7 @@ import ( type RegisterCustomCodeFlags struct { Show bool `json:"show"` Resolve bool `json:"resolve"` + ApplyOnly bool `json:"apply-only"` InstallationURL string `json:"installationURL"` InstallationURLs map[string]string `json:"installationURLs"` Repo string `json:"repo"` @@ -40,6 +41,11 @@ var registerCustomCodeCmd = &model.ExecutableCommand[RegisterCustomCodeFlags]{ Name: "resolve", Description: "resolve conflicts between custom code patches and local changes", }, + flag.BooleanFlag{ + Name: "apply-only", + Shorthand: "a", + Description: "only apply existing custom code patches without running generation", + }, flag.StringFlag{ Name: "installationURL", Shorthand: "i", @@ -89,6 +95,11 @@ func registerCustomCode(ctx context.Context, flags RegisterCustomCodeFlags) erro return registercustomcode.ShowCustomCodePatch(ctx) } + // If --apply-only flag is provided, only apply existing patches + if flags.ApplyOnly { + return registercustomcode.ApplyCustomCodePatchReverse(ctx) + } + // Call the registercustomcode functionality return registercustomcode.RegisterCustomCode(ctx, workflow, flags.Resolve, func() error { switch flags.Output { diff --git a/cmd/run.go b/cmd/run.go index 7e7de7bc4..308e49ae8 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -36,6 +36,7 @@ type RunFlags struct { SkipTesting bool `json:"skip-testing"` SkipVersioning bool `json:"skip-versioning"` SkipUploadSpec bool `json:"skip-upload-spec"` + SkipCustomCode bool `json:"skip-custom-code"` FrozenWorkflowLock bool `json:"frozen-workflow-lockfile"` Force bool `json:"force"` Output string `json:"output"` @@ -129,6 +130,10 @@ var runCmd = &model.ExecutableCommand[RunFlags]{ Name: "skip-upload-spec", Description: "skip uploading the spec to the registry", }, + flag.BooleanFlag{ + Name: "skip-custom-code", + Description: "skip applying custom code patches during generation", + }, flag.BooleanFlag{ Name: "frozen-workflow-lockfile", Description: "executes using the stored inputs from the workflow.lock, such that no OAS change occurs", @@ -439,6 +444,10 @@ func runNonInteractive(ctx context.Context, flags RunFlags) error { run.WithSourceLocation(flags.SourceLocation), } + if flags.SkipCustomCode { + opts = append(opts, run.WithSkipApplyCustomCode()) + } + if flags.Minimal { opts = append(opts, minimalOpts...) } @@ -502,6 +511,10 @@ func runInteractive(ctx context.Context, flags RunFlags) error { run.WithSourceLocation(flags.SourceLocation), } + if flags.SkipCustomCode { + opts = append(opts, run.WithSkipApplyCustomCode()) + } + if flags.Minimal { opts = append(opts, minimalOpts...) } diff --git a/internal/model/command.go b/internal/model/command.go index f16fa7012..cd6ddc753 100644 --- a/internal/model/command.go +++ b/internal/model/command.go @@ -3,7 +3,7 @@ package model import ( "context" "encoding/json" - errs "errors" + // errs "errors" "fmt" "os" "os/exec" @@ -18,7 +18,7 @@ import ( "github.com/fatih/structs" "github.com/hashicorp/go-version" - "github.com/sethvargo/go-githubactions" + // "github.com/sethvargo/go-githubactions" "github.com/speakeasy-api/sdk-gen-config/workflow" "github.com/speakeasy-api/speakeasy-client-sdk-go/v3/pkg/models/shared" "github.com/speakeasy-api/speakeasy-core/events" @@ -320,26 +320,26 @@ func runWithVersionFromWorkflowFile(cmd *cobra.Command) error { } // Get lockfile version before running the command, in case it gets overwritten - lockfileVersion := getSpeakeasyVersionFromLockfile() + // lockfileVersion := getSpeakeasyVersionFromLockfile() // If the workflow succeeds on latest, promote that version to the default shouldPromote := wf.SpeakeasyVersion == "latest" - runErr := runWithVersion(cmd, artifactArch, desiredVersion, shouldPromote) - if runErr != nil { - // If the error has been marked as non-rollbackable, return the cause - if errors.Is(runErr, run.ErrNoRollback) { - return errs.Unwrap(runErr) - } - - // If the command failed to run with the latest version, try to run with the version from the lock file - if wf.SpeakeasyVersion == "latest" { - msg := fmt.Sprintf("Failed to run with Speakeasy version %s: %s\n", desiredVersion, runErr.Error()) - _ = log.SendToLogProxy(ctx, log.LogProxyLevelError, msg, nil) - logger.PrintStyled(styles.DimmedItalic, msg) - if env.IsGithubAction() { - githubactions.AddStepSummary("# Speakeasy Version upgrade failure\n" + msg) - } + runWithVersion(cmd, artifactArch, desiredVersion, shouldPromote) + // if runErr != nil { + // // If the error has been marked as non-rollbackable, return the cause + // if errors.Is(runErr, run.ErrNoRollback) { + // return errs.Unwrap(runErr) + // } + + // // If the command failed to run with the latest version, try to run with the version from the lock file + // if wf.SpeakeasyVersion == "latest" { + // msg := fmt.Sprintf("Failed to run with Speakeasy version %s: %s\n", desiredVersion, runErr.Error()) + // _ = log.SendToLogProxy(ctx, log.LogProxyLevelError, msg, nil) + // logger.PrintStyled(styles.DimmedItalic, msg) + // if env.IsGithubAction() { + // githubactions.AddStepSummary("# Speakeasy Version upgrade failure\n" + msg) + // } if lockfileVersion != "" && lockfileVersion != desiredVersion { logger.PrintfStyled(styles.DimmedItalic, "Rerunning with previous successful version") @@ -347,19 +347,19 @@ func runWithVersionFromWorkflowFile(cmd *cobra.Command) error { } } - // If the command failed to run with the pinned version, fail normally - return runErr - } + // // If the command failed to run with the pinned version, fail normally + // return runErr + // } return nil } // If promote is true, the version will be promoted to the default version (ie when running `speakeasy`) func runWithVersion(cmd *cobra.Command, artifactArch, desiredVersion string, shouldPromote bool) error { - vLocation, err := updates.InstallVersion(cmd.Context(), desiredVersion, artifactArch, 30) - if err != nil { - return ErrInstallFailed.Wrap(err) - } + vLocation := "/home/runner/work/branchgen-pr-test/branchgen-pr-test/bin/speakeasy" + // if err != nil { + // return ErrInstallFailed.Wrap(err) + // } cmdParts := utils.GetCommandParts(cmd) if cmdParts[0] == "speakeasy" { @@ -377,7 +377,7 @@ func runWithVersion(cmd *cobra.Command, artifactArch, desiredVersion string, sho newCmd.Stdout = os.Stdout newCmd.Stderr = os.Stderr - if err = newCmd.Run(); err != nil { + if err := newCmd.Run(); err != nil { return fmt.Errorf("failed to run with version %s: %w", desiredVersion, err) } diff --git a/internal/registercustomcode/registercustomcode.go b/internal/registercustomcode/registercustomcode.go index 109a30056..b35750cca 100644 --- a/internal/registercustomcode/registercustomcode.go +++ b/internal/registercustomcode/registercustomcode.go @@ -147,6 +147,24 @@ func ShowCustomCodePatch(ctx context.Context) error { return nil } +// ApplyCustomCodePatchOnly applies only the existing custom code patch without running generation +func ApplyCustomCodePatchOnly(ctx context.Context) error { + logger := log.From(ctx).With(zap.String("method", "ApplyCustomCodePatchOnly")) + + _, outDir, err := utils.GetWorkflowAndDir() + if err != nil { + return err + } + + // Apply existing custom code patch from gen.lock + if err := applyCustomCodePatch(outDir); err != nil { + return fmt.Errorf("failed to apply custom code patch: %w", err) + } + + logger.Info("Successfully applied custom code patch") + return nil +} + // Git validation helpers func verifyMainUpToDate(ctx context.Context) error { logger := log.From(ctx) @@ -427,7 +445,7 @@ func commitGenLock() error { } // Patch management -func applyCustomCodePatch(outDir string) error { +func applyCustomCodePatch(outDir string, ) error { // Load the current configuration and lock file cfg, err := config.Load(outDir) if err != nil { @@ -455,6 +473,36 @@ func applyCustomCodePatch(outDir string) error { return nil } +func ApplyCustomCodePatchReverse(ctx context.Context) error { + fmt.Println("Reverse apply patch") + _, outDir, _ := utils.GetWorkflowAndDir() + // Load the current configuration and lock file + cfg, err := config.Load(outDir) + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + // Check if there's a custom code patch in the management section + if customCodePatch, exists := cfg.LockFile.Management.AdditionalProperties["customCodePatch"]; exists { + if patchStr, ok := customCodePatch.(string); ok && patchStr != "" { + // Create a temporary patch file + patchFile := filepath.Join(outDir, ".speakeasy", "temp_patch.patch") + if err := os.WriteFile(patchFile, []byte(patchStr), 0644); err != nil { + return fmt.Errorf("failed to write patch file: %w", err) + } + defer os.Remove(patchFile) + + // Apply the patch with 3-way merge + cmd := exec.Command("git", "apply", "-R", "--3way", "--index", patchFile) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to apply patch: %w\nOutput: %s", err, string(output)) + } + } + } + + return nil +} + func applyNewPatch(customCodeDiff string) error { if customCodeDiff == "" { return nil From 4b2be1a67d94968a29f4bddb7500d53e2eb89c45 Mon Sep 17 00:00:00 2001 From: Ian Bentley Date: Tue, 7 Oct 2025 10:31:11 -0400 Subject: [PATCH 18/42] remove unused resolve flag --- cmd/customcode.go | 6 +----- internal/registercustomcode/registercustomcode.go | 4 ++-- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/cmd/customcode.go b/cmd/customcode.go index 76f6e0208..fe7525d24 100644 --- a/cmd/customcode.go +++ b/cmd/customcode.go @@ -37,10 +37,6 @@ var registerCustomCodeCmd = &model.ExecutableCommand[RegisterCustomCodeFlags]{ Shorthand: "s", Description: "show custom code patches", }, - flag.BooleanFlag{ - Name: "resolve", - Description: "resolve conflicts between custom code patches and local changes", - }, flag.BooleanFlag{ Name: "apply-only", Shorthand: "a", @@ -101,7 +97,7 @@ func registerCustomCode(ctx context.Context, flags RegisterCustomCodeFlags) erro } // Call the registercustomcode functionality - return registercustomcode.RegisterCustomCode(ctx, workflow, flags.Resolve, func() error { + return registercustomcode.RegisterCustomCode(ctx, workflow, func() error { switch flags.Output { case "summary": err = workflow.RunWithVisualization(ctx) diff --git a/internal/registercustomcode/registercustomcode.go b/internal/registercustomcode/registercustomcode.go index b35750cca..17397ebea 100644 --- a/internal/registercustomcode/registercustomcode.go +++ b/internal/registercustomcode/registercustomcode.go @@ -22,7 +22,7 @@ import ( ) // RegisterCustomCode registers custom code changes by capturing them as patches in gen.lock -func RegisterCustomCode(ctx context.Context, workflow *run.Workflow, resolve bool, runGenerate func() error) error { +func RegisterCustomCode(ctx context.Context, workflow *run.Workflow, runGenerate func() error) error { wf, outDir, err := utils.GetWorkflowAndDir() logger := log.From(ctx).With(zap.String("method", "RegisterCustomCode")) @@ -56,7 +56,7 @@ func RegisterCustomCode(ctx context.Context, workflow *run.Workflow, resolve boo } // If no custom code changes detected, return early - if customCodeDiff == "" && resolve == false{ + if customCodeDiff == ""{ return fmt.Errorf("No custom code changes detected, nothing to register") } From be00abc6c26207e0b3f5ab6bb71d1e32f54750c5 Mon Sep 17 00:00:00 2001 From: Ian Bentley Date: Mon, 6 Oct 2025 10:14:00 -0400 Subject: [PATCH 19/42] skip version --- cmd/customcode.go | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/cmd/customcode.go b/cmd/customcode.go index fe7525d24..4e16aa6fc 100644 --- a/cmd/customcode.go +++ b/cmd/customcode.go @@ -56,11 +56,6 @@ var registerCustomCodeCmd = &model.ExecutableCommand[RegisterCustomCodeFlags]{ Shorthand: "r", Description: "the repository URL for the SDK, if the published (-p) flag isn't used this will be used to generate installation instructions", }, - flag.BooleanFlag{ - Name: "skip-versioning", - Description: "skip automatic SDK version increments", - DefaultValue: false, - }, flag.EnumFlag{ Name: "output", Shorthand: "o", @@ -78,7 +73,7 @@ func registerCustomCode(ctx context.Context, flags RegisterCustomCodeFlags) erro run.WithRepo(flags.Repo), run.WithRepoSubDirs(flags.RepoSubdirs), run.WithInstallationURLs(flags.InstallationURLs), - run.WithSkipVersioning(flags.SkipVersioning), + run.WithSkipVersioning(true), run.WithSkipApplyCustomCode(), } workflow, err := run.NewWorkflow( From cc2217291ee20ff7717f7e62c59d87d7f793c92c Mon Sep 17 00:00:00 2001 From: Ian Bentley Date: Tue, 7 Oct 2025 10:28:12 -0400 Subject: [PATCH 20/42] introduce hash --- cmd/customcode.go | 30 +++- .../registercustomcode/registercustomcode.go | 131 ++++++++++++------ 2 files changed, 112 insertions(+), 49 deletions(-) diff --git a/cmd/customcode.go b/cmd/customcode.go index 4e16aa6fc..7ed452b02 100644 --- a/cmd/customcode.go +++ b/cmd/customcode.go @@ -14,7 +14,9 @@ import ( type RegisterCustomCodeFlags struct { Show bool `json:"show"` Resolve bool `json:"resolve"` - ApplyOnly bool `json:"apply-only"` + Apply bool `json:"apply"` + ApplyReverse bool `json:"apply-reverse"` + LatestHash bool `json:"latest-hash"` InstallationURL string `json:"installationURL"` InstallationURLs map[string]string `json:"installationURLs"` Repo string `json:"repo"` @@ -40,7 +42,15 @@ var registerCustomCodeCmd = &model.ExecutableCommand[RegisterCustomCodeFlags]{ flag.BooleanFlag{ Name: "apply-only", Shorthand: "a", - Description: "only apply existing custom code patches without running generation", + Description: "apply existing custom code patches without running generation", + }, + flag.BooleanFlag{ + Name: "apply-reverse", + Description: "apply existing custom code patches (with -r flag) without running generation", + }, + flag.BooleanFlag{ + Name: "latest-hash", + Description: "show the latest commit hash from gen.lock that contains custom code changes", }, flag.StringFlag{ Name: "installationURL", @@ -86,9 +96,19 @@ func registerCustomCode(ctx context.Context, flags RegisterCustomCodeFlags) erro return registercustomcode.ShowCustomCodePatch(ctx) } - // If --apply-only flag is provided, only apply existing patches - if flags.ApplyOnly { - return registercustomcode.ApplyCustomCodePatchReverse(ctx) + // If --apply flag is provided, only apply existing patches + if flags.Apply { + return registercustomcode.ApplyCustomCodePatch(ctx, false) + } + + // If --apply-reverse flag is provided, only apply existing patches + if flags.ApplyReverse { + return registercustomcode.ApplyCustomCodePatch(ctx, true) + } + + // If --latest-hash flag is provided, show the commit hash from gen.lock + if flags.LatestHash { + return registercustomcode.ShowLatestCommitHash(ctx) } // Call the registercustomcode functionality diff --git a/internal/registercustomcode/registercustomcode.go b/internal/registercustomcode/registercustomcode.go index 17397ebea..554833d2b 100644 --- a/internal/registercustomcode/registercustomcode.go +++ b/internal/registercustomcode/registercustomcode.go @@ -71,7 +71,7 @@ func RegisterCustomCode(ctx context.Context, workflow *run.Workflow, runGenerate } // Step 7: Apply existing custom code patch from gen.lock - if err := applyCustomCodePatch(outDir); err != nil { + if err := ApplyCustomCodePatch(ctx, false); err != nil { return fmt.Errorf("failed to apply existing patch: %w", err) } @@ -105,8 +105,16 @@ func RegisterCustomCode(ctx context.Context, workflow *run.Workflow, runGenerate return fmt.Errorf("custom code changes failed compilation or linting. Please resolve any compilation/linting errors and run `customcode` again.") } - // Step 11: Update gen.lock with full combined patch - if err := updateGenLockWithPatch(outDir, fullCustomCodeDiff); err != nil { + // Step 10.6: Create commit with custom code changes after successful compilation + customCodeCommitHash, err := commitCustomCodeChanges() + if err != nil { + removeCleanGenerationCommit(ctx, originalHash) + return fmt.Errorf("failed to commit custom code changes: %w", err) + } + logger.Info("Created commit with custom code changes", zap.String("commit_hash", customCodeCommitHash)) + + // Step 11: Update gen.lock with full combined patch and commit hash + if err := updateGenLockWithPatch(outDir, fullCustomCodeDiff, customCodeCommitHash); err != nil { return fmt.Errorf("failed to update gen.lock: %w", err) } @@ -121,13 +129,17 @@ func RegisterCustomCode(ctx context.Context, workflow *run.Workflow, runGenerate // ShowCustomCodePatch displays the custom code patch stored in the gen.lock file func ShowCustomCodePatch(ctx context.Context) error { - logger := log.From(ctx).With(zap.String("method", "RegisterCustomCode")) + logger := log.From(ctx).With(zap.String("method", "ShowCustomCodePatch")) _, outDir, err := utils.GetWorkflowAndDir() if err != nil { return err } cfg, err := config.Load(outDir) + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + customCodePatch, exists := cfg.LockFile.Management.AdditionalProperties["customCodePatch"] if !exists { logger.Warn("No existing custom code patch found") @@ -140,6 +152,13 @@ func ShowCustomCodePatch(ctx context.Context) error { return nil } + // Check if there's a commit hash associated with this patch + if customCodeCommitHash, hashExists := cfg.LockFile.Management.AdditionalProperties["customCodeCommitHash"]; hashExists { + if commitHash, ok := customCodeCommitHash.(string); ok && commitHash != "" { + logger.Info("Custom Code Commit Hash:", zap.String("hash", commitHash)) + } + } + logger.Info("Found custom code patch:") logger.Info("----------------------") logger.Info(fmt.Sprintf("%s\n", patchStr)) @@ -147,21 +166,28 @@ func ShowCustomCodePatch(ctx context.Context) error { return nil } -// ApplyCustomCodePatchOnly applies only the existing custom code patch without running generation -func ApplyCustomCodePatchOnly(ctx context.Context) error { - logger := log.From(ctx).With(zap.String("method", "ApplyCustomCodePatchOnly")) +// ShowLatestCommitHash displays the latest commit hash from gen.lock that contains custom code changes +func ShowLatestCommitHash(ctx context.Context) error { + logger := log.From(ctx).With(zap.String("method", "ShowLatestCommitHash")) _, outDir, err := utils.GetWorkflowAndDir() if err != nil { return err } + cfg, err := config.Load(outDir) + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } - // Apply existing custom code patch from gen.lock - if err := applyCustomCodePatch(outDir); err != nil { - return fmt.Errorf("failed to apply custom code patch: %w", err) + // Check if there's a commit hash stored in gen.lock + if customCodeCommitHash, hashExists := cfg.LockFile.Management.AdditionalProperties["customCodeCommitHash"]; hashExists { + if commitHash, ok := customCodeCommitHash.(string); ok && commitHash != "" { + fmt.Println(commitHash) + return nil + } } - logger.Info("Successfully applied custom code patch") + logger.Warn("No custom code commit hash found in gen.lock") return nil } @@ -418,6 +444,38 @@ func commitCleanGeneration() error { } +func commitCustomCodeChanges() (string, error) { + // Add all changes (excluding gen.lock) + addCmd := exec.Command("git", "add", ".") + if output, err := addCmd.CombinedOutput(); err != nil { + return "", fmt.Errorf("failed to add custom code changes: %w\nOutput: %s", err, string(output)) + } + + // Reset gen.lock if it was staged (we don't want it in this commit) + resetCmd := exec.Command("git", "reset", ".speakeasy/gen.lock") + if output, err := resetCmd.CombinedOutput(); err != nil { + // This is non-fatal - gen.lock might not exist or be staged + fmt.Printf("Warning: failed to unstage gen.lock: %v\nOutput: %s\n", err, string(output)) + } + + // Commit the custom code changes + commitMsg := "Apply custom code changes" + commitCmd := exec.Command("git", "commit", "-m", commitMsg) + if output, err := commitCmd.CombinedOutput(); err != nil { + return "", fmt.Errorf("failed to commit custom code changes: %w\nOutput: %s", err, string(output)) + } + + // Get the commit hash + hashCmd := exec.Command("git", "rev-parse", "HEAD") + hashOutput, err := hashCmd.Output() + if err != nil { + return "", fmt.Errorf("failed to get commit hash: %w", err) + } + + commitHash := strings.TrimSpace(string(hashOutput)) + return commitHash, nil +} + func commitGenLock() error { // Add only the gen.lock file /** GO GIT @@ -444,37 +502,8 @@ func commitGenLock() error { return nil } -// Patch management -func applyCustomCodePatch(outDir string, ) error { - // Load the current configuration and lock file - cfg, err := config.Load(outDir) - if err != nil { - return fmt.Errorf("failed to load config: %w", err) - } - // Check if there's a custom code patch in the management section - if customCodePatch, exists := cfg.LockFile.Management.AdditionalProperties["customCodePatch"]; exists { - if patchStr, ok := customCodePatch.(string); ok && patchStr != "" { - // Create a temporary patch file - patchFile := filepath.Join(outDir, ".speakeasy", "temp_patch.patch") - if err := os.WriteFile(patchFile, []byte(patchStr), 0644); err != nil { - return fmt.Errorf("failed to write patch file: %w", err) - } - defer os.Remove(patchFile) - - // Apply the patch with 3-way merge - cmd := exec.Command("git", "apply", "-3", patchFile) - if output, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("failed to apply patch: %w\nOutput: %s", err, string(output)) - } - } - } - - return nil -} - -func ApplyCustomCodePatchReverse(ctx context.Context) error { - fmt.Println("Reverse apply patch") +func ApplyCustomCodePatch(ctx context.Context, reverse bool) error { _, outDir, _ := utils.GetWorkflowAndDir() // Load the current configuration and lock file cfg, err := config.Load(outDir) @@ -487,15 +516,24 @@ func ApplyCustomCodePatchReverse(ctx context.Context) error { if patchStr, ok := customCodePatch.(string); ok && patchStr != "" { // Create a temporary patch file patchFile := filepath.Join(outDir, ".speakeasy", "temp_patch.patch") + fmt.Println("Saving patch to: %v", patchFile) if err := os.WriteFile(patchFile, []byte(patchStr), 0644); err != nil { return fmt.Errorf("failed to write patch file: %w", err) } defer os.Remove(patchFile) // Apply the patch with 3-way merge - cmd := exec.Command("git", "apply", "-R", "--3way", "--index", patchFile) + args := []string{"apply", "--3way", "--index"} + if reverse { + args = append(args, "-R") + } + args = append(args, patchFile) + fmt.Println("running with args: %v", args) + cmd := exec.Command("git", args...) if output, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("failed to apply patch: %w\nOutput: %s", err, string(output)) + } else { + fmt.Println("Not error: ", string(output)) } } } @@ -524,7 +562,7 @@ func applyNewPatch(customCodeDiff string) error { return nil } -func updateGenLockWithPatch(outDir, patchset string) error { +func updateGenLockWithPatch(outDir, patchset, commitHash string) error { // Load the current configuration and lock file cfg, err := config.Load(outDir) if err != nil { @@ -539,9 +577,14 @@ func updateGenLockWithPatch(outDir, patchset string) error { // Store single patch (replaces any existing patch) if patchset != "" { cfg.LockFile.Management.AdditionalProperties["customCodePatch"] = patchset + // Store the commit hash that contains the custom code application + if commitHash != "" { + cfg.LockFile.Management.AdditionalProperties["customCodeCommitHash"] = commitHash + } } else { - // Remove the patch if empty + // Remove the patch and commit hash if empty delete(cfg.LockFile.Management.AdditionalProperties, "customCodePatch") + delete(cfg.LockFile.Management.AdditionalProperties, "customCodeCommitHash") } // Save the updated gen.lock From 36bca50c57842ebac89e5dcef98991487ebe1c86 Mon Sep 17 00:00:00 2001 From: Ian Bentley Date: Wed, 8 Oct 2025 11:42:18 -0400 Subject: [PATCH 21/42] support multi target workflows --- cmd/customcode.go | 59 +++-- .../registercustomcode/registercustomcode.go | 238 ++++++++++-------- 2 files changed, 161 insertions(+), 136 deletions(-) diff --git a/cmd/customcode.go b/cmd/customcode.go index 7ed452b02..482571a5b 100644 --- a/cmd/customcode.go +++ b/cmd/customcode.go @@ -2,19 +2,23 @@ package cmd import ( "context" + "fmt" "github.com/speakeasy-api/speakeasy/internal/charm/styles" + "github.com/speakeasy-api/speakeasy/internal/utils" "github.com/speakeasy-api/speakeasy/internal/model" "github.com/speakeasy-api/speakeasy/internal/model/flag" "github.com/speakeasy-api/speakeasy/internal/registercustomcode" "github.com/speakeasy-api/speakeasy/internal/log" + "github.com/speakeasy-api/speakeasy/internal/env" + "github.com/speakeasy-api/speakeasy/internal/run" ) type RegisterCustomCodeFlags struct { Show bool `json:"show"` Resolve bool `json:"resolve"` - Apply bool `json:"apply"` + Apply bool `json:"apply-only"` ApplyReverse bool `json:"apply-reverse"` LatestHash bool `json:"latest-hash"` InstallationURL string `json:"installationURL"` @@ -44,10 +48,6 @@ var registerCustomCodeCmd = &model.ExecutableCommand[RegisterCustomCodeFlags]{ Shorthand: "a", Description: "apply existing custom code patches without running generation", }, - flag.BooleanFlag{ - Name: "apply-reverse", - Description: "apply existing custom code patches (with -r flag) without running generation", - }, flag.BooleanFlag{ Name: "latest-hash", Description: "show the latest commit hash from gen.lock that contains custom code changes", @@ -78,19 +78,6 @@ var registerCustomCodeCmd = &model.ExecutableCommand[RegisterCustomCodeFlags]{ func registerCustomCode(ctx context.Context, flags RegisterCustomCodeFlags) error { - opts := []run.Opt{ - run.WithTarget("all"), - run.WithRepo(flags.Repo), - run.WithRepoSubDirs(flags.RepoSubdirs), - run.WithInstallationURLs(flags.InstallationURLs), - run.WithSkipVersioning(true), - run.WithSkipApplyCustomCode(), - } - workflow, err := run.NewWorkflow( - ctx, - opts..., - ) - // If --show flag is provided, show existing customcode if flags.Show { return registercustomcode.ShowCustomCodePatch(ctx) @@ -98,12 +85,13 @@ func registerCustomCode(ctx context.Context, flags RegisterCustomCodeFlags) erro // If --apply flag is provided, only apply existing patches if flags.Apply { - return registercustomcode.ApplyCustomCodePatch(ctx, false) - } - - // If --apply-reverse flag is provided, only apply existing patches - if flags.ApplyReverse { - return registercustomcode.ApplyCustomCodePatch(ctx, true) + wf, _, err := utils.GetWorkflowAndDir() + if err != nil { + return fmt.Errorf("Could not find workflow file") + } + for _, target := range wf.Targets { + return registercustomcode.ApplyCustomCodePatch(ctx, target) + } } // If --latest-hash flag is provided, show the commit hash from gen.lock @@ -112,7 +100,26 @@ func registerCustomCode(ctx context.Context, flags RegisterCustomCodeFlags) erro } // Call the registercustomcode functionality - return registercustomcode.RegisterCustomCode(ctx, workflow, func() error { + return registercustomcode.RegisterCustomCode(ctx, func(targetName string) error { + opts := []run.Opt{ + run.WithTarget(targetName), + run.WithRepo(flags.Repo), + run.WithRepoSubDirs(flags.RepoSubdirs), + run.WithInstallationURLs(flags.InstallationURLs), + run.WithSkipVersioning(true), + run.WithSkipApplyCustomCode(), + } + workflow, err := run.NewWorkflow( + ctx, + opts..., + ) + defer func() { + // we should leave temp directories for debugging if run fails + if env.IsGithubAction() { + workflow.Cleanup() + } + }() + switch flags.Output { case "summary": err = workflow.RunWithVisualization(ctx) @@ -135,6 +142,6 @@ func registerCustomCode(ctx context.Context, flags RegisterCustomCodeFlags) erro } } return nil - }) + }, ) } \ No newline at end of file diff --git a/internal/registercustomcode/registercustomcode.go b/internal/registercustomcode/registercustomcode.go index 554833d2b..bc2f090f3 100644 --- a/internal/registercustomcode/registercustomcode.go +++ b/internal/registercustomcode/registercustomcode.go @@ -15,15 +15,35 @@ import ( "github.com/speakeasy-api/sdk-gen-config/workflow" "github.com/speakeasy-api/openapi-generation/v2/pkg/generate" "github.com/speakeasy-api/speakeasy/internal/utils" - "github.com/speakeasy-api/speakeasy/internal/env" "github.com/speakeasy-api/speakeasy/internal/log" - "github.com/speakeasy-api/speakeasy/internal/run" "go.uber.org/zap" ) +// getTargetOutput returns the target output directory, defaulting to "." if nil +func getTargetOutput(target workflow.Target) string { + if target.Output == nil { + return "." + } + return *target.Output +} + +// getOtherTargetOutputs returns all target output directories except the current one +func getOtherTargetOutputs(wf *workflow.Workflow, currentTargetName string) []string { + var otherOutputs []string + for targetName, target := range wf.Targets { + if targetName != currentTargetName { + output := getTargetOutput(target) + if output != "." { // Don't exclude current directory + otherOutputs = append(otherOutputs, output) + } + } + } + return otherOutputs +} + // RegisterCustomCode registers custom code changes by capturing them as patches in gen.lock -func RegisterCustomCode(ctx context.Context, workflow *run.Workflow, runGenerate func() error) error { - wf, outDir, err := utils.GetWorkflowAndDir() +func RegisterCustomCode(ctx context.Context, runGenerate func(string) error) error { + wf, _, err := utils.GetWorkflowAndDir() logger := log.From(ctx).With(zap.String("method", "RegisterCustomCode")) @@ -48,21 +68,24 @@ func RegisterCustomCode(ctx context.Context, workflow *run.Workflow, runGenerate if err := checkNoLocalSpecChanges(ctx, wf); err != nil { return fmt.Errorf("Registering custom code in your openapi spec and related files is not supported: %w", err) } + targetPatches := make(map[string]string) + for targetName, target := range wf.Targets { + // Step 4: Capture patchset with git diff for custom code changes + otherTargetOutputs := getOtherTargetOutputs(wf, targetName) + customCodeDiff, err := captureCustomCodeDiff(getTargetOutput(target), otherTargetOutputs) + if err != nil { + return fmt.Errorf("failed to capture custom code diff: %w", err) + } - // Step 4: Capture patchset with git diff for custom code changes - customCodeDiff, err := captureCustomCodeDiff() - if err != nil { - return fmt.Errorf("failed to capture custom code diff: %w", err) - } - - // If no custom code changes detected, return early - if customCodeDiff == ""{ - return fmt.Errorf("No custom code changes detected, nothing to register") - } - - // Step 5: Generate clean SDK (without custom code) on main branch - if err := generateCleanSDK(ctx, workflow, runGenerate); err != nil { - return fmt.Errorf("failed to generate clean SDK: %w", err) + // If no custom code changes detected, return early + if customCodeDiff == ""{ + fmt.Println(fmt.Sprintf("No custom code changes detected in target %v, nothing to register", targetName)) + } + targetPatches[targetName] = customCodeDiff + // Step 5: Generate clean SDK (without custom code) on main branch + if err := generateCleanSDK(ctx, targetName, runGenerate); err != nil { + return fmt.Errorf("failed to generate clean SDK: %w", err) + } } // Step 6: Commit clean generation to preserve metadata @@ -70,58 +93,63 @@ func RegisterCustomCode(ctx context.Context, workflow *run.Workflow, runGenerate return fmt.Errorf("failed to commit clean generation: %w", err) } - // Step 7: Apply existing custom code patch from gen.lock - if err := ApplyCustomCodePatch(ctx, false); err != nil { - return fmt.Errorf("failed to apply existing patch: %w", err) - } + for targetName, target := range wf.Targets { + fmt.Println("Starting target", targetName) + fmt.Println(fmt.Sprintf("Patch: '%v'", targetPatches[targetName])) + if targetPatches[targetName] == "" { + continue + } + // Step 7: Apply existing custom code patch from gen.lock + if err := ApplyCustomCodePatch(ctx, target); err != nil { + return fmt.Errorf("failed to apply existing patch: %w", err) + } - // Step 8: Stage all changes after applying existing patch - if err := stageAllChanges(); err != nil { - return fmt.Errorf("failed to stage changes after applying existing patch: %w", err) - } + // Step 8: Stage all changes after applying existing patch + if err := stageAllChanges(getTargetOutput(target)); err != nil { + return fmt.Errorf("failed to stage changes after applying existing patch: %w", err) + } - // Step 9: Apply the new custom code diff - if customCodeDiff != "" { - if err := applyNewPatch(customCodeDiff); err != nil { - removeCleanGenerationCommit(ctx, originalHash) - return fmt.Errorf("conflicts detected when applying new patch. Please resolve any conflicts, and run `customcode` again.") + // Step 9: Apply the new custom code diff + if targetPatches[targetName] != "" { + if err := applyNewPatch(targetPatches[targetName]); err != nil { + removeCleanGenerationCommit(ctx, originalHash) + return fmt.Errorf("conflicts detected when applying new patch. Please resolve any conflicts, and run `customcode` again.") + } } - } - // Step 10: Capture the full combined diff (existing patch + new changes) - fullCustomCodeDiff, err := captureCustomCodeDiff() - if err != nil { - return fmt.Errorf("failed to capture full custom code diff: %w", err) - } + // Step 10: Capture the full combined diff (existing patch + new changes) + otherTargetOutputs := getOtherTargetOutputs(wf, targetName) + fullCustomCodeDiff, err := captureCustomCodeDiff(getTargetOutput(target), otherTargetOutputs) + if err != nil { + return fmt.Errorf("failed to capture full custom code diff: %w", err) + } + targetPatches[targetName] = fullCustomCodeDiff + logger.Info("Compiling SDK to verify custom code changes...") + if err := compileAndLintSDK(ctx, target); err != nil { + fmt.Println("err: ", err) + removeCleanGenerationCommit(ctx, originalHash) + return fmt.Errorf("custom code changes failed compilation or linting. Please resolve any compilation/linting errors and run `customcode` again.") + } + // Step 10.6: Create commit with custom code changes after successful compilation + customCodeCommitHash, err := commitCustomCodeChanges() + if err != nil { + removeCleanGenerationCommit(ctx, originalHash) + return fmt.Errorf("failed to commit custom code changes: %w", err) + } + logger.Info("Created commit with custom code changes", zap.String("commit_hash", customCodeCommitHash)) - // Step 10.5: Compile SDK to verify custom code changes - target := "all" - if workflow != nil && workflow.Target != "" { - target = workflow.Target - } - logger.Info("Compiling SDK to verify custom code changes...") - if err := compileAndLintSDK(ctx, target, outDir); err != nil { - removeCleanGenerationCommit(ctx, originalHash) - return fmt.Errorf("custom code changes failed compilation or linting. Please resolve any compilation/linting errors and run `customcode` again.") - } + // Step 11: Update gen.lock with full combined patch and commit hash + if err := updateGenLockWithPatch(getTargetOutput(target), targetPatches[targetName], customCodeCommitHash); err != nil { + return fmt.Errorf("failed to update gen.lock: %w", err) + } - // Step 10.6: Create commit with custom code changes after successful compilation - customCodeCommitHash, err := commitCustomCodeChanges() - if err != nil { - removeCleanGenerationCommit(ctx, originalHash) - return fmt.Errorf("failed to commit custom code changes: %w", err) - } - logger.Info("Created commit with custom code changes", zap.String("commit_hash", customCodeCommitHash)) + // Step 12: Commit just gen.lock with new patch + if err := commitGenLock(); err != nil { + return fmt.Errorf("failed to commit gen.lock: %w", err) + } - // Step 11: Update gen.lock with full combined patch and commit hash - if err := updateGenLockWithPatch(outDir, fullCustomCodeDiff, customCodeCommitHash); err != nil { - return fmt.Errorf("failed to update gen.lock: %w", err) } - // Step 12: Commit just gen.lock with new patch - if err := commitGenLock(); err != nil { - return fmt.Errorf("failed to commit gen.lock: %w", err) - } logger.Info("Successfully registered custom code changes. Code changes will be applied on top of your code after generation.") return nil @@ -377,17 +405,9 @@ func isLocalPath(location workflow.LocationString) bool { (!strings.Contains(resolvedPath, "://") && !strings.Contains(resolvedPath, "@")) } -func generateCleanSDK(ctx context.Context, workflow *run.Workflow, runGenerate func() error) error { +func generateCleanSDK(ctx context.Context, targetName string, runGenerate func(targetName string) error) error { logger := log.From(ctx) - err := runGenerate() - - defer func() { - // we should leave temp directories for debugging if run fails - if err == nil || env.IsGithubAction() { - workflow.Cleanup() - } - }() - + err := runGenerate(targetName) if err != nil { return fmt.Errorf("failed to generate SDK: %w", err) @@ -398,19 +418,38 @@ func generateCleanSDK(ctx context.Context, workflow *run.Workflow, runGenerate f } // Git operations -func captureCustomCodeDiff() (string, error) { - cmd := exec.Command("git", "diff", "HEAD") - output, err := cmd.Output() +func captureCustomCodeDiff(outDir string, excludePaths []string) (string, error) { + args := []string{"diff", "HEAD", outDir} + + // Filter excludePaths to only include children of outDir + cleanOutDir := filepath.Clean(outDir) + for _, excludePath := range excludePaths { + cleanExcludePath := filepath.Clean(excludePath) + + // Check if excludePath is a child of outDir (or equal to outDir) + rel, err := filepath.Rel(cleanOutDir, cleanExcludePath) + if err == nil && !strings.HasPrefix(rel, "..") && rel != "." { + args = append(args, ":^"+excludePath) + } + } + + cmd := exec.Command("git", args...) + fmt.Printf("Running git command: git %s\n", strings.Join(args, " ")) + combinedOutput, err := cmd.CombinedOutput() + if err != nil { return "", fmt.Errorf("failed to capture git diff: %w", err) } - return string(output), nil + return string(combinedOutput), nil } -func stageAllChanges() error { +func stageAllChanges(dir string) error { + if dir == "" { + dir = "." + } // Add all changes - addCmd := exec.Command("git", "add", ".") + addCmd := exec.Command("git", "add", dir) if output, err := addCmd.CombinedOutput(); err != nil { return fmt.Errorf("failed to add changes: %w\nOutput: %s", err, string(output)) } @@ -460,7 +499,7 @@ func commitCustomCodeChanges() (string, error) { // Commit the custom code changes commitMsg := "Apply custom code changes" - commitCmd := exec.Command("git", "commit", "-m", commitMsg) + commitCmd := exec.Command("git", "commit", "--allow-empty", "-m", commitMsg) if output, err := commitCmd.CombinedOutput(); err != nil { return "", fmt.Errorf("failed to commit custom code changes: %w\nOutput: %s", err, string(output)) } @@ -495,16 +534,19 @@ func commitGenLock() error { // Commit with a descriptive message commitMsg := "Register custom code changes" cmd = exec.Command("git", "commit", "-m", commitMsg) - if err := cmd.Run(); err != nil { + if output, err := cmd.CombinedOutput(); err != nil { + fmt.Printf("git commit output: %s\n", string(output)) return fmt.Errorf("failed to commit gen.lock: %w", err) + } else { + fmt.Printf("git commit output: %s\n", string(output)) } return nil } -func ApplyCustomCodePatch(ctx context.Context, reverse bool) error { - _, outDir, _ := utils.GetWorkflowAndDir() +func ApplyCustomCodePatch(ctx context.Context, target workflow.Target) error { + outDir := getTargetOutput(target) // Load the current configuration and lock file cfg, err := config.Load(outDir) if err != nil { @@ -516,7 +558,6 @@ func ApplyCustomCodePatch(ctx context.Context, reverse bool) error { if patchStr, ok := customCodePatch.(string); ok && patchStr != "" { // Create a temporary patch file patchFile := filepath.Join(outDir, ".speakeasy", "temp_patch.patch") - fmt.Println("Saving patch to: %v", patchFile) if err := os.WriteFile(patchFile, []byte(patchStr), 0644); err != nil { return fmt.Errorf("failed to write patch file: %w", err) } @@ -524,9 +565,6 @@ func ApplyCustomCodePatch(ctx context.Context, reverse bool) error { // Apply the patch with 3-way merge args := []string{"apply", "--3way", "--index"} - if reverse { - args = append(args, "-R") - } args = append(args, patchFile) fmt.Println("running with args: %v", args) cmd := exec.Command("git", args...) @@ -596,38 +634,18 @@ func updateGenLockWithPatch(outDir, patchset, commitHash string) error { } // compileSDK compiles the SDK to verify custom code changes don't break compilation -func compileAndLintSDK(ctx context.Context, target, outDir string) error { +func compileAndLintSDK(ctx context.Context, target workflow.Target) error { // Create generator instance g, err := generate.New() if err != nil { return fmt.Errorf("failed to create generator: %w", err) } - // If target is "all", detect each target language from the SDK config - if target == "all" { - cfg, err := config.Load(outDir) - if err != nil { - return fmt.Errorf("failed to load config to detect language: %w", err) - } - - // Get the first (and usually only) language from the config - for lang := range cfg.Config.Languages { - fmt.Println("Language: " + lang) - // Call the public Compile method - if err := g.Compile(ctx, lang, outDir); err != nil { - return err - } - if err := g.Lint(ctx, lang, outDir); err != nil { - return err - } - } - } else { - if err := g.Compile(ctx, target, outDir); err != nil { - return err - } - if err := g.Lint(ctx, target, outDir); err != nil { + if err := g.Compile(ctx, target.Target, getTargetOutput(target)); err != nil { return err - } + } + if err := g.Lint(ctx, target.Target, getTargetOutput(target)); err != nil { + return err } return nil From 400b54e3b46a65e3eeaee61ba090e5fff4e9418e Mon Sep 17 00:00:00 2001 From: Ian Bentley Date: Wed, 8 Oct 2025 11:44:26 -0400 Subject: [PATCH 22/42] fix command.go --- internal/model/command.go | 55 ++++++++++++++++++++------------------- 1 file changed, 28 insertions(+), 27 deletions(-) diff --git a/internal/model/command.go b/internal/model/command.go index cd6ddc753..3f94d69a6 100644 --- a/internal/model/command.go +++ b/internal/model/command.go @@ -3,7 +3,7 @@ package model import ( "context" "encoding/json" - // errs "errors" + errs "errors" "fmt" "os" "os/exec" @@ -18,7 +18,7 @@ import ( "github.com/fatih/structs" "github.com/hashicorp/go-version" - // "github.com/sethvargo/go-githubactions" + "github.com/sethvargo/go-githubactions" "github.com/speakeasy-api/sdk-gen-config/workflow" "github.com/speakeasy-api/speakeasy-client-sdk-go/v3/pkg/models/shared" "github.com/speakeasy-api/speakeasy-core/events" @@ -318,28 +318,27 @@ func runWithVersionFromWorkflowFile(cmd *cobra.Command) error { } else { logger.PrintfStyled(styles.DimmedItalic, "Running with speakeasyVersion defined in workflow.yaml\n") } - // Get lockfile version before running the command, in case it gets overwritten - // lockfileVersion := getSpeakeasyVersionFromLockfile() + lockfileVersion := getSpeakeasyVersionFromLockfile() // If the workflow succeeds on latest, promote that version to the default shouldPromote := wf.SpeakeasyVersion == "latest" - runWithVersion(cmd, artifactArch, desiredVersion, shouldPromote) - // if runErr != nil { - // // If the error has been marked as non-rollbackable, return the cause - // if errors.Is(runErr, run.ErrNoRollback) { - // return errs.Unwrap(runErr) - // } - - // // If the command failed to run with the latest version, try to run with the version from the lock file - // if wf.SpeakeasyVersion == "latest" { - // msg := fmt.Sprintf("Failed to run with Speakeasy version %s: %s\n", desiredVersion, runErr.Error()) - // _ = log.SendToLogProxy(ctx, log.LogProxyLevelError, msg, nil) - // logger.PrintStyled(styles.DimmedItalic, msg) - // if env.IsGithubAction() { - // githubactions.AddStepSummary("# Speakeasy Version upgrade failure\n" + msg) - // } + runErr := runWithVersion(cmd, artifactArch, desiredVersion, shouldPromote) + if runErr != nil { + // If the error has been marked as non-rollbackable, return the cause + if errors.Is(runErr, run.ErrNoRollback) { + return errs.Unwrap(runErr) + } + + // If the command failed to run with the latest version, try to run with the version from the lock file + if wf.SpeakeasyVersion == "latest" { + msg := fmt.Sprintf("Failed to run with Speakeasy version %s: %s\n", desiredVersion, runErr.Error()) + _ = log.SendToLogProxy(ctx, log.LogProxyLevelError, msg, nil) + logger.PrintStyled(styles.DimmedItalic, msg) + if env.IsGithubAction() { + githubactions.AddStepSummary("# Speakeasy Version upgrade failure\n" + msg) + } if lockfileVersion != "" && lockfileVersion != desiredVersion { logger.PrintfStyled(styles.DimmedItalic, "Rerunning with previous successful version") @@ -347,19 +346,20 @@ func runWithVersionFromWorkflowFile(cmd *cobra.Command) error { } } - // // If the command failed to run with the pinned version, fail normally - // return runErr - // } + // If the command failed to run with the pinned version, fail normally + return runErr + } return nil } + // If promote is true, the version will be promoted to the default version (ie when running `speakeasy`) func runWithVersion(cmd *cobra.Command, artifactArch, desiredVersion string, shouldPromote bool) error { - vLocation := "/home/runner/work/branchgen-pr-test/branchgen-pr-test/bin/speakeasy" - // if err != nil { - // return ErrInstallFailed.Wrap(err) - // } + vLocation, err := updates.InstallVersion(cmd.Context(), desiredVersion, artifactArch, 30) + if err != nil { + return ErrInstallFailed.Wrap(err) + } cmdParts := utils.GetCommandParts(cmd) if cmdParts[0] == "speakeasy" { @@ -377,7 +377,7 @@ func runWithVersion(cmd *cobra.Command, artifactArch, desiredVersion string, sho newCmd.Stdout = os.Stdout newCmd.Stderr = os.Stderr - if err := newCmd.Run(); err != nil { + if err = newCmd.Run(); err != nil { return fmt.Errorf("failed to run with version %s: %w", desiredVersion, err) } @@ -391,6 +391,7 @@ func runWithVersion(cmd *cobra.Command, artifactArch, desiredVersion string, sho return nil } + func promoteVersion(ctx context.Context, vLocation string) error { mutex := locks.CLIUpdateLock() for result := range mutex.TryLock(ctx, 1*time.Second) { From af628fb26ff9c0e810ad19559e282e918150539b Mon Sep 17 00:00:00 2001 From: Ian Bentley Date: Wed, 8 Oct 2025 13:02:56 -0400 Subject: [PATCH 23/42] fix multi-build --- .../registercustomcode/registercustomcode.go | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/internal/registercustomcode/registercustomcode.go b/internal/registercustomcode/registercustomcode.go index bc2f090f3..7e9b5791f 100644 --- a/internal/registercustomcode/registercustomcode.go +++ b/internal/registercustomcode/registercustomcode.go @@ -54,10 +54,10 @@ func RegisterCustomCode(ctx context.Context, runGenerate func(string) error) err } logger.Info("Recorded original git hash for error recovery", zap.String("hash", originalHash.String())) - // Step 1: Verify main is up to date with origin/main - if err := verifyMainUpToDate(ctx); err != nil { - return fmt.Errorf("In order to register your custom code, your local branch must be up to date with origin/main: %w", err) - } + // // Step 1: Verify main is up to date with origin/main + // if err := verifyMainUpToDate(ctx); err != nil { + // return fmt.Errorf("In order to register your custom code, your local branch must be up to date with origin/main: %w", err) + // } // Step 2: Check changeset doesn't include .speakeasy directory changes if err := checkNoSpeakeasyChanges(ctx); err != nil { @@ -144,7 +144,7 @@ func RegisterCustomCode(ctx context.Context, runGenerate func(string) error) err } // Step 12: Commit just gen.lock with new patch - if err := commitGenLock(); err != nil { + if err := commitGenLock(getTargetOutput(target)); err != nil { return fmt.Errorf("failed to commit gen.lock: %w", err) } @@ -286,7 +286,7 @@ func checkNoSpeakeasyChanges(ctx context.Context) error { } fmt.Println(buf.String()) // Prints the unified diff */ - cmd := exec.Command("git", "diff", "--name-only", "main") + cmd := exec.Command("git", "diff", "--name-only") output, err := cmd.Output() if err != nil { return fmt.Errorf("failed to get changed files: %w", err) @@ -324,7 +324,7 @@ func checkNoLocalSpecChanges(ctx context.Context, workflow *workflow.Workflow) e logger.Info("Found local OpenAPI spec paths", zap.Strings("paths", localSpecPaths)) // Check if any of the local spec files have changes - cmd := exec.Command("git", "diff", "--name-only", "main") + cmd := exec.Command("git", "diff", "--name-only") output, err := cmd.Output() if err != nil { return fmt.Errorf("failed to get changed files: %w", err) @@ -515,7 +515,7 @@ func commitCustomCodeChanges() (string, error) { return commitHash, nil } -func commitGenLock() error { +func commitGenLock(outDir string) error { // Add only the gen.lock file /** GO GIT w, err := repo.Worktree() @@ -526,7 +526,7 @@ func commitGenLock() error { Email: "..." }}}) */ - cmd := exec.Command("git", "add", ".speakeasy/gen.lock") + cmd := exec.Command("git", "add", fmt.Sprintf("%v/.speakeasy/gen.lock", outDir)) if err := cmd.Run(); err != nil { return fmt.Errorf("failed to add gen.lock: %w", err) } From b5bcaaddd1e0c12b6042ad849d56014c752d1d8a Mon Sep 17 00:00:00 2001 From: Ian Bentley Date: Wed, 8 Oct 2025 21:28:24 -0400 Subject: [PATCH 24/42] cleanup --- cmd/customcode.go | 26 ++++- .../registercustomcode/registercustomcode.go | 110 +++++++++--------- 2 files changed, 75 insertions(+), 61 deletions(-) diff --git a/cmd/customcode.go b/cmd/customcode.go index 482571a5b..3deb714a6 100644 --- a/cmd/customcode.go +++ b/cmd/customcode.go @@ -13,6 +13,7 @@ import ( "github.com/speakeasy-api/speakeasy/internal/env" "github.com/speakeasy-api/speakeasy/internal/run" + "go.uber.org/zap" ) type RegisterCustomCodeFlags struct { @@ -77,21 +78,38 @@ var registerCustomCodeCmd = &model.ExecutableCommand[RegisterCustomCodeFlags]{ } func registerCustomCode(ctx context.Context, flags RegisterCustomCodeFlags) error { + logger := log.From(ctx).With(zap.String("method", "RegisterCustomCode")) // If --show flag is provided, show existing customcode if flags.Show { - return registercustomcode.ShowCustomCodePatch(ctx) + wf, _, err := utils.GetWorkflowAndDir() + if err != nil { + return fmt.Errorf("Could not find workflow file") + } + var allErrors []error + for targetName, target := range wf.Targets { + logger.Info("Showing target", zap.String("target_name", targetName)) + if err := registercustomcode.ShowCustomCodePatch(ctx, target); err != nil { + allErrors = append(allErrors, fmt.Errorf("target %s: %w", targetName, err)) + } + } + if len(allErrors) > 0 { + return fmt.Errorf("errors occurred: %v", allErrors) + } + return nil } - // If --apply flag is provided, only apply existing patches + // If --apply-only flag is provided, only apply existing patches if flags.Apply { wf, _, err := utils.GetWorkflowAndDir() if err != nil { return fmt.Errorf("Could not find workflow file") } - for _, target := range wf.Targets { - return registercustomcode.ApplyCustomCodePatch(ctx, target) + for targetName, target := range wf.Targets { + fmt.Println("Applying target ", targetName) + registercustomcode.ApplyCustomCodePatch(ctx, target) } + return nil } // If --latest-hash flag is provided, show the commit hash from gen.lock diff --git a/internal/registercustomcode/registercustomcode.go b/internal/registercustomcode/registercustomcode.go index 7e9b5791f..988bb3c6d 100644 --- a/internal/registercustomcode/registercustomcode.go +++ b/internal/registercustomcode/registercustomcode.go @@ -41,6 +41,7 @@ func getOtherTargetOutputs(wf *workflow.Workflow, currentTargetName string) []st return otherOutputs } + // RegisterCustomCode registers custom code changes by capturing them as patches in gen.lock func RegisterCustomCode(ctx context.Context, runGenerate func(string) error) error { wf, _, err := utils.GetWorkflowAndDir() @@ -94,8 +95,6 @@ func RegisterCustomCode(ctx context.Context, runGenerate func(string) error) err } for targetName, target := range wf.Targets { - fmt.Println("Starting target", targetName) - fmt.Println(fmt.Sprintf("Patch: '%v'", targetPatches[targetName])) if targetPatches[targetName] == "" { continue } @@ -103,22 +102,24 @@ func RegisterCustomCode(ctx context.Context, runGenerate func(string) error) err if err := ApplyCustomCodePatch(ctx, target); err != nil { return fmt.Errorf("failed to apply existing patch: %w", err) } - - // Step 8: Stage all changes after applying existing patch - if err := stageAllChanges(getTargetOutput(target)); err != nil { - return fmt.Errorf("failed to stage changes after applying existing patch: %w", err) + // Step 8: Apply the new custom code diff (with --index to stage changes) + if err := applyNewPatch(targetPatches[targetName]); err != nil { + removeCleanGenerationCommit(ctx, originalHash) + return fmt.Errorf("conflicts detected when applying new patch. Please resolve any conflicts, and run `customcode` again.") } - // Step 9: Apply the new custom code diff - if targetPatches[targetName] != "" { - if err := applyNewPatch(targetPatches[targetName]); err != nil { - removeCleanGenerationCommit(ctx, originalHash) - return fmt.Errorf("conflicts detected when applying new patch. Please resolve any conflicts, and run `customcode` again.") - } + // Check if there are any changes after applying the patch. If no changes, continue the loop + otherTargetOutputs := getOtherTargetOutputs(wf, targetName) + hasChanges, err := checkForChangesWithExclusions(getTargetOutput(target), otherTargetOutputs) + if err != nil { + return fmt.Errorf("failed to check for changes: %w", err) + } + if !hasChanges { + fmt.Printf("No changes detected for target %s after applying existing patch, skipping\n", targetName) + continue } // Step 10: Capture the full combined diff (existing patch + new changes) - otherTargetOutputs := getOtherTargetOutputs(wf, targetName) fullCustomCodeDiff, err := captureCustomCodeDiff(getTargetOutput(target), otherTargetOutputs) if err != nil { return fmt.Errorf("failed to capture full custom code diff: %w", err) @@ -126,7 +127,6 @@ func RegisterCustomCode(ctx context.Context, runGenerate func(string) error) err targetPatches[targetName] = fullCustomCodeDiff logger.Info("Compiling SDK to verify custom code changes...") if err := compileAndLintSDK(ctx, target); err != nil { - fmt.Println("err: ", err) removeCleanGenerationCommit(ctx, originalHash) return fmt.Errorf("custom code changes failed compilation or linting. Please resolve any compilation/linting errors and run `customcode` again.") } @@ -156,13 +156,11 @@ func RegisterCustomCode(ctx context.Context, runGenerate func(string) error) err } // ShowCustomCodePatch displays the custom code patch stored in the gen.lock file -func ShowCustomCodePatch(ctx context.Context) error { +func ShowCustomCodePatch(ctx context.Context, target workflow.Target) error { logger := log.From(ctx).With(zap.String("method", "ShowCustomCodePatch")) - _, outDir, err := utils.GetWorkflowAndDir() - if err != nil { - return err - } + outDir := getTargetOutput(target) + cfg, err := config.Load(outDir) if err != nil { return fmt.Errorf("failed to load config: %w", err) @@ -434,7 +432,6 @@ func captureCustomCodeDiff(outDir string, excludePaths []string) (string, error) } cmd := exec.Command("git", args...) - fmt.Printf("Running git command: git %s\n", strings.Join(args, " ")) combinedOutput, err := cmd.CombinedOutput() if err != nil { @@ -444,6 +441,33 @@ func captureCustomCodeDiff(outDir string, excludePaths []string) (string, error) return string(combinedOutput), nil } + +func checkForChangesWithExclusions(dir string, excludePaths []string) (bool, error) { + args := []string{"diff", "--cached", dir} + + // Filter excludePaths to only include children of dir + cleanDir := filepath.Clean(dir) + for _, excludePath := range excludePaths { + cleanExcludePath := filepath.Clean(excludePath) + + // Check if excludePath is a child of dir (or equal to dir) + rel, err := filepath.Rel(cleanDir, cleanExcludePath) + if err == nil && !strings.HasPrefix(rel, "..") && rel != "." { + args = append(args, ":^"+excludePath) + } + } + + cmd := exec.Command("git", args...) + output, err := cmd.CombinedOutput() + + if err != nil { + return false, fmt.Errorf("failed to check for changes: %w", err) + } + + // Check if output is empty (no changes) or has content (changes exist) + return strings.TrimSpace(string(output)) != "", nil +} + func stageAllChanges(dir string) error { if dir == "" { dir = "." @@ -474,8 +498,8 @@ func commitCleanGeneration() error { } // Commit the clean generation - commitCmd := exec.Command("git", "commit", "-m", "clean generation") - if output, err := commitCmd.CombinedOutput(); err != nil { + cmd := exec.Command("git", "commit", "-m", "clean generation") + if output, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("failed to commit clean generation: %w\nOutput: %s", err, string(output)) } @@ -484,23 +508,10 @@ func commitCleanGeneration() error { func commitCustomCodeChanges() (string, error) { - // Add all changes (excluding gen.lock) - addCmd := exec.Command("git", "add", ".") - if output, err := addCmd.CombinedOutput(); err != nil { - return "", fmt.Errorf("failed to add custom code changes: %w\nOutput: %s", err, string(output)) - } - - // Reset gen.lock if it was staged (we don't want it in this commit) - resetCmd := exec.Command("git", "reset", ".speakeasy/gen.lock") - if output, err := resetCmd.CombinedOutput(); err != nil { - // This is non-fatal - gen.lock might not exist or be staged - fmt.Printf("Warning: failed to unstage gen.lock: %v\nOutput: %s\n", err, string(output)) - } - - // Commit the custom code changes + // Commit the staged changes (changes should already be staged by --index operations) commitMsg := "Apply custom code changes" - commitCmd := exec.Command("git", "commit", "--allow-empty", "-m", commitMsg) - if output, err := commitCmd.CombinedOutput(); err != nil { + cmd := exec.Command("git", "commit", "-m", commitMsg, "--allow-empty") + if output, err := cmd.CombinedOutput(); err != nil { return "", fmt.Errorf("failed to commit custom code changes: %w\nOutput: %s", err, string(output)) } @@ -517,15 +528,6 @@ func commitCustomCodeChanges() (string, error) { func commitGenLock(outDir string) error { // Add only the gen.lock file - /** GO GIT - w, err := repo.Worktree() - _, err = w.Add(".speakeasy/gen.lock") - w.Commit("Register custom code changes", &git.CommitOptions{ - Author: &object.Signature{ - Name: "speakeasybot", - Email: "..." - }}}) - */ cmd := exec.Command("git", "add", fmt.Sprintf("%v/.speakeasy/gen.lock", outDir)) if err := cmd.Run(); err != nil { return fmt.Errorf("failed to add gen.lock: %w", err) @@ -535,10 +537,7 @@ func commitGenLock(outDir string) error { commitMsg := "Register custom code changes" cmd = exec.Command("git", "commit", "-m", commitMsg) if output, err := cmd.CombinedOutput(); err != nil { - fmt.Printf("git commit output: %s\n", string(output)) - return fmt.Errorf("failed to commit gen.lock: %w", err) - } else { - fmt.Printf("git commit output: %s\n", string(output)) + return fmt.Errorf("failed to commit gen.lock: %w\nOutput: %s", err, string(output)) } return nil @@ -561,17 +560,14 @@ func ApplyCustomCodePatch(ctx context.Context, target workflow.Target) error { if err := os.WriteFile(patchFile, []byte(patchStr), 0644); err != nil { return fmt.Errorf("failed to write patch file: %w", err) } - defer os.Remove(patchFile) + // defer os.Remove(patchFile) // Apply the patch with 3-way merge args := []string{"apply", "--3way", "--index"} args = append(args, patchFile) - fmt.Println("running with args: %v", args) cmd := exec.Command("git", args...) if output, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("failed to apply patch: %w\nOutput: %s", err, string(output)) - } else { - fmt.Println("Not error: ", string(output)) } } } @@ -591,8 +587,8 @@ func applyNewPatch(customCodeDiff string) error { } defer os.Remove(patchFile) - // Apply the patch with 3-way merge - cmd := exec.Command("git", "apply", "-3", patchFile) + // Apply the patch with 3-way merge and stage changes + cmd := exec.Command("git", "apply", "-3", "--index", patchFile) if output, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("failed to apply new patch: %w\nOutput: %s", err, string(output)) } From db90d5304468fbc7bb9f7e0693ea751b76d142b8 Mon Sep 17 00:00:00 2001 From: Ivan Gorshkov Date: Thu, 9 Oct 2025 14:06:08 +0100 Subject: [PATCH 25/42] New conflict resolution flow --- cmd/customcode.go | 9 + .../registercustomcode/registercustomcode.go | 198 +++++++++++++++++- 2 files changed, 205 insertions(+), 2 deletions(-) diff --git a/cmd/customcode.go b/cmd/customcode.go index 3deb714a6..5f14580d2 100644 --- a/cmd/customcode.go +++ b/cmd/customcode.go @@ -44,6 +44,10 @@ var registerCustomCodeCmd = &model.ExecutableCommand[RegisterCustomCodeFlags]{ Shorthand: "s", Description: "show custom code patches", }, + flag.BooleanFlag{ + Name: "resolve", + Description: "enter conflict resolution mode after a failed generation", + }, flag.BooleanFlag{ Name: "apply-only", Shorthand: "a", @@ -99,6 +103,11 @@ func registerCustomCode(ctx context.Context, flags RegisterCustomCodeFlags) erro return nil } + // If --resolve flag is provided, enter conflict resolution mode + if flags.Resolve { + return registercustomcode.ResolveCustomCodeConflicts(ctx) + } + // If --apply-only flag is provided, only apply existing patches if flags.Apply { wf, _, err := utils.GetWorkflowAndDir() diff --git a/internal/registercustomcode/registercustomcode.go b/internal/registercustomcode/registercustomcode.go index 988bb3c6d..119004130 100644 --- a/internal/registercustomcode/registercustomcode.go +++ b/internal/registercustomcode/registercustomcode.go @@ -45,9 +45,17 @@ func getOtherTargetOutputs(wf *workflow.Workflow, currentTargetName string) []st // RegisterCustomCode registers custom code changes by capturing them as patches in gen.lock func RegisterCustomCode(ctx context.Context, runGenerate func(string) error) error { wf, _, err := utils.GetWorkflowAndDir() + if err != nil { + return err + } logger := log.From(ctx).With(zap.String("method", "RegisterCustomCode")) + // Check if we're completing conflict resolution + if isConflictResolutionMode() { + return completeConflictResolution(ctx, wf) + } + // Record the current git hash at the very beginning for error recovery originalHash, err := getCurrentGitHash() if err != nil { @@ -217,6 +225,180 @@ func ShowLatestCommitHash(ctx context.Context) error { return nil } +// ResolveCustomCodeConflicts enters conflict resolution mode to help users resolve conflicts +// that occurred during generation when applying custom code patches +func ResolveCustomCodeConflicts(ctx context.Context) error { + logger := log.From(ctx).With(zap.String("method", "ResolveCustomCodeConflicts")) + + // Load workflow to get targets + wf, _, err := utils.GetWorkflowAndDir() + if err != nil { + return fmt.Errorf("could not find workflow file: %w", err) + } + + hadConflicts := false + + for targetName, target := range wf.Targets { + outDir := getTargetOutput(target) + + // Check if patch exists in gen.lock + cfg, err := config.Load(outDir) + if err != nil { + return fmt.Errorf("failed to load config for target %s: %w", targetName, err) + } + + customCodePatch, exists := cfg.LockFile.Management.AdditionalProperties["customCodePatch"] + if !exists { + logger.Info(fmt.Sprintf("No custom code patch for target %s, skipping", targetName)) + continue + } + + patchStr, ok := customCodePatch.(string) + if !ok || patchStr == "" { + continue + } + + logger.Info(fmt.Sprintf("Resolving conflicts for target %s", targetName)) + + // Step 1: Undo patch application - extract clean new generation from "ours" side + cmd := exec.Command("git", "checkout", "--ours", "--", outDir) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to checkout ours: %w\nOutput: %s", err, string(output)) + } + + // Step 2: Add other changes to worktree (stage the clean generation files) + if err := stageAllChanges(outDir); err != nil { + return fmt.Errorf("failed to stage changes for target %s: %w", targetName, err) + } + + // Step 3: Commit as 'clean generation' + cmd = exec.Command("git", "commit", "-m", "clean generation (conflict resolution)", "--allow-empty") + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to commit clean generation: %w\nOutput: %s", err, string(output)) + } + + // Step 4: Apply old patch (will create conflicts) + patchFile := filepath.Join(outDir, ".speakeasy", "resolve_patch.patch") + if err := os.WriteFile(patchFile, []byte(patchStr), 0644); err != nil { + return fmt.Errorf("failed to write patch file: %w", err) + } + defer os.Remove(patchFile) + + cmd = exec.Command("git", "apply", "-3", patchFile) + _, _ = cmd.CombinedOutput() // Expect failure with conflicts + + // Step 5: Check if conflicts exist + cmd = exec.Command("git", "diff", "--name-only", "--diff-filter=U") + conflictOutput, err := cmd.Output() + if err != nil { + return fmt.Errorf("failed to check for conflicts: %w", err) + } + + conflictFiles := strings.Split(strings.TrimSpace(string(conflictOutput)), "\n") + if len(conflictFiles) > 0 && conflictFiles[0] != "" { + hadConflicts = true + fmt.Printf("\nConflicts detected in target '%s':\n", targetName) + for _, file := range conflictFiles { + fmt.Printf(" - %s\n", file) + } + } + } + + if hadConflicts { + fmt.Println("\nPlease:") + fmt.Println(" 1. Resolve conflicts in your editor") + fmt.Println(" 2. Stage resolved files: git add ") + fmt.Println(" 3. Run: speakeasy customcode") + fmt.Println("\nThe updated patch will be registered.") + } else { + fmt.Println("\nNo conflicts detected. You may proceed with registration.") + } + + return nil +} + +// completeConflictResolution completes the conflict resolution process after user has resolved conflicts +func completeConflictResolution(ctx context.Context, wf *workflow.Workflow) error { + logger := log.From(ctx).With(zap.String("method", "completeConflictResolution")) + + logger.Info("Completing conflict resolution registration") + + for targetName, target := range wf.Targets { + outDir := getTargetOutput(target) + + // Check for staged changes + cmd := exec.Command("git", "diff", "--cached", "--name-only", outDir) + output, err := cmd.Output() + if err != nil { + return fmt.Errorf("failed to check staged changes: %w", err) + } + + if strings.TrimSpace(string(output)) == "" { + logger.Info(fmt.Sprintf("No staged changes for target %s, skipping", targetName)) + continue + } + + // Check for unresolved conflicts + cmd = exec.Command("git", "diff", "--cached", "--check") + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("unresolved conflicts remain:\n%s\nPlease resolve and stage all files", string(output)) + } + + // Capture resolved patch from staged changes + otherTargetOutputs := getOtherTargetOutputs(wf, targetName) + args := []string{"diff", "--cached", outDir} + + // Filter excludePaths to only include children of outDir + cleanOutDir := filepath.Clean(outDir) + for _, excludePath := range otherTargetOutputs { + cleanExcludePath := filepath.Clean(excludePath) + + // Check if excludePath is a child of outDir (or equal to outDir) + rel, err := filepath.Rel(cleanOutDir, cleanExcludePath) + if err == nil && !strings.HasPrefix(rel, "..") && rel != "." { + args = append(args, ":^"+excludePath) + } + } + + cmd = exec.Command("git", args...) + resolvedPatch, err := cmd.Output() + if err != nil { + return fmt.Errorf("failed to capture resolved patch: %w", err) + } + + // Commit resolved custom code + commitHash, err := commitCustomCodeChanges() + if err != nil { + return err + } + + logger.Info("Created commit with resolved custom code", zap.String("commit_hash", commitHash)) + + // Update gen.lock + if err := updateGenLockWithPatch(outDir, string(resolvedPatch), commitHash); err != nil { + return err + } + + // Compile/lint + logger.Info("Verifying resolved custom code...") + if err := compileAndLintSDK(ctx, target); err != nil { + return fmt.Errorf("resolved custom code failed compilation: %w", err) + } + + // Commit gen.lock + if err := commitGenLock(outDir); err != nil { + return err + } + + logger.Info(fmt.Sprintf("Successfully registered resolved patch for target %s", targetName)) + } + + fmt.Println("\nSuccessfully registered updated custom code patches.") + fmt.Println("Your custom code is now compatible with the latest generation.") + + return nil +} + // Git validation helpers func verifyMainUpToDate(ctx context.Context) error { logger := log.From(ctx) @@ -497,8 +679,8 @@ func commitCleanGeneration() error { return fmt.Errorf("failed to add changes for clean generation commit: %w\nOutput: %s", err, string(output)) } - // Commit the clean generation - cmd := exec.Command("git", "commit", "-m", "clean generation") + // Commit the clean generation (allow empty if nothing changed) + cmd := exec.Command("git", "commit", "-m", "clean generation", "--allow-empty") if output, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("failed to commit clean generation: %w\nOutput: %s", err, string(output)) } @@ -722,3 +904,15 @@ func removeCleanGenerationCommit(ctx context.Context, originalHash plumbing.Hash logger.Info("Error recovery completed successfully") return nil } + +// isConflictResolutionMode checks if we're in conflict resolution mode by checking the HEAD commit message +func isConflictResolutionMode() bool { + cmd := exec.Command("git", "log", "-1", "--format=%s") + output, err := cmd.Output() + if err != nil { + return false + } + + msg := strings.TrimSpace(string(output)) + return msg == "clean generation (conflict resolution)" +} From df3743a12a87d2f4998303deb81043caadf40604 Mon Sep 17 00:00:00 2001 From: Ivan Gorshkov Date: Fri, 10 Oct 2025 17:43:34 +0100 Subject: [PATCH 26/42] fixes --- .../registercustomcode/registercustomcode.go | 94 +++++++++---------- 1 file changed, 45 insertions(+), 49 deletions(-) diff --git a/internal/registercustomcode/registercustomcode.go b/internal/registercustomcode/registercustomcode.go index 119004130..fdc56e916 100644 --- a/internal/registercustomcode/registercustomcode.go +++ b/internal/registercustomcode/registercustomcode.go @@ -11,11 +11,11 @@ import ( "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing" + "github.com/speakeasy-api/openapi-generation/v2/pkg/generate" config "github.com/speakeasy-api/sdk-gen-config" "github.com/speakeasy-api/sdk-gen-config/workflow" - "github.com/speakeasy-api/openapi-generation/v2/pkg/generate" - "github.com/speakeasy-api/speakeasy/internal/utils" "github.com/speakeasy-api/speakeasy/internal/log" + "github.com/speakeasy-api/speakeasy/internal/utils" "go.uber.org/zap" ) @@ -33,7 +33,7 @@ func getOtherTargetOutputs(wf *workflow.Workflow, currentTargetName string) []st for targetName, target := range wf.Targets { if targetName != currentTargetName { output := getTargetOutput(target) - if output != "." { // Don't exclude current directory + if output != "." { // Don't exclude current directory otherOutputs = append(otherOutputs, output) } } @@ -41,7 +41,6 @@ func getOtherTargetOutputs(wf *workflow.Workflow, currentTargetName string) []st return otherOutputs } - // RegisterCustomCode registers custom code changes by capturing them as patches in gen.lock func RegisterCustomCode(ctx context.Context, runGenerate func(string) error) error { wf, _, err := utils.GetWorkflowAndDir() @@ -52,9 +51,9 @@ func RegisterCustomCode(ctx context.Context, runGenerate func(string) error) err logger := log.From(ctx).With(zap.String("method", "RegisterCustomCode")) // Check if we're completing conflict resolution - if isConflictResolutionMode() { - return completeConflictResolution(ctx, wf) - } + // if isConflictResolutionMode() { + // return completeConflictResolution(ctx, wf) + // } // Record the current git hash at the very beginning for error recovery originalHash, err := getCurrentGitHash() @@ -85,9 +84,9 @@ func RegisterCustomCode(ctx context.Context, runGenerate func(string) error) err if err != nil { return fmt.Errorf("failed to capture custom code diff: %w", err) } - + fmt.Println(fmt.Sprintf("Captured custom code diff for target %v:\n%s", targetName, customCodeDiff)) // If no custom code changes detected, return early - if customCodeDiff == ""{ + if customCodeDiff == "" { fmt.Println(fmt.Sprintf("No custom code changes detected in target %v, nothing to register", targetName)) } targetPatches[targetName] = customCodeDiff @@ -110,6 +109,8 @@ func RegisterCustomCode(ctx context.Context, runGenerate func(string) error) err if err := ApplyCustomCodePatch(ctx, target); err != nil { return fmt.Errorf("failed to apply existing patch: %w", err) } + fmt.Println(fmt.Sprintf("Applied existing custom code patch for target %v", targetName)) + fmt.Println(targetPatches[targetName]) // Step 8: Apply the new custom code diff (with --index to stage changes) if err := applyNewPatch(targetPatches[targetName]); err != nil { removeCleanGenerationCommit(ctx, originalHash) @@ -158,7 +159,6 @@ func RegisterCustomCode(ctx context.Context, runGenerate func(string) error) err } - logger.Info("Successfully registered custom code changes. Code changes will be applied on top of your code after generation.") return nil } @@ -440,31 +440,31 @@ func checkNoSpeakeasyChanges(ctx context.Context) error { logger.Info("Checking that changeset doesn't include .speakeasy directory changes") /** - * Can be done with GO GIT, but it's not so obvious - head, err := repo.Head() - if err != nil { - // Handle error - } - commit, err := repo.CommitObject(head.Hash()) - if err != nil { - // Handle error - } - tree, err := commit.Tree() - if err != nil { - // Handle error - } - patch, err := tree1.Diff(tree2) - if err != nil { - // Handle error - } - - var buf bytes.Buffer - encoder := diff.NewUnifiedEncoder(&buf) - err = encoder.Encode(patch) - if err != nil { - // Handle error - } - fmt.Println(buf.String()) // Prints the unified diff + * Can be done with GO GIT, but it's not so obvious + head, err := repo.Head() + if err != nil { + // Handle error + } + commit, err := repo.CommitObject(head.Hash()) + if err != nil { + // Handle error + } + tree, err := commit.Tree() + if err != nil { + // Handle error + } + patch, err := tree1.Diff(tree2) + if err != nil { + // Handle error + } + + var buf bytes.Buffer + encoder := diff.NewUnifiedEncoder(&buf) + err = encoder.Encode(patch) + if err != nil { + // Handle error + } + fmt.Println(buf.String()) // Prints the unified diff */ cmd := exec.Command("git", "diff", "--name-only") output, err := cmd.Output() @@ -493,7 +493,6 @@ func checkNoLocalSpecChanges(ctx context.Context, workflow *workflow.Workflow) e logger := log.From(ctx) logger.Info("Checking if workflow.yaml references local OpenAPI specs and validating no spec changes") - // Extract local spec paths from workflow localSpecPaths := extractLocalSpecPaths(workflow) if len(localSpecPaths) == 0 { @@ -588,7 +587,7 @@ func isLocalPath(location workflow.LocationString) bool { func generateCleanSDK(ctx context.Context, targetName string, runGenerate func(targetName string) error) error { logger := log.From(ctx) err := runGenerate(targetName) - + if err != nil { return fmt.Errorf("failed to generate SDK: %w", err) } @@ -600,19 +599,19 @@ func generateCleanSDK(ctx context.Context, targetName string, runGenerate func(t // Git operations func captureCustomCodeDiff(outDir string, excludePaths []string) (string, error) { args := []string{"diff", "HEAD", outDir} - + // Filter excludePaths to only include children of outDir cleanOutDir := filepath.Clean(outDir) for _, excludePath := range excludePaths { cleanExcludePath := filepath.Clean(excludePath) - + // Check if excludePath is a child of outDir (or equal to outDir) rel, err := filepath.Rel(cleanOutDir, cleanExcludePath) if err == nil && !strings.HasPrefix(rel, "..") && rel != "." { args = append(args, ":^"+excludePath) } } - + cmd := exec.Command("git", args...) combinedOutput, err := cmd.CombinedOutput() @@ -623,29 +622,28 @@ func captureCustomCodeDiff(outDir string, excludePaths []string) (string, error) return string(combinedOutput), nil } - func checkForChangesWithExclusions(dir string, excludePaths []string) (bool, error) { args := []string{"diff", "--cached", dir} - + // Filter excludePaths to only include children of dir cleanDir := filepath.Clean(dir) for _, excludePath := range excludePaths { cleanExcludePath := filepath.Clean(excludePath) - + // Check if excludePath is a child of dir (or equal to dir) rel, err := filepath.Rel(cleanDir, cleanExcludePath) if err == nil && !strings.HasPrefix(rel, "..") && rel != "." { args = append(args, ":^"+excludePath) } } - + cmd := exec.Command("git", args...) output, err := cmd.CombinedOutput() - + if err != nil { return false, fmt.Errorf("failed to check for changes: %w", err) } - + // Check if output is empty (no changes) or has content (changes exist) return strings.TrimSpace(string(output)) != "", nil } @@ -688,7 +686,6 @@ func commitCleanGeneration() error { return nil } - func commitCustomCodeChanges() (string, error) { // Commit the staged changes (changes should already be staged by --index operations) commitMsg := "Apply custom code changes" @@ -725,7 +722,6 @@ func commitGenLock(outDir string) error { return nil } - func ApplyCustomCodePatch(ctx context.Context, target workflow.Target) error { outDir := getTargetOutput(target) // Load the current configuration and lock file @@ -820,7 +816,7 @@ func compileAndLintSDK(ctx context.Context, target workflow.Target) error { } if err := g.Compile(ctx, target.Target, getTargetOutput(target)); err != nil { - return err + return err } if err := g.Lint(ctx, target.Target, getTargetOutput(target)); err != nil { return err From a37fcaacc32c12f03727570e81ce0c5aacf0e169 Mon Sep 17 00:00:00 2001 From: Ian Bentley Date: Fri, 10 Oct 2025 14:34:57 -0400 Subject: [PATCH 27/42] cleanup and prevent common issues --- .../registercustomcode/registercustomcode.go | 333 ++++++++---------- 1 file changed, 145 insertions(+), 188 deletions(-) diff --git a/internal/registercustomcode/registercustomcode.go b/internal/registercustomcode/registercustomcode.go index fdc56e916..b1a6a810b 100644 --- a/internal/registercustomcode/registercustomcode.go +++ b/internal/registercustomcode/registercustomcode.go @@ -51,9 +51,9 @@ func RegisterCustomCode(ctx context.Context, runGenerate func(string) error) err logger := log.From(ctx).With(zap.String("method", "RegisterCustomCode")) // Check if we're completing conflict resolution - // if isConflictResolutionMode() { - // return completeConflictResolution(ctx, wf) - // } + if isConflictResolutionMode() { + return completeConflictResolution(ctx, wf) + } // Record the current git hash at the very beginning for error recovery originalHash, err := getCurrentGitHash() @@ -76,20 +76,11 @@ func RegisterCustomCode(ctx context.Context, runGenerate func(string) error) err if err := checkNoLocalSpecChanges(ctx, wf); err != nil { return fmt.Errorf("Registering custom code in your openapi spec and related files is not supported: %w", err) } - targetPatches := make(map[string]string) - for targetName, target := range wf.Targets { - // Step 4: Capture patchset with git diff for custom code changes - otherTargetOutputs := getOtherTargetOutputs(wf, targetName) - customCodeDiff, err := captureCustomCodeDiff(getTargetOutput(target), otherTargetOutputs) - if err != nil { - return fmt.Errorf("failed to capture custom code diff: %w", err) - } - fmt.Println(fmt.Sprintf("Captured custom code diff for target %v:\n%s", targetName, customCodeDiff)) - // If no custom code changes detected, return early - if customCodeDiff == "" { - fmt.Println(fmt.Sprintf("No custom code changes detected in target %v, nothing to register", targetName)) - } - targetPatches[targetName] = customCodeDiff + targetPatches, err := getPatchesPerTarget(wf) + if err != nil { + return err + } + for targetName, _ := range wf.Targets { // Step 5: Generate clean SDK (without custom code) on main branch if err := generateCleanSDK(ctx, targetName, runGenerate); err != nil { return fmt.Errorf("failed to generate clean SDK: %w", err) @@ -105,61 +96,88 @@ func RegisterCustomCode(ctx context.Context, runGenerate func(string) error) err if targetPatches[targetName] == "" { continue } - // Step 7: Apply existing custom code patch from gen.lock - if err := ApplyCustomCodePatch(ctx, target); err != nil { - return fmt.Errorf("failed to apply existing patch: %w", err) - } - fmt.Println(fmt.Sprintf("Applied existing custom code patch for target %v", targetName)) - fmt.Println(targetPatches[targetName]) - // Step 8: Apply the new custom code diff (with --index to stage changes) - if err := applyNewPatch(targetPatches[targetName]); err != nil { - removeCleanGenerationCommit(ctx, originalHash) - return fmt.Errorf("conflicts detected when applying new patch. Please resolve any conflicts, and run `customcode` again.") + err = updateCustomPatchAndUpdateGenLock(ctx, wf, originalHash, targetPatches, target, targetName) + if err != nil { + return err } - // Check if there are any changes after applying the patch. If no changes, continue the loop + } + + logger.Info("Successfully registered custom code changes. Code changes will be applied on top of your code after generation.") + return nil +} + + +func getPatchesPerTarget(wf *workflow.Workflow) (map[string]string, error){ + targetPatches := make(map[string]string) + for targetName, target := range wf.Targets { + // Step 4: Capture patchset with git diff for custom code changes otherTargetOutputs := getOtherTargetOutputs(wf, targetName) - hasChanges, err := checkForChangesWithExclusions(getTargetOutput(target), otherTargetOutputs) + customCodeDiff, err := captureCustomCodeDiff(getTargetOutput(target), otherTargetOutputs) if err != nil { - return fmt.Errorf("failed to check for changes: %w", err) + return nil, fmt.Errorf("failed to capture custom code diff: %w", err) } - if !hasChanges { - fmt.Printf("No changes detected for target %s after applying existing patch, skipping\n", targetName) - continue + fmt.Println(fmt.Sprintf("Captured custom code diff for target %v:\n%s", targetName, customCodeDiff)) + // If no custom code changes detected, return early + if customCodeDiff == "" { + fmt.Println(fmt.Sprintf("No custom code changes detected in target %v, nothing to register", targetName)) } + targetPatches[targetName] = customCodeDiff + } + return targetPatches, nil +} - // Step 10: Capture the full combined diff (existing patch + new changes) - fullCustomCodeDiff, err := captureCustomCodeDiff(getTargetOutput(target), otherTargetOutputs) - if err != nil { - return fmt.Errorf("failed to capture full custom code diff: %w", err) - } - targetPatches[targetName] = fullCustomCodeDiff - logger.Info("Compiling SDK to verify custom code changes...") - if err := compileAndLintSDK(ctx, target); err != nil { - removeCleanGenerationCommit(ctx, originalHash) - return fmt.Errorf("custom code changes failed compilation or linting. Please resolve any compilation/linting errors and run `customcode` again.") - } - // Step 10.6: Create commit with custom code changes after successful compilation - customCodeCommitHash, err := commitCustomCodeChanges() - if err != nil { - removeCleanGenerationCommit(ctx, originalHash) - return fmt.Errorf("failed to commit custom code changes: %w", err) - } - logger.Info("Created commit with custom code changes", zap.String("commit_hash", customCodeCommitHash)) +func updateCustomPatchAndUpdateGenLock(ctx context.Context, wf *workflow.Workflow, originalHash plumbing.Hash, targetPatches map[string]string, target workflow.Target, targetName string) error { + logger := log.From(ctx).With(zap.String("method", "updateCustomPatchAndUpdateGenLock")) + // Step 7: Apply existing custom code patch from gen.lock + if err := ApplyCustomCodePatch(ctx, target); err != nil { + return fmt.Errorf("failed to apply existing patch: %w", err) + } + // Step 8: Apply the new custom code diff (with --index to stage changes) + if err := applyNewPatch(targetPatches[targetName]); err != nil { + removeCleanGenerationCommit(ctx, originalHash) + return fmt.Errorf("conflicts detected when applying new patch. Please resolve any conflicts, and run `customcode` again.") + } - // Step 11: Update gen.lock with full combined patch and commit hash - if err := updateGenLockWithPatch(getTargetOutput(target), targetPatches[targetName], customCodeCommitHash); err != nil { - return fmt.Errorf("failed to update gen.lock: %w", err) - } + // Check if there are any changes after applying the patch. If no changes, continue the loop + otherTargetOutputs := getOtherTargetOutputs(wf, targetName) + hasChanges, err := checkForChangesWithExclusions(getTargetOutput(target), otherTargetOutputs) + if err != nil { + return fmt.Errorf("failed to check for changes: %w", err) + } + if !hasChanges { + fmt.Printf("No changes detected for target %s after applying existing patch, skipping\n", targetName) + return nil + } - // Step 12: Commit just gen.lock with new patch - if err := commitGenLock(getTargetOutput(target)); err != nil { - return fmt.Errorf("failed to commit gen.lock: %w", err) - } + // Step 10: Capture the full combined diff (existing patch + new changes) + fullCustomCodeDiff, err := captureCustomCodeDiff(getTargetOutput(target), otherTargetOutputs) + if err != nil { + return fmt.Errorf("failed to capture full custom code diff: %w", err) + } + targetPatches[targetName] = fullCustomCodeDiff + logger.Info("Compiling SDK to verify custom code changes...") + if err := compileAndLintSDK(ctx, target); err != nil { + removeCleanGenerationCommit(ctx, originalHash) + return fmt.Errorf("custom code changes failed compilation or linting. Please resolve any compilation/linting errors and run `customcode` again.") + } + // Step 10.6: Create commit with custom code changes after successful compilation + customCodeCommitHash, err := commitCustomCodeChanges() + if err != nil { + removeCleanGenerationCommit(ctx, originalHash) + return fmt.Errorf("failed to commit custom code changes: %w", err) + } + logger.Info("Created commit with custom code changes", zap.String("commit_hash", customCodeCommitHash)) + // Step 11: Update gen.lock with full combined patch and commit hash + if err := updateGenLockWithPatch(getTargetOutput(target), targetPatches[targetName], customCodeCommitHash); err != nil { + return fmt.Errorf("failed to update gen.lock: %w", err) } - logger.Info("Successfully registered custom code changes. Code changes will be applied on top of your code after generation.") + // Step 12: Commit just gen.lock with new patch + if err := commitGenLock(getTargetOutput(target)); err != nil { + return fmt.Errorf("failed to commit gen.lock: %w", err) + } return nil } @@ -228,82 +246,9 @@ func ShowLatestCommitHash(ctx context.Context) error { // ResolveCustomCodeConflicts enters conflict resolution mode to help users resolve conflicts // that occurred during generation when applying custom code patches func ResolveCustomCodeConflicts(ctx context.Context) error { - logger := log.From(ctx).With(zap.String("method", "ResolveCustomCodeConflicts")) - - // Load workflow to get targets - wf, _, err := utils.GetWorkflowAndDir() - if err != nil { - return fmt.Errorf("could not find workflow file: %w", err) - } hadConflicts := false - for targetName, target := range wf.Targets { - outDir := getTargetOutput(target) - - // Check if patch exists in gen.lock - cfg, err := config.Load(outDir) - if err != nil { - return fmt.Errorf("failed to load config for target %s: %w", targetName, err) - } - - customCodePatch, exists := cfg.LockFile.Management.AdditionalProperties["customCodePatch"] - if !exists { - logger.Info(fmt.Sprintf("No custom code patch for target %s, skipping", targetName)) - continue - } - - patchStr, ok := customCodePatch.(string) - if !ok || patchStr == "" { - continue - } - - logger.Info(fmt.Sprintf("Resolving conflicts for target %s", targetName)) - - // Step 1: Undo patch application - extract clean new generation from "ours" side - cmd := exec.Command("git", "checkout", "--ours", "--", outDir) - if output, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("failed to checkout ours: %w\nOutput: %s", err, string(output)) - } - - // Step 2: Add other changes to worktree (stage the clean generation files) - if err := stageAllChanges(outDir); err != nil { - return fmt.Errorf("failed to stage changes for target %s: %w", targetName, err) - } - - // Step 3: Commit as 'clean generation' - cmd = exec.Command("git", "commit", "-m", "clean generation (conflict resolution)", "--allow-empty") - if output, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("failed to commit clean generation: %w\nOutput: %s", err, string(output)) - } - - // Step 4: Apply old patch (will create conflicts) - patchFile := filepath.Join(outDir, ".speakeasy", "resolve_patch.patch") - if err := os.WriteFile(patchFile, []byte(patchStr), 0644); err != nil { - return fmt.Errorf("failed to write patch file: %w", err) - } - defer os.Remove(patchFile) - - cmd = exec.Command("git", "apply", "-3", patchFile) - _, _ = cmd.CombinedOutput() // Expect failure with conflicts - - // Step 5: Check if conflicts exist - cmd = exec.Command("git", "diff", "--name-only", "--diff-filter=U") - conflictOutput, err := cmd.Output() - if err != nil { - return fmt.Errorf("failed to check for conflicts: %w", err) - } - - conflictFiles := strings.Split(strings.TrimSpace(string(conflictOutput)), "\n") - if len(conflictFiles) > 0 && conflictFiles[0] != "" { - hadConflicts = true - fmt.Printf("\nConflicts detected in target '%s':\n", targetName) - for _, file := range conflictFiles { - fmt.Printf(" - %s\n", file) - } - } - } - if hadConflicts { fmt.Println("\nPlease:") fmt.Println(" 1. Resolve conflicts in your editor") @@ -317,80 +262,92 @@ func ResolveCustomCodeConflicts(ctx context.Context) error { return nil } -// completeConflictResolution completes the conflict resolution process after user has resolved conflicts -func completeConflictResolution(ctx context.Context, wf *workflow.Workflow) error { - logger := log.From(ctx).With(zap.String("method", "completeConflictResolution")) - - logger.Info("Completing conflict resolution registration") +// ensureAllConflictsResolvedAndStaged checks that all git conflicts are resolved and staged +func ensureAllConflictsResolvedAndStaged() error { + // Check for unmerged paths (conflicts) + statusCmd := exec.Command("git", "status", "--porcelain") + output, err := statusCmd.Output() + if err != nil { + return fmt.Errorf("failed to check git status: %w", err) + } - for targetName, target := range wf.Targets { - outDir := getTargetOutput(target) + lines := strings.Split(strings.TrimSpace(string(output)), "\n") + var unresolvedConflicts []string + var unstagedFiles []string - // Check for staged changes - cmd := exec.Command("git", "diff", "--cached", "--name-only", outDir) - output, err := cmd.Output() - if err != nil { - return fmt.Errorf("failed to check staged changes: %w", err) + for _, line := range lines { + if len(line) < 3 { + continue } - if strings.TrimSpace(string(output)) == "" { - logger.Info(fmt.Sprintf("No staged changes for target %s, skipping", targetName)) - continue + statusCode := line[:2] + filename := line[3:] + + // Check for unmerged paths (conflicts) + // U = unmerged, AA = both added, UU = both modified, etc. + if strings.ContainsAny(statusCode, "U") || statusCode == "AA" || statusCode == "DD" { + unresolvedConflicts = append(unresolvedConflicts, filename) } - // Check for unresolved conflicts - cmd = exec.Command("git", "diff", "--cached", "--check") - if output, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("unresolved conflicts remain:\n%s\nPlease resolve and stage all files", string(output)) + // Check for unstaged modifications + if len(statusCode) >= 2 && statusCode[1] == 'M' { + unstagedFiles = append(unstagedFiles, filename) } + } - // Capture resolved patch from staged changes - otherTargetOutputs := getOtherTargetOutputs(wf, targetName) - args := []string{"diff", "--cached", outDir} + if len(unresolvedConflicts) > 0 { + return fmt.Errorf("unresolved git conflicts found in files: %s. Please resolve conflicts and stage the files", strings.Join(unresolvedConflicts, ", ")) + } - // Filter excludePaths to only include children of outDir - cleanOutDir := filepath.Clean(outDir) - for _, excludePath := range otherTargetOutputs { - cleanExcludePath := filepath.Clean(excludePath) + if len(unstagedFiles) > 0 { + return fmt.Errorf("unstaged changes found in files: %s. Please stage all resolved files with 'git add'", strings.Join(unstagedFiles, ", ")) + } - // Check if excludePath is a child of outDir (or equal to outDir) - rel, err := filepath.Rel(cleanOutDir, cleanExcludePath) - if err == nil && !strings.HasPrefix(rel, "..") && rel != "." { - args = append(args, ":^"+excludePath) - } - } + // Check for conflict markers in staged changes + diffCmd := exec.Command("git", "diff", "--cached") + diffOutput, err := diffCmd.Output() + if err != nil { + return fmt.Errorf("failed to get staged changes: %w", err) + } - cmd = exec.Command("git", args...) - resolvedPatch, err := cmd.Output() - if err != nil { - return fmt.Errorf("failed to capture resolved patch: %w", err) + diffContent := string(diffOutput) + conflictMarkers := []string{"<<<<<<<", "=======", ">>>>>>>"} + + for _, marker := range conflictMarkers { + if strings.Contains(diffContent, marker) { + return fmt.Errorf("unresolved conflict markers found in staged changes. Please resolve all conflicts (remove %s markers) before continuing", marker) } + } - // Commit resolved custom code - commitHash, err := commitCustomCodeChanges() - if err != nil { - return err - } + return nil +} - logger.Info("Created commit with resolved custom code", zap.String("commit_hash", commitHash)) +// completeConflictResolution completes the conflict resolution process after user has resolved conflicts +func completeConflictResolution(ctx context.Context, wf *workflow.Workflow) error { + logger := log.From(ctx).With(zap.String("method", "completeConflictResolution")) - // Update gen.lock - if err := updateGenLockWithPatch(outDir, string(resolvedPatch), commitHash); err != nil { - return err - } + // Ensure all conflicts are resolved and staged before continuing + if err := ensureAllConflictsResolvedAndStaged(); err != nil { + return err + } - // Compile/lint - logger.Info("Verifying resolved custom code...") - if err := compileAndLintSDK(ctx, target); err != nil { - return fmt.Errorf("resolved custom code failed compilation: %w", err) - } + // Record the current git hash at the very beginning for error recovery + originalHash, err := getCurrentGitHash() + if err != nil { + return fmt.Errorf("failed to get current git hash: %w", err) + } - // Commit gen.lock - if err := commitGenLock(outDir); err != nil { + logger.Info("Completing conflict resolution registration") + + targetPatches, err := getPatchesPerTarget(wf) + if err != nil { + return err + } + for targetName, target := range wf.Targets { + err = updateCustomPatchAndUpdateGenLock(ctx, wf, originalHash, targetPatches, target, targetName) + if err != nil { return err } - - logger.Info(fmt.Sprintf("Successfully registered resolved patch for target %s", targetName)) } fmt.Println("\nSuccessfully registered updated custom code patches.") From ccd4429b30964701775ea181eb58c47d9d98af37 Mon Sep 17 00:00:00 2001 From: Ian Bentley Date: Fri, 10 Oct 2025 14:54:06 -0400 Subject: [PATCH 28/42] reintroduce accidental deletion --- .../registercustomcode/registercustomcode.go | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/internal/registercustomcode/registercustomcode.go b/internal/registercustomcode/registercustomcode.go index b1a6a810b..a633cb631 100644 --- a/internal/registercustomcode/registercustomcode.go +++ b/internal/registercustomcode/registercustomcode.go @@ -246,9 +246,81 @@ func ShowLatestCommitHash(ctx context.Context) error { // ResolveCustomCodeConflicts enters conflict resolution mode to help users resolve conflicts // that occurred during generation when applying custom code patches func ResolveCustomCodeConflicts(ctx context.Context) error { + logger := log.From(ctx).With(zap.String("method", "ResolveCustomCodeConflicts")) + wf, _, err := utils.GetWorkflowAndDir() + if err != nil { + return err + } + hadConflicts := false + for targetName, target := range wf.Targets { + outDir := getTargetOutput(target) + + // Check if patch exists in gen.lock + cfg, err := config.Load(outDir) + if err != nil { + return fmt.Errorf("failed to load config for target %s: %w", targetName, err) + } + + customCodePatch, exists := cfg.LockFile.Management.AdditionalProperties["customCodePatch"] + if !exists { + logger.Info(fmt.Sprintf("No custom code patch for target %s, skipping", targetName)) + continue + } + + patchStr, ok := customCodePatch.(string) + if !ok || patchStr == "" { + continue + } + + logger.Info(fmt.Sprintf("Resolving conflicts for target %s", targetName)) + + // Step 1: Undo patch application - extract clean new generation from "ours" side + cmd := exec.Command("git", "checkout", "--ours", "--", outDir) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to checkout ours: %w\nOutput: %s", err, string(output)) + } + + // Step 2: Add other changes to worktree (stage the clean generation files) + if err := stageAllChanges(outDir); err != nil { + return fmt.Errorf("failed to stage changes for target %s: %w", targetName, err) + } + + // Step 3: Commit as 'clean generation' + cmd = exec.Command("git", "commit", "-m", "clean generation (conflict resolution)", "--allow-empty") + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to commit clean generation: %w\nOutput: %s", err, string(output)) + } + + // Step 4: Apply old patch (will create conflicts) + patchFile := filepath.Join(outDir, ".speakeasy", "resolve_patch.patch") + if err := os.WriteFile(patchFile, []byte(patchStr), 0644); err != nil { + return fmt.Errorf("failed to write patch file: %w", err) + } + defer os.Remove(patchFile) + + cmd = exec.Command("git", "apply", "-3", patchFile) + _, _ = cmd.CombinedOutput() // Expect failure with conflicts + + // Step 5: Check if conflicts exist + cmd = exec.Command("git", "diff", "--name-only", "--diff-filter=U") + conflictOutput, err := cmd.Output() + if err != nil { + return fmt.Errorf("failed to check for conflicts: %w", err) + } + + conflictFiles := strings.Split(strings.TrimSpace(string(conflictOutput)), "\n") + if len(conflictFiles) > 0 && conflictFiles[0] != "" { + hadConflicts = true + fmt.Printf("\nConflicts detected in target '%s':\n", targetName) + for _, file := range conflictFiles { + fmt.Printf(" - %s\n", file) + } + } + } + if hadConflicts { fmt.Println("\nPlease:") fmt.Println(" 1. Resolve conflicts in your editor") From 6406863814bc32ed3bc9cbe2475ace7dc9bc454a Mon Sep 17 00:00:00 2001 From: Ivan Gorshkov Date: Thu, 23 Oct 2025 14:11:34 +0100 Subject: [PATCH 29/42] Saving patches to .diff file instead of gen.lock --- .../registercustomcode/registercustomcode.go | 178 ++++++++++++------ 1 file changed, 118 insertions(+), 60 deletions(-) diff --git a/internal/registercustomcode/registercustomcode.go b/internal/registercustomcode/registercustomcode.go index a633cb631..2541a96bf 100644 --- a/internal/registercustomcode/registercustomcode.go +++ b/internal/registercustomcode/registercustomcode.go @@ -169,39 +169,38 @@ func updateCustomPatchAndUpdateGenLock(ctx context.Context, wf *workflow.Workflo } logger.Info("Created commit with custom code changes", zap.String("commit_hash", customCodeCommitHash)) - // Step 11: Update gen.lock with full combined patch and commit hash - if err := updateGenLockWithPatch(getTargetOutput(target), targetPatches[targetName], customCodeCommitHash); err != nil { - return fmt.Errorf("failed to update gen.lock: %w", err) + // Step 11: Save custom code patch and update gen.lock with commit hash + if err := saveCustomCodePatch(getTargetOutput(target), targetPatches[targetName], customCodeCommitHash); err != nil { + return fmt.Errorf("failed to save custom code patch: %w", err) } - // Step 12: Commit just gen.lock with new patch - if err := commitGenLock(getTargetOutput(target)); err != nil { - return fmt.Errorf("failed to commit gen.lock: %w", err) + // Step 12: Commit gen.lock and patch file + if err := commitCustomCodeRegistration(getTargetOutput(target)); err != nil { + return fmt.Errorf("failed to commit custom code registration: %w", err) } return nil } -// ShowCustomCodePatch displays the custom code patch stored in the gen.lock file +// ShowCustomCodePatch displays the custom code patch stored in the patch file func ShowCustomCodePatch(ctx context.Context, target workflow.Target) error { logger := log.From(ctx).With(zap.String("method", "ShowCustomCodePatch")) outDir := getTargetOutput(target) - cfg, err := config.Load(outDir) + // Read patch from file + patchStr, err := readPatchFile(outDir) if err != nil { - return fmt.Errorf("failed to load config: %w", err) + return fmt.Errorf("failed to read patch file: %w", err) } - - customCodePatch, exists := cfg.LockFile.Management.AdditionalProperties["customCodePatch"] - if !exists { + if patchStr == "" { logger.Warn("No existing custom code patch found") return nil } - patchStr, ok := customCodePatch.(string) - if !ok || patchStr == "" { - logger.Warn("No existing custom code patch found") - return nil + // Load config to get commit hash + cfg, err := config.Load(outDir) + if err != nil { + return fmt.Errorf("failed to load config: %w", err) } // Check if there's a commit hash associated with this patch @@ -258,23 +257,16 @@ func ResolveCustomCodeConflicts(ctx context.Context) error { for targetName, target := range wf.Targets { outDir := getTargetOutput(target) - // Check if patch exists in gen.lock - cfg, err := config.Load(outDir) + // Check if patch file exists + patchStr, err := readPatchFile(outDir) if err != nil { - return fmt.Errorf("failed to load config for target %s: %w", targetName, err) + return fmt.Errorf("failed to read patch file for target %s: %w", targetName, err) } - - customCodePatch, exists := cfg.LockFile.Management.AdditionalProperties["customCodePatch"] - if !exists { + if patchStr == "" { logger.Info(fmt.Sprintf("No custom code patch for target %s, skipping", targetName)) continue } - patchStr, ok := customCodePatch.(string) - if !ok || patchStr == "" { - continue - } - logger.Info(fmt.Sprintf("Resolving conflicts for target %s", targetName)) // Step 1: Undo patch application - extract clean new generation from "ours" side @@ -734,18 +726,21 @@ func commitCustomCodeChanges() (string, error) { return commitHash, nil } -func commitGenLock(outDir string) error { - // Add only the gen.lock file - cmd := exec.Command("git", "add", fmt.Sprintf("%v/.speakeasy/gen.lock", outDir)) +func commitCustomCodeRegistration(outDir string) error { + // Add gen.lock and patch file + genLockPath := fmt.Sprintf("%v/.speakeasy/gen.lock", outDir) + patchPath := getPatchFilePath(outDir) + + cmd := exec.Command("git", "add", genLockPath, patchPath) if err := cmd.Run(); err != nil { - return fmt.Errorf("failed to add gen.lock: %w", err) + return fmt.Errorf("failed to add gen.lock and patch file: %w", err) } // Commit with a descriptive message commitMsg := "Register custom code changes" cmd = exec.Command("git", "commit", "-m", commitMsg) if output, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("failed to commit gen.lock: %w\nOutput: %s", err, string(output)) + return fmt.Errorf("failed to commit custom code registration: %w\nOutput: %s", err, string(output)) } return nil @@ -753,30 +748,27 @@ func commitGenLock(outDir string) error { func ApplyCustomCodePatch(ctx context.Context, target workflow.Target) error { outDir := getTargetOutput(target) - // Load the current configuration and lock file - cfg, err := config.Load(outDir) + + // Check if patch file exists + if !patchFileExists(outDir) { + return nil // No patch to apply + } + + // Read patch content to verify it's not empty + patchContent, err := readPatchFile(outDir) if err != nil { - return fmt.Errorf("failed to load config: %w", err) + return fmt.Errorf("failed to read patch file: %w", err) + } + if patchContent == "" { + return nil // Empty patch, nothing to apply } - // Check if there's a custom code patch in the management section - if customCodePatch, exists := cfg.LockFile.Management.AdditionalProperties["customCodePatch"]; exists { - if patchStr, ok := customCodePatch.(string); ok && patchStr != "" { - // Create a temporary patch file - patchFile := filepath.Join(outDir, ".speakeasy", "temp_patch.patch") - if err := os.WriteFile(patchFile, []byte(patchStr), 0644); err != nil { - return fmt.Errorf("failed to write patch file: %w", err) - } - // defer os.Remove(patchFile) - - // Apply the patch with 3-way merge - args := []string{"apply", "--3way", "--index"} - args = append(args, patchFile) - cmd := exec.Command("git", args...) - if output, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("failed to apply patch: %w\nOutput: %s", err, string(output)) - } - } + // Apply the patch directly from file with 3-way merge + patchFile := getPatchFilePath(outDir) + args := []string{"apply", "--3way", "--index", patchFile} + cmd := exec.Command("git", args...) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to apply patch: %w\nOutput: %s", err, string(output)) } return nil @@ -788,7 +780,7 @@ func applyNewPatch(customCodeDiff string) error { } // Create a temporary patch file - patchFile := ".speakeasy/temp_new_patch.patch" + patchFile := ".speakeasy/temp_new_patch.diff" if err := os.WriteFile(patchFile, []byte(customCodeDiff), 0644); err != nil { return fmt.Errorf("failed to write new patch file: %w", err) } @@ -803,7 +795,69 @@ func applyNewPatch(customCodeDiff string) error { return nil } -func updateGenLockWithPatch(outDir, patchset, commitHash string) error { +// Patch file helper functions + +// getPatchFilePath returns the standardized path for the custom code patch file +func getPatchFilePath(outDir string) string { + return filepath.Join(outDir, ".speakeasy", "patches", "custom-code.diff") +} + +// ensurePatchesDirectoryExists creates the patches directory if it doesn't exist +func ensurePatchesDirectoryExists(outDir string) error { + patchesDir := filepath.Join(outDir, ".speakeasy", "patches") + if err := os.MkdirAll(patchesDir, 0755); err != nil { + return fmt.Errorf("failed to create patches directory: %w", err) + } + return nil +} + +// writePatchFile writes the patch content to the custom code patch file +func writePatchFile(outDir, patchContent string) error { + if err := ensurePatchesDirectoryExists(outDir); err != nil { + return err + } + + patchPath := getPatchFilePath(outDir) + if err := os.WriteFile(patchPath, []byte(patchContent), 0644); err != nil { + return fmt.Errorf("failed to write patch file: %w", err) + } + + return nil +} + +// readPatchFile reads the patch content from the custom code patch file +// Returns empty string if file doesn't exist (not an error) +func readPatchFile(outDir string) (string, error) { + patchPath := getPatchFilePath(outDir) + + content, err := os.ReadFile(patchPath) + if err != nil { + if os.IsNotExist(err) { + return "", nil // No patch found + } + return "", fmt.Errorf("failed to read patch file: %w", err) + } + + return string(content), nil +} + +// deletePatchFile deletes the custom code patch file +func deletePatchFile(outDir string) error { + patchPath := getPatchFilePath(outDir) + if err := os.Remove(patchPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to delete patch file: %w", err) + } + return nil +} + +// patchFileExists checks if the custom code patch file exists +func patchFileExists(outDir string) bool { + patchPath := getPatchFilePath(outDir) + _, err := os.Stat(patchPath) + return err == nil +} + +func saveCustomCodePatch(outDir, patchset, commitHash string) error { // Load the current configuration and lock file cfg, err := config.Load(outDir) if err != nil { @@ -815,16 +869,20 @@ func updateGenLockWithPatch(outDir, patchset, commitHash string) error { cfg.LockFile.Management.AdditionalProperties = make(map[string]any) } - // Store single patch (replaces any existing patch) + // Write patch to file if patchset != "" { - cfg.LockFile.Management.AdditionalProperties["customCodePatch"] = patchset - // Store the commit hash that contains the custom code application + if err := writePatchFile(outDir, patchset); err != nil { + return fmt.Errorf("failed to write patch file: %w", err) + } + // Store the commit hash in gen.lock if commitHash != "" { cfg.LockFile.Management.AdditionalProperties["customCodeCommitHash"] = commitHash } } else { - // Remove the patch and commit hash if empty - delete(cfg.LockFile.Management.AdditionalProperties, "customCodePatch") + // Remove patch file and commit hash if empty + if err := deletePatchFile(outDir); err != nil { + return fmt.Errorf("failed to delete patch file: %w", err) + } delete(cfg.LockFile.Management.AdditionalProperties, "customCodeCommitHash") } From 51774469661c4c9d94c9cb2ca048a3c9031e930f Mon Sep 17 00:00:00 2001 From: Ivan Gorshkov Date: Thu, 23 Oct 2025 17:53:18 +0100 Subject: [PATCH 30/42] Integration test for perfect custom code scenario --- integration/customcode_test.go | 334 ++++++++++ integration/resources/customcodespec.yaml | 727 ++++++++++++++++++++++ 2 files changed, 1061 insertions(+) create mode 100644 integration/customcode_test.go create mode 100644 integration/resources/customcodespec.yaml diff --git a/integration/customcode_test.go b/integration/customcode_test.go new file mode 100644 index 000000000..63ff92be3 --- /dev/null +++ b/integration/customcode_test.go @@ -0,0 +1,334 @@ +package integration_tests + +import ( + "bufio" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/speakeasy-api/sdk-gen-config/workflow" + "github.com/stretchr/testify/require" +) + +func TestCustomCodeWorkflows(t *testing.T) { + t.Parallel() + + // Build the speakeasy binary once for all tests + speakeasyBinary := buildSpeakeasyBinary(t) + + tests := []struct { + name string + targetTypes []string + inputDoc string + withCodeSamples bool + }{ + { + name: "generation with local document", + targetTypes: []string{ + "go", + }, + inputDoc: "customcodespec.yaml", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + // Use custom-code-test directory to avoid gitignore issues from speakeasy repo + temp := setupCustomCodeTestDir(t) + + // Create workflow file and associated resources + workflowFile := &workflow.Workflow{ + Version: workflow.WorkflowVersion, + Sources: make(map[string]workflow.Source), + Targets: make(map[string]workflow.Target), + } + workflowFile.Sources["first-source"] = workflow.Source{ + Inputs: []workflow.Document{ + { + Location: workflow.LocationString(tt.inputDoc), + }, + }, + } + + for i := range tt.targetTypes { + outdir := "go" + target := workflow.Target{ + Target: tt.targetTypes[i], + Source: "first-source", + Output: &outdir, + } + if tt.withCodeSamples { + target.CodeSamples = &workflow.CodeSamples{ + Output: "codeSamples.yaml", + } + } + workflowFile.Targets[fmt.Sprintf("%d-target", i)] = target + } + + if isLocalFileReference(tt.inputDoc) { + err := copyFile("resources/customcodespec.yaml", fmt.Sprintf("%s/%s", temp, tt.inputDoc)) + require.NoError(t, err) + } + + err := os.MkdirAll(filepath.Join(temp, ".speakeasy"), 0o755) + require.NoError(t, err) + err = workflow.Save(temp, workflowFile) + require.NoError(t, err) + + // Run speakeasy run command using the built binary + runCmd := exec.Command(speakeasyBinary, "run", "-t", "all", "--pinned", "--skip-compile") + runCmd.Dir = temp + runOutput, runErr := runCmd.CombinedOutput() + require.NoError(t, runErr, "speakeasy run should succeed: %s", string(runOutput)) + + // SDK directory where files are generated + sdkDir := filepath.Join(temp, "go") + + // Run go mod tidy to ensure go.sum is properly populated + // This is necessary because we used --skip-compile above + goModTidyCmd := exec.Command("go", "mod", "tidy") + goModTidyCmd.Dir = sdkDir + output, err := goModTidyCmd.CombinedOutput() + require.NoError(t, err, "Failed to run go mod tidy: %s", string(output)) + + if tt.withCodeSamples { + codeSamplesPath := filepath.Join(sdkDir, "codeSamples.yaml") + content, err := os.ReadFile(codeSamplesPath) + require.NoError(t, err, "No readable file %s exists", codeSamplesPath) + + // Check if codeSamples file is not empty and contains expected content + require.NotEmpty(t, content, "codeSamples.yaml should not be empty") + } + + // SDK is generated in go subdirectory + for _, targetType := range tt.targetTypes { + checkForExpectedFiles(t, sdkDir, expectedFilesByLanguage(targetType)) + } + + // Initialize git repository in the go directory + initGitRepo(t, sdkDir) + + // Copy workflow.yaml and spec to SDK directory before committing + copyWorkflowToSDK(t, temp, sdkDir) + + // Commit all generated files with "clean generation" message + gitCommit(t, sdkDir, "clean generation") + + // Verify the commit was created with the correct message + verifyGitCommit(t, sdkDir, "clean generation") + + // Modify httpmetadata.go to add custom code + httpMetadataPath := filepath.Join(sdkDir, "models", "components", "httpmetadata.go") + modifyLineInFile(t, httpMetadataPath, 10, "\t// custom code") + + // Run customcode command from SDK directory using the built binary + customCodeCmd := exec.Command(speakeasyBinary, "customcode", "--output", "console") + customCodeCmd.Dir = sdkDir + customCodeOutput, customCodeErr := customCodeCmd.CombinedOutput() + require.NoError(t, customCodeErr, "customcode command should succeed: %s", string(customCodeOutput)) + + // Verify patches directory was created in SDK directory + patchesDir := filepath.Join(sdkDir, ".speakeasy", "patches") + _, err = os.Stat(patchesDir) + require.NoError(t, err, "patches directory should exist at %s", patchesDir) + + // Verify patch file was created + patchFile := filepath.Join(patchesDir, "custom-code.diff") + _, err = os.Stat(patchFile) + require.NoError(t, err, "patch file should exist at %s", patchFile) + + // Run speakeasy run again from the SDK directory to regenerate and apply patches + regenCmd := exec.Command(speakeasyBinary, "run", "-t", "all", "--pinned", "--skip-compile") + regenCmd.Dir = sdkDir + regenOutput, regenErr := regenCmd.CombinedOutput() + require.NoError(t, regenErr, "speakeasy run should succeed on regeneration: %s", string(regenOutput)) + + // Verify the custom code is still present after regeneration + httpMetadataContent, err := os.ReadFile(httpMetadataPath) + require.NoError(t, err, "Failed to read httpmetadata.go after regeneration") + require.Contains(t, string(httpMetadataContent), "// custom code", "Custom code comment should still be present after regeneration") + }) + } +} + +// initGitRepo initializes a git repository in the specified directory +func initGitRepo(t *testing.T, dir string) { + t.Helper() + + // Initialize git repo + cmd := exec.Command("git", "init") + cmd.Dir = dir + output, err := cmd.CombinedOutput() + require.NoError(t, err, "Failed to initialize git repo: %s", string(output)) + + // Configure git user for commits + cmd = exec.Command("git", "config", "user.email", "test@example.com") + cmd.Dir = dir + output, err = cmd.CombinedOutput() + require.NoError(t, err, "Failed to configure git user.email: %s", string(output)) + + cmd = exec.Command("git", "config", "user.name", "Test User") + cmd.Dir = dir + output, err = cmd.CombinedOutput() + require.NoError(t, err, "Failed to configure git user.name: %s", string(output)) +} + +// gitCommit creates a git commit with all changes in the specified directory +func gitCommit(t *testing.T, dir, message string) { + t.Helper() + + // Add all files + cmd := exec.Command("git", "add", ".") + cmd.Dir = dir + output, err := cmd.CombinedOutput() + require.NoError(t, err, "Failed to git add: %s", string(output)) + + // Commit with message + cmd = exec.Command("git", "commit", "-m", message) + cmd.Dir = dir + output, err = cmd.CombinedOutput() + require.NoError(t, err, "Failed to git commit: %s", string(output)) +} + +// verifyGitCommit verifies that a git commit exists with the expected message +func verifyGitCommit(t *testing.T, dir, expectedMessage string) { + t.Helper() + + // Check that .git directory exists + gitDir := filepath.Join(dir, ".git") + _, err := os.Stat(gitDir) + require.NoError(t, err, ".git directory should exist") + + // Get the latest commit message + cmd := exec.Command("git", "log", "-1", "--pretty=%B") + cmd.Dir = dir + output, err := cmd.CombinedOutput() + require.NoError(t, err, "Failed to get git log: %s", string(output)) + + // Verify commit message matches + commitMessage := strings.TrimSpace(string(output)) + require.Equal(t, expectedMessage, commitMessage, "Commit message should match expected message") +} + +// modifyLineInFile modifies a specific line in a file (1-indexed line number) +func modifyLineInFile(t *testing.T, filePath string, lineNumber int, newContent string) { + t.Helper() + + // Read the file + file, err := os.Open(filePath) + require.NoError(t, err, "Failed to open file: %s", filePath) + defer file.Close() + + var lines []string + scanner := bufio.NewScanner(file) + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + require.NoError(t, scanner.Err(), "Failed to read file: %s", filePath) + + // Modify the specific line (convert 1-indexed to 0-indexed) + require.Less(t, lineNumber, len(lines)+1, "Line number %d is out of range (file has %d lines)", lineNumber, len(lines)) + lines[lineNumber-1] = newContent + + // Write back to the file + file, err = os.Create(filePath) + require.NoError(t, err, "Failed to open file for writing: %s", filePath) + defer file.Close() + + writer := bufio.NewWriter(file) + for _, line := range lines { + _, err := writer.WriteString(line + "\n") + require.NoError(t, err, "Failed to write line to file") + } + require.NoError(t, writer.Flush(), "Failed to flush writer") +} + +// buildSpeakeasyBinary builds the speakeasy binary and returns the path to it +func buildSpeakeasyBinary(t *testing.T) string { + t.Helper() + + _, filename, _, _ := runtime.Caller(0) + baseFolder := filepath.Join(filepath.Dir(filename), "..") + binaryPath := filepath.Join(baseFolder, "speakeasy-test-binary") + + // Build the binary + cmd := exec.Command("go", "build", "-o", binaryPath, "./main.go") + cmd.Dir = baseFolder + output, err := cmd.CombinedOutput() + require.NoError(t, err, "Failed to build speakeasy binary: %s", string(output)) + + // Clean up the binary when test completes + t.Cleanup(func() { + os.Remove(binaryPath) + }) + + return binaryPath +} + +// copyWorkflowToSDK copies the workflow.yaml and spec files from the workspace to the SDK directory +func copyWorkflowToSDK(t *testing.T, workspaceDir, sdkDir string) { + t.Helper() + + // Copy workflow.yaml file, removing output paths since we're running from SDK directory + srcWorkflowPath := filepath.Join(workspaceDir, ".speakeasy", "workflow.yaml") + dstWorkflowPath := filepath.Join(sdkDir, ".speakeasy", "workflow.yaml") + workflowContent, err := os.ReadFile(srcWorkflowPath) + require.NoError(t, err, "Failed to read workflow.yaml") + + // Remove "output: go" lines from the workflow content + workflowStr := string(workflowContent) + workflowStr = strings.ReplaceAll(workflowStr, "\n output: go", "") + + err = os.WriteFile(dstWorkflowPath, []byte(workflowStr), 0o644) + require.NoError(t, err, "Failed to write workflow.yaml") + + // Read the workflow to find spec files to copy + workflowFile, _, err := workflow.Load(workspaceDir) + require.NoError(t, err, "Failed to load workflow.yaml") + + // Copy local spec files to SDK directory + for _, source := range workflowFile.Sources { + for i := range source.Inputs { + if isLocalFileReference(string(source.Inputs[i].Location)) { + specPath := string(source.Inputs[i].Location) + + // Copy the spec file to SDK directory + srcSpecPath := filepath.Join(workspaceDir, specPath) + dstSpecPath := filepath.Join(sdkDir, specPath) + + specContent, err := os.ReadFile(srcSpecPath) + require.NoError(t, err, "Failed to read spec file: %s", srcSpecPath) + + err = os.WriteFile(dstSpecPath, specContent, 0o644) + require.NoError(t, err, "Failed to write spec file to SDK directory: %s", dstSpecPath) + } + } + } +} + +// setupCustomCodeTestDir creates a test directory in custom-code-test/speakeasy_tests +func setupCustomCodeTestDir(t *testing.T) string { + t.Helper() + + baseDir := "/Users/ivangorshkov/speakeasy/repos/custom-code-test/speakeasy_tests" + + // Create base directory if it doesn't exist + err := os.MkdirAll(baseDir, 0o755) + require.NoError(t, err, "Failed to create base directory") + + // Create unique test directory + testDir := filepath.Join(baseDir, fmt.Sprintf("test-%d", os.Getpid())) + err = os.MkdirAll(testDir, 0o755) + require.NoError(t, err, "Failed to create test directory") + + // Clean up after test + t.Cleanup(func() { + os.RemoveAll(testDir) + }) + + return testDir +} diff --git a/integration/resources/customcodespec.yaml b/integration/resources/customcodespec.yaml new file mode 100644 index 000000000..5b9d6d118 --- /dev/null +++ b/integration/resources/customcodespec.yaml @@ -0,0 +1,727 @@ +openapi: 3.1.0 +info: + title: Petstore - OpenAPI 3.1 + description: |- + This is a sample Pet Store Server based on the OpenAPI 3.1 specification. + + Some useful links: + - [OpenAPI Reference](https://www.speakeasy.com/openapi) + - [The Pet Store repository](https://github.com/swagger-api/swagger-petstore) + - [The source API definition for the Pet Store](https://github.com/swagger-api/swagger-petstore/blob/master/src/main/resources/openapi.yaml) + termsOfService: http://swagger.io/terms/ + contact: + email: support@speakeasy.com + license: + name: Apache 2.0 + url: http://www.apache.org/licenses/LICENSE-2.0.html + version: 1.0.0 +externalDocs: + description: Find out more about Swagger + url: http://swagger.io +security: + - api_key: [] +servers: + - url: https://{environment}.petstore.io + description: A per-environment API. + variables: + environment: + description: The environment name. Defaults to the production environment. + default: prod + enum: + - prod + - staging + - dev +tags: + - name: pet + description: Everything about your Pets + externalDocs: + description: Find out more + url: http://swagger.io + - name: store + description: Access to Petstore orders + externalDocs: + description: Find out more about our store + url: http://swagger.io + - name: user + description: Operations about user +paths: + "/pet": + put: + tags: + - pet + summary: Update an existing pet + description: Update an existing pet by Id + operationId: updatePet + requestBody: + description: Update an existent pet in the store + content: + application/json: + schema: + "$ref": "#/components/schemas/Pet" + required: true + responses: + '200': + description: Successful operation + content: + application/json: + schema: + "$ref": "#/components/schemas/Pet" + '400': + $ref: '#/components/responses/InvalidInput' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + + post: + tags: + - pet + summary: Add a new pet to the store + description: Add a new pet to the store + operationId: addPet + requestBody: + description: Create a new pet in the store + content: + application/json: + schema: + "$ref": "#/components/schemas/Pet" + required: true + responses: + '200': + description: Successful operation + content: + application/json: + schema: + "$ref": "#/components/schemas/Pet" + '405': + description: Invalid input + + "/pet/findByStatus": + get: + tags: + - pet + summary: Finds Pets by status + description: Multiple status values can be provided with comma separated strings + operationId: findPetsByStatus + parameters: + - name: status + in: query + description: Status values that need to be considered for filter + required: false + explode: true + schema: + type: string + default: available + enum: + - available + - pending + - sold + responses: + '200': + description: successful operation + content: + application/json: + schema: + type: array + items: + "$ref": "#/components/schemas/Pet" + '400': + $ref: '#/components/responses/InvalidInput' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + + "/pet/findByTags": + get: + tags: + - pet + summary: Finds Pets by tags + description: Multiple tags can be provided with comma separated strings. Use + tag1, tag2, tag3 for testing. + operationId: findPetsByTags + parameters: + - name: tags + in: query + description: Tags to filter by + required: false + explode: true + schema: + type: array + items: + type: string + responses: + '200': + description: successful operation + content: + application/json: + schema: + type: array + items: + "$ref": "#/components/schemas/Pet" + '400': + $ref: '#/components/responses/InvalidInput' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + + "/pet/{petId}": + get: + tags: + - pet + summary: Find pet by ID + description: Returns a single pet + operationId: getPetById + parameters: + - name: petId + in: path + description: ID of pet to return + required: true + schema: + type: integer + format: int64 + responses: + '200': + description: successful operation + content: + application/json: + schema: + "$ref": "#/components/schemas/Pet" + '400': + $ref: '#/components/responses/InvalidInput' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + + delete: + tags: + - pet + summary: Deletes a pet + description: '' + operationId: deletePet + parameters: + - name: api_key + in: header + description: '' + required: false + schema: + type: string + - name: petId + in: path + description: Pet id to delete + required: true + schema: + type: integer + format: int64 + responses: + '200': + description: Pet deleted + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + '400': + $ref: '#/components/responses/InvalidInput' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + + "/pet/{petId}/uploadImage": + post: + tags: + - pet + summary: uploads an image + description: '' + operationId: uploadFile + parameters: + - name: petId + in: path + description: ID of pet to update + required: true + schema: + type: integer + format: int64 + - name: additionalMetadata + in: query + description: Additional Metadata + required: false + schema: + type: string + requestBody: + content: + application/octet-stream: + schema: + type: string + format: binary + responses: + '200': + description: successful operation + content: + application/json: + schema: + "$ref": "#/components/schemas/ApiResponse" + + "/store/inventory": + get: + tags: + - store + summary: Returns pet inventories by status + description: Returns a map of status codes to quantities + operationId: getInventory + responses: + '200': + description: successful operation + content: + application/json: + schema: + type: object + additionalProperties: + type: integer + format: int32 + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + + "/store/order": + post: + tags: + - store + summary: Place an order for a pet + description: Place a new order in the store + operationId: placeOrder + requestBody: + content: + application/json: + schema: + "$ref": "#/components/schemas/Order" + responses: + '200': + description: successful operation + content: + application/json: + schema: + "$ref": "#/components/schemas/Order" + '405': + description: Invalid input + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + "/store/order/{orderId}": + get: + tags: + - store + summary: Find purchase order by ID + description: For valid response try integer IDs with value <= 5 or > 10. Other + values will generate exceptions. + operationId: getOrderById + parameters: + - name: orderId + in: path + description: ID of order that needs to be fetched + required: true + schema: + type: integer + format: int64 + responses: + '200': + description: successful operation + content: + application/json: + schema: + "$ref": "#/components/schemas/Order" + '400': + $ref: '#/components/responses/InvalidInput' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + delete: + tags: + - store + summary: Delete purchase order by ID + description: For valid response try integer IDs with value < 1000. Anything + above 1000 or nonintegers will generate API errors + operationId: deleteOrder + parameters: + - name: orderId + in: path + description: ID of the order that needs to be deleted + required: true + schema: + type: integer + format: int64 + responses: + '200': + description: Order deleted + content: + application/json: + schema: + $ref: '#/components/schemas/Order' + '400': + $ref: '#/components/responses/InvalidInput' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + "/user": + post: + tags: + - user + summary: Create user + description: This can only be done by the logged in user. + operationId: createUser + requestBody: + description: Created user object + content: + application/json: + schema: + "$ref": "#/components/schemas/User" + responses: + '200': + description: Successful operation + content: + application/json: + schema: + "$ref": "#/components/schemas/User" + "/user/createWithList": + post: + tags: + - user + summary: Creates list of users with given input array + description: Creates list of users with given input array + operationId: createUsersWithListInput + requestBody: + content: + application/json: + schema: + type: array + items: + "$ref": "#/components/schemas/User" + responses: + '200': + description: Successful operation + content: + application/json: + schema: + "$ref": "#/components/schemas/User" + "/user/login": + get: + tags: + - user + summary: Logs user into the system + description: '' + operationId: loginUser + parameters: + - name: username + in: query + description: The user name for login + required: false + schema: + type: string + - name: password + in: query + description: The password for login in clear text + required: false + schema: + type: string + responses: + '200': + description: successful operation + headers: + X-Rate-Limit: + description: calls per hour allowed by the user + schema: + type: integer + format: int32 + X-Expires-After: + description: date in UTC when token expires + schema: + type: string + format: date-time + content: + application/json: + schema: + type: string + '400': + $ref: '#/components/responses/InvalidInput' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + "/user/logout": + get: + tags: + - user + summary: Logs out current logged in user session + description: '' + operationId: logoutUser + parameters: [] + responses: + '200': + description: successful operation + "/user/{username}": + get: + tags: + - user + summary: Get user by user name + description: '' + operationId: getUserByName + parameters: + - name: username + in: path + description: 'The name that needs to be fetched. Use user1 for testing. ' + required: true + schema: + type: string + responses: + '200': + description: successful operation + content: + application/json: + schema: + "$ref": "#/components/schemas/User" + '400': + $ref: '#/components/responses/InvalidInput' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + put: + tags: + - user + summary: Update user + description: This can only be done by the logged in user. + operationId: updateUser + parameters: + - name: username + in: path + description: name that needs to be updated + required: true + schema: + type: string + requestBody: + description: Update an existent user in the store + content: + application/json: + schema: + "$ref": "#/components/schemas/User" + responses: + '200': + description: successful operation + delete: + tags: + - user + summary: Delete user + description: This can only be done by the logged in user. + operationId: deleteUser + parameters: + - name: username + in: path + description: The name that needs to be deleted + required: true + schema: + type: string + responses: + '200': + description: User deleted + content: + application/json: + schema: + $ref: '#/components/schemas/User' + '400': + $ref: '#/components/responses/InvalidInput' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' +components: + securitySchemes: + api_key: + type: apiKey + name: api_key + in: header + schemas: + Order: + type: object + properties: + id: + type: integer + format: int64 + example: 10 + petId: + type: integer + format: int64 + example: 198772 + quantity: + type: integer + format: int32 + example: 7 + shipDate: + type: string + format: date-time + status: + type: string + description: Order Status + example: approved + enum: + - placed + - approved + - delivered + complete: + type: boolean + Category: + type: object + properties: + id: + type: integer + format: int64 + example: 1 + name: + type: string + example: Dogs + User: + type: object + properties: + id: + type: integer + format: int64 + example: 10 + username: + type: string + example: theUser + firstName: + type: string + example: John + lastName: + type: string + example: James + email: + type: string + example: john@email.com + password: + type: string + example: '12345' + phone: + type: string + example: '12345' + userStatus: + type: integer + description: User Status + format: int32 + example: 1 + Tag: + type: object + properties: + id: + type: integer + format: int64 + name: + type: string + Pet: + required: + - name + - photoUrls + type: object + properties: + id: + type: integer + format: int64 + example: 10 + name: + type: string + example: doggie + category: + "$ref": "#/components/schemas/Category" + photoUrls: + type: array + items: + type: string + tags: + type: array + items: + "$ref": "#/components/schemas/Tag" + status: + type: string + description: pet status in the store + enum: + - available + - pending + - sold + ApiResponse: + type: object + properties: + code: + type: integer + format: int32 + type: + type: string + message: + type: string + ApiErrorInvalidInput: + type: object + required: + - status + - error + properties: + status: + type: integer + format: int32 + example: 400 + error: + type: string + example: Bad request + ApiErrorNotFound: + type: object + required: + - status + - error + - code + properties: + status: + type: integer + format: int32 + example: 404 + error: + type: string + example: Not Found + code: + type: string + example: object_not_found + ApiErrorUnauthorized: + type: object + required: + - status + - error + properties: + status: + type: integer + format: int32 + example: 401 + error: + type: string + example: Unauthorized + responses: + Unauthorized: + description: Unauthorized error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiErrorUnauthorized' + NotFound: + description: Not Found error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiErrorNotFound' + InvalidInput: + description: Not Found error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiErrorInvalidInput' + From ac8b44e80045d0a969ce6204b134038744711cef Mon Sep 17 00:00:00 2001 From: Ivan Gorshkov Date: Fri, 24 Oct 2025 17:31:56 +0100 Subject: [PATCH 31/42] Fixed patch deletion when no custom code is found + integration tests for conflicts --- integration/customcode_singletarget_test.go | 412 ++++++++++++++++++ integration/customcode_test.go | 334 -------------- .../registercustomcode/registercustomcode.go | 53 ++- 3 files changed, 462 insertions(+), 337 deletions(-) create mode 100644 integration/customcode_singletarget_test.go delete mode 100644 integration/customcode_test.go diff --git a/integration/customcode_singletarget_test.go b/integration/customcode_singletarget_test.go new file mode 100644 index 000000000..79051b6fc --- /dev/null +++ b/integration/customcode_singletarget_test.go @@ -0,0 +1,412 @@ +package integration_tests + +import ( + "bufio" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/speakeasy-api/sdk-gen-config/workflow" + "github.com/stretchr/testify/require" +) + +func TestCustomCode(t *testing.T) { + t.Parallel() + + // Build the speakeasy binary once for all subtests + speakeasyBinary := buildSpeakeasyBinaryOnce(t) + + t.Run("BasicWorkflow", func(t *testing.T) { + t.Parallel() + testCustomCodeBasicWorkflow(t, speakeasyBinary) + }) + + t.Run("ConflictResolution", func(t *testing.T) { + t.Parallel() + testCustomCodeConflictResolution(t, speakeasyBinary) + }) + + t.Run("ConflictResolutionAcceptOurs", func(t *testing.T) { + t.Parallel() + testCustomCodeConflictResolutionAcceptOurs(t, speakeasyBinary) + }) +} + +// testCustomCodeBasicWorkflow tests basic custom code registration and reapplication +func testCustomCodeBasicWorkflow(t *testing.T, speakeasyBinary string) { + temp := setupSDKGeneration(t, speakeasyBinary, "customcodespec.yaml") + + httpMetadataPath := filepath.Join(temp, "models", "components", "httpmetadata.go") + registerCustomCode(t, speakeasyBinary, temp, httpMetadataPath, 10, "\t// custom code") + + runRegeneration(t, speakeasyBinary, temp, true) + verifyCustomCodePresent(t, httpMetadataPath, "// custom code") +} + +// testCustomCodeConflictResolution tests conflict resolution workflow +func testCustomCodeConflictResolution(t *testing.T, speakeasyBinary string) { + temp := setupSDKGeneration(t, speakeasyBinary, "customcodespec.yaml") + + getUserByNamePath := filepath.Join(temp, "models", "operations", "getuserbyname.go") + + // Register custom code + registerCustomCode(t, speakeasyBinary, temp, getUserByNamePath, 10, "\t// custom code") + + // Modify the spec to change line 477 from original description to "spec change" + specPath := filepath.Join(temp, "customcodespec.yaml") + modifyLineInFile(t, specPath, 477, " description: 'spec change'") + + // Run speakeasy run to regenerate - this should produce a conflict + runRegeneration(t, speakeasyBinary, temp, false) + + // Run customcode --resolve to enter conflict resolution mode + resolveCmd := exec.Command(speakeasyBinary, "customcode", "--resolve", "--output", "console") + resolveCmd.Dir = temp + resolveOutput, resolveErr := resolveCmd.CombinedOutput() + require.NoError(t, resolveErr, "customcode --resolve should succeed: %s", string(resolveOutput)) + + // Check for conflict markers in the file + getUserByNameContent, err := os.ReadFile(getUserByNamePath) + require.NoError(t, err, "Failed to read getuserbyname.go") + require.Contains(t, string(getUserByNameContent), "<<<<<<<", "File should contain conflict markers") + + // Resolve the conflict by accepting the patch (theirs) + checkoutCmd := exec.Command("git", "checkout", "--theirs", getUserByNamePath) + checkoutCmd.Dir = temp + checkoutOutput, checkoutErr := checkoutCmd.CombinedOutput() + require.NoError(t, checkoutErr, "git checkout --theirs should succeed: %s", string(checkoutOutput)) + + // Stage the resolved file + gitAddCmd := exec.Command("git", "add", getUserByNamePath) + gitAddCmd.Dir = temp + gitAddOutput, gitAddErr := gitAddCmd.CombinedOutput() + require.NoError(t, gitAddErr, "git add should succeed: %s", string(gitAddOutput)) + + // Run customcode command again to register the resolved changes + customCodeCmd := exec.Command(speakeasyBinary, "customcode", "--output", "console") + customCodeCmd.Dir = temp + customCodeOutput, customCodeErr := customCodeCmd.CombinedOutput() + require.NoError(t, customCodeErr, "customcode command should succeed after conflict resolution: %s", string(customCodeOutput)) + + // Run speakeasy run again to verify patches are applied correctly + runRegeneration(t, speakeasyBinary, temp, true) + + // Verify the custom code from the patch is present in the final file + verifyCustomCodePresent(t, getUserByNamePath, "// custom code") +} + +// testCustomCodeConflictResolutionAcceptOurs tests conflict resolution by accepting spec changes (ours) +func testCustomCodeConflictResolutionAcceptOurs(t *testing.T, speakeasyBinary string) { + temp := setupSDKGeneration(t, speakeasyBinary, "customcodespec.yaml") + + getUserByNamePath := filepath.Join(temp, "models", "operations", "getuserbyname.go") + + // Register custom code + registerCustomCode(t, speakeasyBinary, temp, getUserByNamePath, 10, "\t// custom code") + + // Modify the spec to change line 477 from original description to "spec change" + specPath := filepath.Join(temp, "customcodespec.yaml") + modifyLineInFile(t, specPath, 477, " description: 'spec change'") + + // Run speakeasy run to regenerate - this should produce a conflict + runRegeneration(t, speakeasyBinary, temp, false) + + // Run customcode --resolve to enter conflict resolution mode + resolveCmd := exec.Command(speakeasyBinary, "customcode", "--resolve", "--output", "console") + resolveCmd.Dir = temp + resolveOutput, resolveErr := resolveCmd.CombinedOutput() + require.NoError(t, resolveErr, "customcode --resolve should succeed: %s", string(resolveOutput)) + + // Check for conflict markers in the file + getUserByNameContent, err := os.ReadFile(getUserByNamePath) + require.NoError(t, err, "Failed to read getuserbyname.go") + require.Contains(t, string(getUserByNameContent), "<<<<<<<", "File should contain conflict markers") + + // Resolve the conflict by accepting the spec changes (ours) + checkoutCmd := exec.Command("git", "checkout", "--ours", getUserByNamePath) + checkoutCmd.Dir = temp + checkoutOutput, checkoutErr := checkoutCmd.CombinedOutput() + require.NoError(t, checkoutErr, "git checkout --ours should succeed: %s", string(checkoutOutput)) + + // Verify conflict markers are gone after checkout + getUserByNameContentAfterCheckout, err := os.ReadFile(getUserByNamePath) + require.NoError(t, err, "Failed to read getuserbyname.go after checkout") + require.NotContains(t, string(getUserByNameContentAfterCheckout), "<<<<<<<", "File should not contain conflict markers after checkout") + + // Stage the resolved file + gitAddCmd := exec.Command("git", "add", getUserByNamePath) + gitAddCmd.Dir = temp + gitAddOutput, gitAddErr := gitAddCmd.CombinedOutput() + require.NoError(t, gitAddErr, "git add should succeed: %s", string(gitAddOutput)) + + // Run customcode command again to register the resolved changes + customCodeCmd := exec.Command(speakeasyBinary, "customcode", "--output", "console") + customCodeCmd.Dir = temp + customCodeOutput, customCodeErr := customCodeCmd.CombinedOutput() + require.NoError(t, customCodeErr, "customcode command should succeed after conflict resolution: %s", string(customCodeOutput)) + + // Run speakeasy run again to verify patches are applied correctly + runRegeneration(t, speakeasyBinary, temp, true) + + // Verify the spec change is present in the final file (not the custom code) + finalContent, err := os.ReadFile(getUserByNamePath) + require.NoError(t, err, "Failed to read getuserbyname.go after final regeneration") + require.Contains(t, string(finalContent), "spec change", "Spec change should be present after accepting ours") + require.NotContains(t, string(finalContent), "// custom code", "Custom code should not be present after accepting ours") +} + +// buildSpeakeasyBinaryOnce builds the speakeasy binary and returns the path to it +func buildSpeakeasyBinaryOnce(t *testing.T) string { + t.Helper() + + _, filename, _, _ := runtime.Caller(0) + baseFolder := filepath.Join(filepath.Dir(filename), "..") + binaryPath := filepath.Join(baseFolder, "speakeasy-customcode-test-binary") + + // Build the binary + cmd := exec.Command("go", "build", "-o", binaryPath, "./main.go") + cmd.Dir = baseFolder + output, err := cmd.CombinedOutput() + require.NoError(t, err, "Failed to build speakeasy binary: %s", string(output)) + + // Clean up the binary when test completes + t.Cleanup(func() { + os.Remove(binaryPath) + }) + + return binaryPath +} + +// initGitRepo initializes a git repository in the specified directory +func initGitRepo(t *testing.T, dir string) { + t.Helper() + + // Initialize git repo + cmd := exec.Command("git", "init") + cmd.Dir = dir + output, err := cmd.CombinedOutput() + require.NoError(t, err, "Failed to initialize git repo: %s", string(output)) + + // Configure git user for commits + cmd = exec.Command("git", "config", "user.email", "test@example.com") + cmd.Dir = dir + output, err = cmd.CombinedOutput() + require.NoError(t, err, "Failed to configure git user.email: %s", string(output)) + + cmd = exec.Command("git", "config", "user.name", "Test User") + cmd.Dir = dir + output, err = cmd.CombinedOutput() + require.NoError(t, err, "Failed to configure git user.name: %s", string(output)) +} + +// gitCommit creates a git commit with all changes in the specified directory +func gitCommit(t *testing.T, dir, message string) { + t.Helper() + + // Add all files + cmd := exec.Command("git", "add", ".") + cmd.Dir = dir + output, err := cmd.CombinedOutput() + require.NoError(t, err, "Failed to git add: %s", string(output)) + + // Commit with message + cmd = exec.Command("git", "commit", "-m", message) + cmd.Dir = dir + output, err = cmd.CombinedOutput() + require.NoError(t, err, "Failed to git commit: %s", string(output)) +} + +// verifyGitCommit verifies that a git commit exists with the expected message +func verifyGitCommit(t *testing.T, dir, expectedMessage string) { + t.Helper() + + // Check that .git directory exists + gitDir := filepath.Join(dir, ".git") + _, err := os.Stat(gitDir) + require.NoError(t, err, ".git directory should exist") + + // Get the latest commit message + cmd := exec.Command("git", "log", "-1", "--pretty=%B") + cmd.Dir = dir + output, err := cmd.CombinedOutput() + require.NoError(t, err, "Failed to get git log: %s", string(output)) + + // Verify commit message matches + commitMessage := strings.TrimSpace(string(output)) + require.Equal(t, expectedMessage, commitMessage, "Commit message should match expected message") +} + +// modifyLineInFile modifies a specific line in a file (1-indexed line number) +func modifyLineInFile(t *testing.T, filePath string, lineNumber int, newContent string) { + t.Helper() + + // Read the file + file, err := os.Open(filePath) + require.NoError(t, err, "Failed to open file: %s", filePath) + defer file.Close() + + var lines []string + scanner := bufio.NewScanner(file) + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + require.NoError(t, scanner.Err(), "Failed to read file: %s", filePath) + + // Modify the specific line (convert 1-indexed to 0-indexed) + require.Less(t, lineNumber, len(lines)+1, "Line number %d is out of range (file has %d lines)", lineNumber, len(lines)) + lines[lineNumber-1] = newContent + + // Write back to the file + file, err = os.Create(filePath) + require.NoError(t, err, "Failed to open file for writing: %s", filePath) + defer file.Close() + + writer := bufio.NewWriter(file) + for _, line := range lines { + _, err := writer.WriteString(line + "\n") + require.NoError(t, err, "Failed to write line to file") + } + require.NoError(t, writer.Flush(), "Failed to flush writer") +} + +// setupCustomCodeTestDir creates a test directory outside the speakeasy repo +func setupCustomCodeTestDir(t *testing.T) string { + t.Helper() + + // Check for custom test directory environment variable + baseDir := os.Getenv("SPEAKEASY_TEST_DIR") + if baseDir == "" { + // Fall back to system temp + baseDir = os.TempDir() + } + + // Create unique test directory + testDir, err := os.MkdirTemp(baseDir, "speakeasy-customcode-*") + require.NoError(t, err, "Failed to create test directory") + + // Clean up after test + // t.Cleanup(func() { + // os.RemoveAll(testDir) + // }) + + return testDir +} + +// setupSDKGeneration sets up a test directory with SDK generation and git initialization +func setupSDKGeneration(t *testing.T, speakeasyBinary, inputDoc string) string { + t.Helper() + + temp := setupCustomCodeTestDir(t) + + // Create workflow file and associated resources + workflowFile := &workflow.Workflow{ + Version: workflow.WorkflowVersion, + Sources: make(map[string]workflow.Source), + Targets: make(map[string]workflow.Target), + } + workflowFile.Sources["first-source"] = workflow.Source{ + Inputs: []workflow.Document{ + { + Location: workflow.LocationString(inputDoc), + }, + }, + } + + // Single go target with no output directory - generates to workspace root + target := workflow.Target{ + Target: "go", + Source: "first-source", + // Output: nil - generates directly in workspace root + } + workflowFile.Targets["test-target"] = target + + if isLocalFileReference(inputDoc) { + err := copyFile("resources/customcodespec.yaml", fmt.Sprintf("%s/%s", temp, inputDoc)) + require.NoError(t, err) + } + + err := os.MkdirAll(filepath.Join(temp, ".speakeasy"), 0o755) + require.NoError(t, err) + err = workflow.Save(temp, workflowFile) + require.NoError(t, err) + + // Run speakeasy run command using the built binary + runCmd := exec.Command(speakeasyBinary, "run", "-t", "all", "--pinned", "--skip-compile") + runCmd.Dir = temp + runOutput, runErr := runCmd.CombinedOutput() + require.NoError(t, runErr, "speakeasy run should succeed: %s", string(runOutput)) + + // Run go mod tidy to ensure go.sum is properly populated + // This is necessary because we used --skip-compile above + goModTidyCmd := exec.Command("go", "mod", "tidy") + goModTidyCmd.Dir = temp + output, err := goModTidyCmd.CombinedOutput() + require.NoError(t, err, "Failed to run go mod tidy: %s", string(output)) + + // SDK is generated in workspace root + checkForExpectedFiles(t, temp, expectedFilesByLanguage("go")) + + // Initialize git repository in the workspace root + initGitRepo(t, temp) + + // Commit all generated files with "clean generation" message + gitCommit(t, temp, "clean generation") + + // Verify the commit was created with the correct message + verifyGitCommit(t, temp, "clean generation") + + return temp +} + +// registerCustomCode modifies a file and registers it as custom code +func registerCustomCode(t *testing.T, speakeasyBinary, workingDir, filePath string, lineNum int, newContent string) { + t.Helper() + + // Modify the file + modifyLineInFile(t, filePath, lineNum, newContent) + + // Run customcode command + customCodeCmd := exec.Command(speakeasyBinary, "customcode", "--output", "console") + customCodeCmd.Dir = workingDir + customCodeOutput, customCodeErr := customCodeCmd.CombinedOutput() + require.NoError(t, customCodeErr, "customcode command should succeed: %s", string(customCodeOutput)) + + // Verify patches directory was created + patchesDir := filepath.Join(workingDir, ".speakeasy", "patches") + _, err := os.Stat(patchesDir) + require.NoError(t, err, "patches directory should exist at %s", patchesDir) + + // Verify patch file was created + patchFile := filepath.Join(patchesDir, "custom-code.diff") + _, err = os.Stat(patchFile) + require.NoError(t, err, "patch file should exist at %s", patchFile) +} + +// runRegeneration runs speakeasy run and checks if it succeeds or fails based on expectSuccess +func runRegeneration(t *testing.T, speakeasyBinary, workingDir string, expectSuccess bool) { + t.Helper() + + regenCmd := exec.Command(speakeasyBinary, "run", "-t", "all", "--pinned", "--skip-compile") + regenCmd.Dir = workingDir + regenOutput, regenErr := regenCmd.CombinedOutput() + + if expectSuccess { + require.NoError(t, regenErr, "speakeasy run should succeed on regeneration: %s", string(regenOutput)) + } else { + require.Error(t, regenErr, "speakeasy run should fail due to conflicts: %s", string(regenOutput)) + require.Contains(t, string(regenOutput), "conflict", "Output should mention conflicts") + } +} + +// verifyCustomCodePresent checks that custom code is present in the specified file +func verifyCustomCodePresent(t *testing.T, filePath, expectedContent string) { + t.Helper() + + content, err := os.ReadFile(filePath) + require.NoError(t, err, "Failed to read file: %s", filePath) + require.Contains(t, string(content), expectedContent, "Custom code should be present in file") +} diff --git a/integration/customcode_test.go b/integration/customcode_test.go deleted file mode 100644 index 63ff92be3..000000000 --- a/integration/customcode_test.go +++ /dev/null @@ -1,334 +0,0 @@ -package integration_tests - -import ( - "bufio" - "fmt" - "os" - "os/exec" - "path/filepath" - "runtime" - "strings" - "testing" - - "github.com/speakeasy-api/sdk-gen-config/workflow" - "github.com/stretchr/testify/require" -) - -func TestCustomCodeWorkflows(t *testing.T) { - t.Parallel() - - // Build the speakeasy binary once for all tests - speakeasyBinary := buildSpeakeasyBinary(t) - - tests := []struct { - name string - targetTypes []string - inputDoc string - withCodeSamples bool - }{ - { - name: "generation with local document", - targetTypes: []string{ - "go", - }, - inputDoc: "customcodespec.yaml", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - // Use custom-code-test directory to avoid gitignore issues from speakeasy repo - temp := setupCustomCodeTestDir(t) - - // Create workflow file and associated resources - workflowFile := &workflow.Workflow{ - Version: workflow.WorkflowVersion, - Sources: make(map[string]workflow.Source), - Targets: make(map[string]workflow.Target), - } - workflowFile.Sources["first-source"] = workflow.Source{ - Inputs: []workflow.Document{ - { - Location: workflow.LocationString(tt.inputDoc), - }, - }, - } - - for i := range tt.targetTypes { - outdir := "go" - target := workflow.Target{ - Target: tt.targetTypes[i], - Source: "first-source", - Output: &outdir, - } - if tt.withCodeSamples { - target.CodeSamples = &workflow.CodeSamples{ - Output: "codeSamples.yaml", - } - } - workflowFile.Targets[fmt.Sprintf("%d-target", i)] = target - } - - if isLocalFileReference(tt.inputDoc) { - err := copyFile("resources/customcodespec.yaml", fmt.Sprintf("%s/%s", temp, tt.inputDoc)) - require.NoError(t, err) - } - - err := os.MkdirAll(filepath.Join(temp, ".speakeasy"), 0o755) - require.NoError(t, err) - err = workflow.Save(temp, workflowFile) - require.NoError(t, err) - - // Run speakeasy run command using the built binary - runCmd := exec.Command(speakeasyBinary, "run", "-t", "all", "--pinned", "--skip-compile") - runCmd.Dir = temp - runOutput, runErr := runCmd.CombinedOutput() - require.NoError(t, runErr, "speakeasy run should succeed: %s", string(runOutput)) - - // SDK directory where files are generated - sdkDir := filepath.Join(temp, "go") - - // Run go mod tidy to ensure go.sum is properly populated - // This is necessary because we used --skip-compile above - goModTidyCmd := exec.Command("go", "mod", "tidy") - goModTidyCmd.Dir = sdkDir - output, err := goModTidyCmd.CombinedOutput() - require.NoError(t, err, "Failed to run go mod tidy: %s", string(output)) - - if tt.withCodeSamples { - codeSamplesPath := filepath.Join(sdkDir, "codeSamples.yaml") - content, err := os.ReadFile(codeSamplesPath) - require.NoError(t, err, "No readable file %s exists", codeSamplesPath) - - // Check if codeSamples file is not empty and contains expected content - require.NotEmpty(t, content, "codeSamples.yaml should not be empty") - } - - // SDK is generated in go subdirectory - for _, targetType := range tt.targetTypes { - checkForExpectedFiles(t, sdkDir, expectedFilesByLanguage(targetType)) - } - - // Initialize git repository in the go directory - initGitRepo(t, sdkDir) - - // Copy workflow.yaml and spec to SDK directory before committing - copyWorkflowToSDK(t, temp, sdkDir) - - // Commit all generated files with "clean generation" message - gitCommit(t, sdkDir, "clean generation") - - // Verify the commit was created with the correct message - verifyGitCommit(t, sdkDir, "clean generation") - - // Modify httpmetadata.go to add custom code - httpMetadataPath := filepath.Join(sdkDir, "models", "components", "httpmetadata.go") - modifyLineInFile(t, httpMetadataPath, 10, "\t// custom code") - - // Run customcode command from SDK directory using the built binary - customCodeCmd := exec.Command(speakeasyBinary, "customcode", "--output", "console") - customCodeCmd.Dir = sdkDir - customCodeOutput, customCodeErr := customCodeCmd.CombinedOutput() - require.NoError(t, customCodeErr, "customcode command should succeed: %s", string(customCodeOutput)) - - // Verify patches directory was created in SDK directory - patchesDir := filepath.Join(sdkDir, ".speakeasy", "patches") - _, err = os.Stat(patchesDir) - require.NoError(t, err, "patches directory should exist at %s", patchesDir) - - // Verify patch file was created - patchFile := filepath.Join(patchesDir, "custom-code.diff") - _, err = os.Stat(patchFile) - require.NoError(t, err, "patch file should exist at %s", patchFile) - - // Run speakeasy run again from the SDK directory to regenerate and apply patches - regenCmd := exec.Command(speakeasyBinary, "run", "-t", "all", "--pinned", "--skip-compile") - regenCmd.Dir = sdkDir - regenOutput, regenErr := regenCmd.CombinedOutput() - require.NoError(t, regenErr, "speakeasy run should succeed on regeneration: %s", string(regenOutput)) - - // Verify the custom code is still present after regeneration - httpMetadataContent, err := os.ReadFile(httpMetadataPath) - require.NoError(t, err, "Failed to read httpmetadata.go after regeneration") - require.Contains(t, string(httpMetadataContent), "// custom code", "Custom code comment should still be present after regeneration") - }) - } -} - -// initGitRepo initializes a git repository in the specified directory -func initGitRepo(t *testing.T, dir string) { - t.Helper() - - // Initialize git repo - cmd := exec.Command("git", "init") - cmd.Dir = dir - output, err := cmd.CombinedOutput() - require.NoError(t, err, "Failed to initialize git repo: %s", string(output)) - - // Configure git user for commits - cmd = exec.Command("git", "config", "user.email", "test@example.com") - cmd.Dir = dir - output, err = cmd.CombinedOutput() - require.NoError(t, err, "Failed to configure git user.email: %s", string(output)) - - cmd = exec.Command("git", "config", "user.name", "Test User") - cmd.Dir = dir - output, err = cmd.CombinedOutput() - require.NoError(t, err, "Failed to configure git user.name: %s", string(output)) -} - -// gitCommit creates a git commit with all changes in the specified directory -func gitCommit(t *testing.T, dir, message string) { - t.Helper() - - // Add all files - cmd := exec.Command("git", "add", ".") - cmd.Dir = dir - output, err := cmd.CombinedOutput() - require.NoError(t, err, "Failed to git add: %s", string(output)) - - // Commit with message - cmd = exec.Command("git", "commit", "-m", message) - cmd.Dir = dir - output, err = cmd.CombinedOutput() - require.NoError(t, err, "Failed to git commit: %s", string(output)) -} - -// verifyGitCommit verifies that a git commit exists with the expected message -func verifyGitCommit(t *testing.T, dir, expectedMessage string) { - t.Helper() - - // Check that .git directory exists - gitDir := filepath.Join(dir, ".git") - _, err := os.Stat(gitDir) - require.NoError(t, err, ".git directory should exist") - - // Get the latest commit message - cmd := exec.Command("git", "log", "-1", "--pretty=%B") - cmd.Dir = dir - output, err := cmd.CombinedOutput() - require.NoError(t, err, "Failed to get git log: %s", string(output)) - - // Verify commit message matches - commitMessage := strings.TrimSpace(string(output)) - require.Equal(t, expectedMessage, commitMessage, "Commit message should match expected message") -} - -// modifyLineInFile modifies a specific line in a file (1-indexed line number) -func modifyLineInFile(t *testing.T, filePath string, lineNumber int, newContent string) { - t.Helper() - - // Read the file - file, err := os.Open(filePath) - require.NoError(t, err, "Failed to open file: %s", filePath) - defer file.Close() - - var lines []string - scanner := bufio.NewScanner(file) - for scanner.Scan() { - lines = append(lines, scanner.Text()) - } - require.NoError(t, scanner.Err(), "Failed to read file: %s", filePath) - - // Modify the specific line (convert 1-indexed to 0-indexed) - require.Less(t, lineNumber, len(lines)+1, "Line number %d is out of range (file has %d lines)", lineNumber, len(lines)) - lines[lineNumber-1] = newContent - - // Write back to the file - file, err = os.Create(filePath) - require.NoError(t, err, "Failed to open file for writing: %s", filePath) - defer file.Close() - - writer := bufio.NewWriter(file) - for _, line := range lines { - _, err := writer.WriteString(line + "\n") - require.NoError(t, err, "Failed to write line to file") - } - require.NoError(t, writer.Flush(), "Failed to flush writer") -} - -// buildSpeakeasyBinary builds the speakeasy binary and returns the path to it -func buildSpeakeasyBinary(t *testing.T) string { - t.Helper() - - _, filename, _, _ := runtime.Caller(0) - baseFolder := filepath.Join(filepath.Dir(filename), "..") - binaryPath := filepath.Join(baseFolder, "speakeasy-test-binary") - - // Build the binary - cmd := exec.Command("go", "build", "-o", binaryPath, "./main.go") - cmd.Dir = baseFolder - output, err := cmd.CombinedOutput() - require.NoError(t, err, "Failed to build speakeasy binary: %s", string(output)) - - // Clean up the binary when test completes - t.Cleanup(func() { - os.Remove(binaryPath) - }) - - return binaryPath -} - -// copyWorkflowToSDK copies the workflow.yaml and spec files from the workspace to the SDK directory -func copyWorkflowToSDK(t *testing.T, workspaceDir, sdkDir string) { - t.Helper() - - // Copy workflow.yaml file, removing output paths since we're running from SDK directory - srcWorkflowPath := filepath.Join(workspaceDir, ".speakeasy", "workflow.yaml") - dstWorkflowPath := filepath.Join(sdkDir, ".speakeasy", "workflow.yaml") - workflowContent, err := os.ReadFile(srcWorkflowPath) - require.NoError(t, err, "Failed to read workflow.yaml") - - // Remove "output: go" lines from the workflow content - workflowStr := string(workflowContent) - workflowStr = strings.ReplaceAll(workflowStr, "\n output: go", "") - - err = os.WriteFile(dstWorkflowPath, []byte(workflowStr), 0o644) - require.NoError(t, err, "Failed to write workflow.yaml") - - // Read the workflow to find spec files to copy - workflowFile, _, err := workflow.Load(workspaceDir) - require.NoError(t, err, "Failed to load workflow.yaml") - - // Copy local spec files to SDK directory - for _, source := range workflowFile.Sources { - for i := range source.Inputs { - if isLocalFileReference(string(source.Inputs[i].Location)) { - specPath := string(source.Inputs[i].Location) - - // Copy the spec file to SDK directory - srcSpecPath := filepath.Join(workspaceDir, specPath) - dstSpecPath := filepath.Join(sdkDir, specPath) - - specContent, err := os.ReadFile(srcSpecPath) - require.NoError(t, err, "Failed to read spec file: %s", srcSpecPath) - - err = os.WriteFile(dstSpecPath, specContent, 0o644) - require.NoError(t, err, "Failed to write spec file to SDK directory: %s", dstSpecPath) - } - } - } -} - -// setupCustomCodeTestDir creates a test directory in custom-code-test/speakeasy_tests -func setupCustomCodeTestDir(t *testing.T) string { - t.Helper() - - baseDir := "/Users/ivangorshkov/speakeasy/repos/custom-code-test/speakeasy_tests" - - // Create base directory if it doesn't exist - err := os.MkdirAll(baseDir, 0o755) - require.NoError(t, err, "Failed to create base directory") - - // Create unique test directory - testDir := filepath.Join(baseDir, fmt.Sprintf("test-%d", os.Getpid())) - err = os.MkdirAll(testDir, 0o755) - require.NoError(t, err, "Failed to create test directory") - - // Clean up after test - t.Cleanup(func() { - os.RemoveAll(testDir) - }) - - return testDir -} diff --git a/internal/registercustomcode/registercustomcode.go b/internal/registercustomcode/registercustomcode.go index 2541a96bf..e943e1e23 100644 --- a/internal/registercustomcode/registercustomcode.go +++ b/internal/registercustomcode/registercustomcode.go @@ -146,7 +146,22 @@ func updateCustomPatchAndUpdateGenLock(ctx context.Context, wf *workflow.Workflo return fmt.Errorf("failed to check for changes: %w", err) } if !hasChanges { - fmt.Printf("No changes detected for target %s after applying existing patch, skipping\n", targetName) + // Check if there's actually a patch to clean up + if patchFileExists(getTargetOutput(target)) { + fmt.Printf("No changes detected for target %s after applying patches, cleaning up patch registration\n", targetName) + + // Clean up: remove patch file and commit hash from gen.lock + if err := saveCustomCodePatch(getTargetOutput(target), "", ""); err != nil { + return fmt.Errorf("failed to clean up empty patch: %w", err) + } + + // Commit the cleanup + if err := commitCustomCodeRegistration(getTargetOutput(target)); err != nil { + return fmt.Errorf("failed to commit patch cleanup: %w", err) + } + } else { + fmt.Printf("No changes detected for target %s, skipping\n", targetName) + } return nil } @@ -408,6 +423,25 @@ func completeConflictResolution(ctx context.Context, wf *workflow.Workflow) erro return err } for targetName, target := range wf.Targets { + if targetPatches[targetName] == "" { + // Check if there's actually a patch to clean up + if patchFileExists(getTargetOutput(target)) { + fmt.Printf("No changes detected for target %s after conflict resolution, cleaning up patch registration\n", targetName) + + // Clean up: remove patch file and commit hash from gen.lock + if err := saveCustomCodePatch(getTargetOutput(target), "", ""); err != nil { + return fmt.Errorf("failed to clean up empty patch: %w", err) + } + + // Commit the cleanup + if err := commitCustomCodeRegistration(getTargetOutput(target)); err != nil { + return fmt.Errorf("failed to commit patch cleanup: %w", err) + } + } else { + fmt.Printf("No changes detected for target %s after conflict resolution, skipping\n", targetName) + } + continue + } err = updateCustomPatchAndUpdateGenLock(ctx, wf, originalHash, targetPatches, target, targetName) if err != nil { return err @@ -731,9 +765,22 @@ func commitCustomCodeRegistration(outDir string) error { genLockPath := fmt.Sprintf("%v/.speakeasy/gen.lock", outDir) patchPath := getPatchFilePath(outDir) - cmd := exec.Command("git", "add", genLockPath, patchPath) + // Always add gen.lock + cmd := exec.Command("git", "add", genLockPath) if err := cmd.Run(); err != nil { - return fmt.Errorf("failed to add gen.lock and patch file: %w", err) + return fmt.Errorf("failed to add gen.lock: %w", err) + } + + // Handle patch file - add if exists, stage deletion if removed + if patchFileExists(outDir) { + cmd = exec.Command("git", "add", patchPath) + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to add patch file: %w", err) + } + } else { + // Stage deletion using git rm (won't fail if file not tracked) + cmd = exec.Command("git", "rm", "--ignore-unmatch", patchPath) + _ = cmd.Run() // Ignore errors - file might not exist in git } // Commit with a descriptive message From 5dd125c636bf5276f52e3f3fe286b91fca2f1509 Mon Sep 17 00:00:00 2001 From: Ivan Gorshkov Date: Fri, 24 Oct 2025 17:38:07 +0100 Subject: [PATCH 32/42] test improvement --- integration/customcode_singletarget_test.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/integration/customcode_singletarget_test.go b/integration/customcode_singletarget_test.go index 79051b6fc..eda83b18d 100644 --- a/integration/customcode_singletarget_test.go +++ b/integration/customcode_singletarget_test.go @@ -149,6 +149,20 @@ func testCustomCodeConflictResolutionAcceptOurs(t *testing.T, speakeasyBinary st customCodeOutput, customCodeErr := customCodeCmd.CombinedOutput() require.NoError(t, customCodeErr, "customcode command should succeed after conflict resolution: %s", string(customCodeOutput)) + // Verify patch file was removed or is empty + patchFile := filepath.Join(temp, ".speakeasy", "patches", "custom-code.diff") + patchContent, err := os.ReadFile(patchFile) + if err == nil { + require.Empty(t, patchContent, "Patch file should be empty after accepting ours") + } + // If file doesn't exist, that's also fine + + // Verify gen.lock doesn't contain customCodeCommitHash + genLockPath := filepath.Join(temp, ".speakeasy", "gen.lock") + genLockContent, err := os.ReadFile(genLockPath) + require.NoError(t, err, "Failed to read gen.lock") + require.NotContains(t, string(genLockContent), "customCodeCommitHash", "gen.lock should not contain customCodeCommitHash after accepting ours") + // Run speakeasy run again to verify patches are applied correctly runRegeneration(t, speakeasyBinary, temp, true) From 6180540c03442af672d40ac98f63113705dbe114 Mon Sep 17 00:00:00 2001 From: Ivan Gorshkov Date: Mon, 27 Oct 2025 17:04:56 +0000 Subject: [PATCH 33/42] tests and added added git reset --hard HEAD after capturing patches but before clean SDK generation --- cmd/customcode.go | 10 - integration/customcode_singletarget_test.go | 329 +++++++++++++++++- .../registercustomcode/registercustomcode.go | 9 + internal/sdkgen/sdkgen.go | 5 + 4 files changed, 327 insertions(+), 26 deletions(-) diff --git a/cmd/customcode.go b/cmd/customcode.go index 5f14580d2..c6527eddf 100644 --- a/cmd/customcode.go +++ b/cmd/customcode.go @@ -18,7 +18,6 @@ import ( type RegisterCustomCodeFlags struct { Show bool `json:"show"` - Resolve bool `json:"resolve"` Apply bool `json:"apply-only"` ApplyReverse bool `json:"apply-reverse"` LatestHash bool `json:"latest-hash"` @@ -44,10 +43,6 @@ var registerCustomCodeCmd = &model.ExecutableCommand[RegisterCustomCodeFlags]{ Shorthand: "s", Description: "show custom code patches", }, - flag.BooleanFlag{ - Name: "resolve", - Description: "enter conflict resolution mode after a failed generation", - }, flag.BooleanFlag{ Name: "apply-only", Shorthand: "a", @@ -103,11 +98,6 @@ func registerCustomCode(ctx context.Context, flags RegisterCustomCodeFlags) erro return nil } - // If --resolve flag is provided, enter conflict resolution mode - if flags.Resolve { - return registercustomcode.ResolveCustomCodeConflicts(ctx) - } - // If --apply-only flag is provided, only apply existing patches if flags.Apply { wf, _, err := utils.GetWorkflowAndDir() diff --git a/integration/customcode_singletarget_test.go b/integration/customcode_singletarget_test.go index eda83b18d..52b145e94 100644 --- a/integration/customcode_singletarget_test.go +++ b/integration/customcode_singletarget_test.go @@ -34,6 +34,31 @@ func TestCustomCode(t *testing.T) { t.Parallel() testCustomCodeConflictResolutionAcceptOurs(t, speakeasyBinary) }) + + t.Run("SequentialPatchesAppliedWithRegenerationBetween", func(t *testing.T) { + t.Parallel() + testCustomCodeSequentialPatchesAppliedWithRegenerationBetween(t, speakeasyBinary) + }) + + t.Run("SequentialPatchesAppliedWithoutRegenerationBetween", func(t *testing.T) { + t.Parallel() + testCustomCodeSequentialPatchesAppliedWithoutRegenerationBetween(t, speakeasyBinary) + }) + + t.Run("ConflictDetectionDuringCustomCodeRegistration", func(t *testing.T) { + t.Parallel() + testCustomCodeConflictDetectionDuringRegistration(t, speakeasyBinary) + }) + + t.Run("NewFilePreservation", func(t *testing.T) { + t.Parallel() + testCustomCodeNewFilePreservation(t, speakeasyBinary) + }) + + t.Run("NewFileDeletion", func(t *testing.T) { + t.Parallel() + testCustomCodeNewFileDeletion(t, speakeasyBinary) + }) } // testCustomCodeBasicWorkflow tests basic custom code registration and reapplication @@ -60,14 +85,14 @@ func testCustomCodeConflictResolution(t *testing.T, speakeasyBinary string) { specPath := filepath.Join(temp, "customcodespec.yaml") modifyLineInFile(t, specPath, 477, " description: 'spec change'") - // Run speakeasy run to regenerate - this should produce a conflict - runRegeneration(t, speakeasyBinary, temp, false) - - // Run customcode --resolve to enter conflict resolution mode - resolveCmd := exec.Command(speakeasyBinary, "customcode", "--resolve", "--output", "console") - resolveCmd.Dir = temp - resolveOutput, resolveErr := resolveCmd.CombinedOutput() - require.NoError(t, resolveErr, "customcode --resolve should succeed: %s", string(resolveOutput)) + // Run speakeasy run to regenerate - this should detect conflict and automatically enter resolution mode + // The process should exit with code 2 after setting up conflict resolution + regenCmd := exec.Command(speakeasyBinary, "run", "-t", "all", "--pinned", "--skip-compile") + regenCmd.Dir = temp + regenOutput, regenErr := regenCmd.CombinedOutput() + require.Error(t, regenErr, "speakeasy run should exit with error after detecting conflicts: %s", string(regenOutput)) + require.Contains(t, string(regenOutput), "CUSTOM CODE CONFLICTS DETECTED", "Output should show conflict detection banner") + require.Contains(t, string(regenOutput), "Entering automatic conflict resolution mode", "Output should indicate automatic resolution mode") // Check for conflict markers in the file getUserByNameContent, err := os.ReadFile(getUserByNamePath) @@ -112,14 +137,14 @@ func testCustomCodeConflictResolutionAcceptOurs(t *testing.T, speakeasyBinary st specPath := filepath.Join(temp, "customcodespec.yaml") modifyLineInFile(t, specPath, 477, " description: 'spec change'") - // Run speakeasy run to regenerate - this should produce a conflict - runRegeneration(t, speakeasyBinary, temp, false) - - // Run customcode --resolve to enter conflict resolution mode - resolveCmd := exec.Command(speakeasyBinary, "customcode", "--resolve", "--output", "console") - resolveCmd.Dir = temp - resolveOutput, resolveErr := resolveCmd.CombinedOutput() - require.NoError(t, resolveErr, "customcode --resolve should succeed: %s", string(resolveOutput)) + // Run speakeasy run to regenerate - this should detect conflict and automatically enter resolution mode + // The process should exit with code 2 after setting up conflict resolution + regenCmd := exec.Command(speakeasyBinary, "run", "-t", "all", "--pinned", "--skip-compile") + regenCmd.Dir = temp + regenOutput, regenErr := regenCmd.CombinedOutput() + require.Error(t, regenErr, "speakeasy run should exit with error after detecting conflicts: %s", string(regenOutput)) + require.Contains(t, string(regenOutput), "CUSTOM CODE CONFLICTS DETECTED", "Output should show conflict detection banner") + require.Contains(t, string(regenOutput), "Entering automatic conflict resolution mode", "Output should indicate automatic resolution mode") // Check for conflict markers in the file getUserByNameContent, err := os.ReadFile(getUserByNamePath) @@ -173,6 +198,137 @@ func testCustomCodeConflictResolutionAcceptOurs(t *testing.T, speakeasyBinary st require.NotContains(t, string(finalContent), "// custom code", "Custom code should not be present after accepting ours") } +// testCustomCodeSequentialPatchesAppliedWithRegenerationBetween tests that patches can be updated +// by registering a first patch, regenerating, then registering a second patch on the same line +func testCustomCodeSequentialPatchesAppliedWithRegenerationBetween(t *testing.T, speakeasyBinary string) { + temp := setupSDKGeneration(t, speakeasyBinary, "customcodespec.yaml") + + getUserByNamePath := filepath.Join(temp, "models", "operations", "getuserbyname.go") + + // Step 1: Register first patch + registerCustomCode(t, speakeasyBinary, temp, getUserByNamePath, 10, "\t// first custom code") + + // Step 2: Verify first patch applies correctly on regeneration + runRegeneration(t, speakeasyBinary, temp, true) + verifyCustomCodePresent(t, getUserByNamePath, "// first custom code") + + // Step 2b: Commit the regenerated code with first patch applied + gitCommit(t, temp, "regenerated with first patch") + + // Step 3: Modify the same line with different content (second patch) + modifyLineInFile(t, getUserByNamePath, 10, "\t// second custom code - updated") + + // Step 4: Register second patch (should update existing patch) + customCodeCmd := exec.Command(speakeasyBinary, "customcode", "--output", "console") + customCodeCmd.Dir = temp + customCodeOutput, customCodeErr := customCodeCmd.CombinedOutput() + require.NoError(t, customCodeErr, "customcode command should succeed for second patch: %s", string(customCodeOutput)) + + // Step 5: Verify patch file was updated (not appended) + patchFile := filepath.Join(temp, ".speakeasy", "patches", "custom-code.diff") + patchContent, err := os.ReadFile(patchFile) + require.NoError(t, err, "Failed to read patch file") + require.Contains(t, string(patchContent), "second custom code - updated", "Patch should contain second custom code") + require.NotContains(t, string(patchContent), "first custom code", "Patch should not contain first custom code") + + // Step 6: Verify second patch applies correctly on final regeneration + runRegeneration(t, speakeasyBinary, temp, true) + + // Step 7: Verify final file contains only second patch content + finalContent, err := os.ReadFile(getUserByNamePath) + require.NoError(t, err, "Failed to read getuserbyname.go after final regeneration") + require.Contains(t, string(finalContent), "// second custom code - updated", "File should contain second custom code") + require.NotContains(t, string(finalContent), "// first custom code", "File should not contain first custom code") +} + +// testCustomCodeSequentialPatchesAppliedWithoutRegenerationBetween tests that patches can be updated +// by registering a first patch, then immediately registering a second patch on the same line without regenerating +func testCustomCodeSequentialPatchesAppliedWithoutRegenerationBetween(t *testing.T, speakeasyBinary string) { + temp := setupSDKGeneration(t, speakeasyBinary, "customcodespec.yaml") + + getUserByNamePath := filepath.Join(temp, "models", "operations", "getuserbyname.go") + + // Step 1: Register first patch + registerCustomCode(t, speakeasyBinary, temp, getUserByNamePath, 10, "\t// first custom code") + + // Step 2: Immediately modify the same line with different content (NO regeneration between) + modifyLineInFile(t, getUserByNamePath, 10, "\t// second custom code - updated") + + // Step 3: Register second patch (should update existing patch) + customCodeCmd := exec.Command(speakeasyBinary, "customcode", "--output", "console") + customCodeCmd.Dir = temp + customCodeOutput, customCodeErr := customCodeCmd.CombinedOutput() + require.NoError(t, customCodeErr, "customcode command should succeed for second patch: %s", string(customCodeOutput)) + + // Step 4: Verify patch file was updated (not appended) + patchFile := filepath.Join(temp, ".speakeasy", "patches", "custom-code.diff") + patchContent, err := os.ReadFile(patchFile) + require.NoError(t, err, "Failed to read patch file") + require.Contains(t, string(patchContent), "second custom code - updated", "Patch should contain second custom code") + require.NotContains(t, string(patchContent), "first custom code", "Patch should not contain first custom code") + + // Step 5: Verify second patch applies correctly on regeneration + runRegeneration(t, speakeasyBinary, temp, true) + + // Step 6: Verify final file contains only second patch content + finalContent, err := os.ReadFile(getUserByNamePath) + require.NoError(t, err, "Failed to read getuserbyname.go after final regeneration") + require.Contains(t, string(finalContent), "// second custom code - updated", "File should contain second custom code") + require.NotContains(t, string(finalContent), "// first custom code", "File should not contain first custom code") +} + +// testCustomCodeConflictDetectionDuringRegistration tests that conflicts are detected during customcode registration +// when the old patch conflicts with new changes +func testCustomCodeConflictDetectionDuringRegistration(t *testing.T, speakeasyBinary string) { + temp := setupSDKGeneration(t, speakeasyBinary, "customcodespec.yaml") + + getUserByNamePath := filepath.Join(temp, "models", "operations", "getuserbyname.go") + + // Step 1: Modify the file + modifyLineInFile(t, getUserByNamePath, 10, "\t// first custom code") + + // Step 2: Register first patch + customCodeCmd := exec.Command(speakeasyBinary, "customcode", "--output", "console") + customCodeCmd.Dir = temp + customCodeOutput, customCodeErr := customCodeCmd.CombinedOutput() + require.NoError(t, customCodeErr, "customcode command should succeed: %s", string(customCodeOutput)) + + // Step 3: Run and commit + runRegeneration(t, speakeasyBinary, temp, true) + gitCommit(t, temp, "regenerated with first patch") + + // Step 4: Modify the same line again + modifyLineInFile(t, getUserByNamePath, 10, "\t// second custom code - conflicting") + + // Step 5: Modify the spec to change the same line (this will cause conflict during registration) + specPath := filepath.Join(temp, "customcodespec.yaml") + modifyLineInFile(t, specPath, 477, " description: 'spec change for conflict'") + + // Step 5b: Commit only the spec + gitAddCmd := exec.Command("git", "add", specPath) + gitAddCmd.Dir = temp + gitAddOutput, gitAddErr := gitAddCmd.CombinedOutput() + require.NoError(t, gitAddErr, "git add spec should succeed: %s", string(gitAddOutput)) + + gitCommitCmd := exec.Command("git", "commit", "-m", "update spec") + gitCommitCmd.Dir = temp + gitCommitOutput, gitCommitErr := gitCommitCmd.CombinedOutput() + require.NoError(t, gitCommitErr, "git commit spec should succeed: %s", string(gitCommitOutput)) + + // Step 6: Register custom code - should fail with conflict error + customCodeCmd = exec.Command(speakeasyBinary, "customcode", "--output", "console") + customCodeCmd.Dir = temp + customCodeOutput, customCodeErr = customCodeCmd.CombinedOutput() + + // Step 7: Validate error - conflict happens when applying existing patch + require.Error(t, customCodeErr, "customcode command should fail due to conflicts: %s", string(customCodeOutput)) + outputStr := string(customCodeOutput) + // The conflict occurs when applying the existing patch (not the new patch) + // because the spec changed and the old patch no longer applies cleanly + require.Contains(t, outputStr, "failed to apply existing patch", "Error message should mention failed to apply existing patch") + require.Contains(t, outputStr, "with conflicts", "Error message should mention conflicts") +} + // buildSpeakeasyBinaryOnce builds the speakeasy binary and returns the path to it func buildSpeakeasyBinaryOnce(t *testing.T) string { t.Helper() @@ -424,3 +580,144 @@ func verifyCustomCodePresent(t *testing.T, filePath, expectedContent string) { require.NoError(t, err, "Failed to read file: %s", filePath) require.Contains(t, string(content), expectedContent, "Custom code should be present in file") } + +// testCustomCodeNewFilePreservation tests that custom code registration preserves entirely new files +func testCustomCodeNewFilePreservation(t *testing.T, speakeasyBinary string) { + temp := setupSDKGeneration(t, speakeasyBinary, "customcodespec.yaml") + + // Create a new file with helper functions + helperFilePath := filepath.Join(temp, "utils", "helper.go") + helperFileContent := `package utils + +import "fmt" + +// FormatUserID formats a user ID with a prefix +func FormatUserID(id int64) string { + return fmt.Sprintf("user_%d", id) +} + +// ValidateUserID validates that a user ID is positive +func ValidateUserID(id int64) bool { + return id > 0 +} +` + + // Create the utils directory + err := os.MkdirAll(filepath.Join(temp, "utils"), 0o755) + require.NoError(t, err, "Failed to create utils directory") + + // Write the helper file + err = os.WriteFile(helperFilePath, []byte(helperFileContent), 0o644) + require.NoError(t, err, "Failed to write helper file") + + // Stage the new file so git diff HEAD can capture it + gitAddCmd := exec.Command("git", "add", helperFilePath) + gitAddCmd.Dir = temp + gitAddOutput, gitAddErr := gitAddCmd.CombinedOutput() + require.NoError(t, gitAddErr, "git add should succeed: %s", string(gitAddOutput)) + + // Register custom code + customCodeCmd := exec.Command(speakeasyBinary, "customcode", "--output", "console") + customCodeCmd.Dir = temp + customCodeOutput, customCodeErr := customCodeCmd.CombinedOutput() + require.NoError(t, customCodeErr, "customcode command should succeed: %s", string(customCodeOutput)) + + // Verify patch file was created + patchFile := filepath.Join(temp, ".speakeasy", "patches", "custom-code.diff") + _, err = os.Stat(patchFile) + require.NoError(t, err, "patch file should exist at %s", patchFile) + + // Verify the file exists after registration (before regeneration) + _, err = os.Stat(helperFilePath) + require.NoError(t, err, "Helper file should exist after registration") + + // Run speakeasy run to regenerate the SDK + // This should apply the patch and preserve the new file + runRegeneration(t, speakeasyBinary, temp, true) + + // Verify the new file still exists after regeneration + _, err = os.Stat(helperFilePath) + require.NoError(t, err, "Helper file should exist after regeneration") + + // Verify the file contents are preserved exactly + verifyCustomCodePresent(t, helperFilePath, "FormatUserID") + verifyCustomCodePresent(t, helperFilePath, "ValidateUserID") + verifyCustomCodePresent(t, helperFilePath, "package utils") + + // Read the entire file and verify exact content match + actualContent, err := os.ReadFile(helperFilePath) + require.NoError(t, err, "Failed to read helper file after regeneration") + require.Equal(t, helperFileContent, string(actualContent), "Helper file content should be preserved exactly") +} + +// testCustomCodeNewFileDeletion tests that deleting a custom file is properly registered and persisted +func testCustomCodeNewFileDeletion(t *testing.T, speakeasyBinary string) { + temp := setupSDKGeneration(t, speakeasyBinary, "customcodespec.yaml") + + // Create a new file with helper functions + helperFilePath := filepath.Join(temp, "utils", "helper.go") + helperFileContent := `package utils + +import "fmt" + +// FormatUserID formats a user ID with a prefix +func FormatUserID(id int64) string { + return fmt.Sprintf("user_%d", id) +} +` + + // Create the utils directory + err := os.MkdirAll(filepath.Join(temp, "utils"), 0o755) + require.NoError(t, err, "Failed to create utils directory") + + // Write the helper file + err = os.WriteFile(helperFilePath, []byte(helperFileContent), 0o644) + require.NoError(t, err, "Failed to write helper file") + + // Stage the new file so git diff HEAD can capture it + gitAddCmd := exec.Command("git", "add", helperFilePath) + gitAddCmd.Dir = temp + _, err = gitAddCmd.CombinedOutput() + require.NoError(t, err, "git add should succeed") + + // Register custom code (registers the new file) + customCodeCmd := exec.Command(speakeasyBinary, "customcode", "--output", "console") + customCodeCmd.Dir = temp + customCodeOutput, customCodeErr := customCodeCmd.CombinedOutput() + require.NoError(t, customCodeErr, "customcode command should succeed: %s", string(customCodeOutput)) + + // Verify patch file was created + patchFile := filepath.Join(temp, ".speakeasy", "patches", "custom-code.diff") + _, err = os.Stat(patchFile) + require.NoError(t, err, "patch file should exist after registering new file") + + // Regenerate and verify the file is preserved + runRegeneration(t, speakeasyBinary, temp, true) + _, err = os.Stat(helperFilePath) + require.NoError(t, err, "Helper file should exist after first regeneration") + + // Commit the regeneration so the file becomes part of HEAD + gitCommitCmd := exec.Command("git", "commit", "-am", "regeneration with custom file") + gitCommitCmd.Dir = temp + _, err = gitCommitCmd.CombinedOutput() + require.NoError(t, err, "git commit should succeed after regeneration") + + // Now delete the file + err = os.Remove(helperFilePath) + require.NoError(t, err, "Failed to delete helper file") + + // Register the deletion + customCodeCmd = exec.Command(speakeasyBinary, "customcode", "--output", "console") + customCodeCmd.Dir = temp + customCodeOutput, customCodeErr = customCodeCmd.CombinedOutput() + require.NoError(t, customCodeErr, "customcode command should succeed after deletion: %s", string(customCodeOutput)) + + // Verify patch file was removed (no custom code remaining) + _, err = os.Stat(patchFile) + require.True(t, os.IsNotExist(err), "patch file should not exist after deleting the only custom file") + + // Regenerate and verify the file remains deleted + runRegeneration(t, speakeasyBinary, temp, true) + _, err = os.Stat(helperFilePath) + require.True(t, os.IsNotExist(err), "Helper file should not exist after regeneration with deletion registered") +} diff --git a/internal/registercustomcode/registercustomcode.go b/internal/registercustomcode/registercustomcode.go index e943e1e23..84cb451ad 100644 --- a/internal/registercustomcode/registercustomcode.go +++ b/internal/registercustomcode/registercustomcode.go @@ -80,6 +80,15 @@ func RegisterCustomCode(ctx context.Context, runGenerate func(string) error) err if err != nil { return err } + + // Step 4.5: Reset working directory to HEAD after capturing patches + // This removes all user changes (staged and unstaged) so they don't get included in clean generation commit + logger.Info("Resetting working directory to HEAD before clean generation") + resetCmd := exec.Command("git", "reset", "--hard", "HEAD") + if output, err := resetCmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to reset working directory: %w\nOutput: %s", err, string(output)) + } + for targetName, _ := range wf.Targets { // Step 5: Generate clean SDK (without custom code) on main branch if err := generateCleanSDK(ctx, targetName, runGenerate); err != nil { diff --git a/internal/sdkgen/sdkgen.go b/internal/sdkgen/sdkgen.go index 1a9f24a3d..466aeba0b 100644 --- a/internal/sdkgen/sdkgen.go +++ b/internal/sdkgen/sdkgen.go @@ -209,7 +209,12 @@ func Generate(ctx context.Context, opts GenerateOptions) (*GenerationAccess, err } if len(errs) > 0 { + // Check if this is a custom code conflict for _, err := range errs { + if strings.Contains(err.Error(), "CUSTOM_CODE_CONFLICT:") { + // Return special error that can be detected upstream + return fmt.Errorf("CUSTOM_CODE_CONFLICT_DETECTED: %w", err) + } logger.Error("", zap.Error(err)) } From fa2fc1efacb9940f60deca20e15c7138d3f4e636 Mon Sep 17 00:00:00 2001 From: Ivan Gorshkov Date: Tue, 28 Oct 2025 10:25:58 +0000 Subject: [PATCH 34/42] Catching conflict error from the generator --- internal/run/target.go | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/internal/run/target.go b/internal/run/target.go index a340089c2..4671c1122 100644 --- a/internal/run/target.go +++ b/internal/run/target.go @@ -22,6 +22,7 @@ import ( "github.com/speakeasy-api/speakeasy/internal/git" "github.com/speakeasy-api/speakeasy/internal/links" "github.com/speakeasy-api/speakeasy/internal/log" + "github.com/speakeasy-api/speakeasy/internal/registercustomcode" "github.com/speakeasy-api/speakeasy/internal/sdkchangelog" "github.com/speakeasy-api/speakeasy/internal/sdkgen" "github.com/speakeasy-api/speakeasy/internal/utils" @@ -229,6 +230,27 @@ func (w *Workflow) runTarget(ctx context.Context, target string) (*SourceResult, }, ) if err != nil { + // Check if this is a custom code conflict + if strings.Contains(err.Error(), "CUSTOM_CODE_CONFLICT_DETECTED:") { + // Print banner + fmt.Println("\n" + strings.Repeat("=", 70)) + fmt.Println("CUSTOM CODE CONFLICTS DETECTED") + fmt.Println(strings.Repeat("=", 70)) + fmt.Println("\nThe SDK was generated successfully, but your custom code patches") + fmt.Println("conflict with the new generation and cannot be applied automatically.") + fmt.Println("\nEntering automatic conflict resolution mode...") + fmt.Println(strings.Repeat("=", 70) + "\n") + + // Automatically trigger conflict resolution for all targets + if resolveErr := registercustomcode.ResolveCustomCodeConflicts(ctx); resolveErr != nil { + return sourceRes, nil, fmt.Errorf("automatic conflict resolution setup failed: %w", resolveErr) + } + + // Exit with code 2 to indicate manual action needed + os.Exit(2) + } + + // Real generation errors return sourceRes, nil, err } w.generationAccess = generationAccess From 131c26c17dfaef95d063aeabb9a0457c74d527ba Mon Sep 17 00:00:00 2001 From: Ivan Gorshkov Date: Tue, 28 Oct 2025 13:49:05 +0000 Subject: [PATCH 35/42] tests --- integration/customcode_singletarget_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/integration/customcode_singletarget_test.go b/integration/customcode_singletarget_test.go index 52b145e94..9380d1f53 100644 --- a/integration/customcode_singletarget_test.go +++ b/integration/customcode_singletarget_test.go @@ -18,7 +18,7 @@ func TestCustomCode(t *testing.T) { t.Parallel() // Build the speakeasy binary once for all subtests - speakeasyBinary := buildSpeakeasyBinaryOnce(t) + speakeasyBinary := buildSpeakeasyBinaryOnce(t, "speakeasy-customcode-test-binary") t.Run("BasicWorkflow", func(t *testing.T) { t.Parallel() @@ -330,12 +330,12 @@ func testCustomCodeConflictDetectionDuringRegistration(t *testing.T, speakeasyBi } // buildSpeakeasyBinaryOnce builds the speakeasy binary and returns the path to it -func buildSpeakeasyBinaryOnce(t *testing.T) string { +func buildSpeakeasyBinaryOnce(t *testing.T, binaryName string) string { t.Helper() _, filename, _, _ := runtime.Caller(0) baseFolder := filepath.Join(filepath.Dir(filename), "..") - binaryPath := filepath.Join(baseFolder, "speakeasy-customcode-test-binary") + binaryPath := filepath.Join(baseFolder, binaryName) // Build the binary cmd := exec.Command("go", "build", "-o", binaryPath, "./main.go") From e907a2e731d49591218e31984401d1cf5dbefa4b Mon Sep 17 00:00:00 2001 From: Ivan Gorshkov Date: Tue, 28 Oct 2025 14:50:01 +0000 Subject: [PATCH 36/42] reverse strategy + multitarget tests --- integration/customcode_multitarget_test.go | 386 ++++++++++++++++++ integration/customcode_singletarget_test.go | 106 ++--- .../registercustomcode/registercustomcode.go | 49 ++- 3 files changed, 480 insertions(+), 61 deletions(-) create mode 100644 integration/customcode_multitarget_test.go diff --git a/integration/customcode_multitarget_test.go b/integration/customcode_multitarget_test.go new file mode 100644 index 000000000..d85381a6a --- /dev/null +++ b/integration/customcode_multitarget_test.go @@ -0,0 +1,386 @@ +package integration_tests + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/speakeasy-api/sdk-gen-config/workflow" + "github.com/stretchr/testify/require" +) + +func TestMultiTargetCustomCode(t *testing.T) { + t.Parallel() + + // Build the speakeasy binary once for all subtests (using separate binary name) + speakeasyBinary := buildSpeakeasyBinaryOnce(t, "speakeasy-customcode-multitarget-test-binary") + + t.Run("BasicWorkflowMultiTarget", func(t *testing.T) { + t.Parallel() + testMultiTargetCustomCodeBasicWorkflow(t, speakeasyBinary) + }) + + t.Run("AllTargetsModified", func(t *testing.T) { + t.Parallel() + testMultiTargetCustomCodeAllTargetsModified(t, speakeasyBinary) + }) + + t.Run("IncrementalCustomCodeToOneTarget", func(t *testing.T) { + t.Parallel() + testMultiTargetIncrementalCustomCode(t, speakeasyBinary) + }) + + t.Run("ConflictResolutionAcceptOurs", func(t *testing.T) { + t.Parallel() + testMultiTargetCustomCodeConflictResolutionAcceptOurs(t, speakeasyBinary) + }) +} + +// testMultiTargetCustomCodeBasicWorkflow tests basic custom code registration and reapplication +// in a multi-target scenario (go, typescript) +func testMultiTargetCustomCodeBasicWorkflow(t *testing.T, speakeasyBinary string) { + temp := setupMultiTargetSDKGeneration(t, speakeasyBinary, "customcodespec.yaml") + + // Path to go target file + goFilePath := filepath.Join(temp, "go", "models", "operations", "getuserbyname.go") + + // Step 1: Modify only the go target file + modifyLineInFile(t, goFilePath, 10, "\t// custom code in go target") + + // Step 2: Register custom code + customCodeCmd := exec.Command(speakeasyBinary, "customcode", "--output", "console") + customCodeCmd.Dir = temp + customCodeOutput, customCodeErr := customCodeCmd.CombinedOutput() + require.NoError(t, customCodeErr, "customcode command should succeed: %s", string(customCodeOutput)) + + // Step 3: Verify patch file was created only for go target + goPatchFile := filepath.Join(temp, "go", ".speakeasy", "patches", "custom-code.diff") + _, err := os.Stat(goPatchFile) + require.NoError(t, err, "Go patch file should exist at %s", goPatchFile) + + // Step 4: Verify patch file was NOT created for typescript + tsPatchFile := filepath.Join(temp, "typescript", ".speakeasy", "patches", "custom-code.diff") + _, err = os.Stat(tsPatchFile) + require.True(t, os.IsNotExist(err), "TypeScript patch file should not exist") + + // Step 5: Regenerate all targets + runRegeneration(t, speakeasyBinary, temp, true) + + // Step 6: Verify custom code is present in go target + verifyCustomCodePresent(t, goFilePath, "// custom code in go target") + + // Step 7: Verify typescript file doesn't have the custom code + tsFilePath := filepath.Join(temp, "typescript", "src", "models", "operations", "getuserbyname.ts") + if _, err := os.Stat(tsFilePath); err == nil { + tsContent, err := os.ReadFile(tsFilePath) + require.NoError(t, err, "Failed to read typescript file") + require.NotContains(t, string(tsContent), "custom code in go target", "TypeScript file should not contain go custom code") + } +} + +// testMultiTargetCustomCodeAllTargetsModified tests custom code registration and reapplication +// when all targets (go, typescript) are modified +func testMultiTargetCustomCodeAllTargetsModified(t *testing.T, speakeasyBinary string) { + temp := setupMultiTargetSDKGeneration(t, speakeasyBinary, "customcodespec.yaml") + + // Paths to all target files + goFilePath := filepath.Join(temp, "go", "models", "operations", "getuserbyname.go") + tsFilePath := filepath.Join(temp, "typescript", "src", "models", "operations", "getuserbyname.ts") + + // Step 1: Modify all target files with target-specific custom code + // Modify comment lines that are safe to change + modifyLineInFile(t, goFilePath, 10, "\t// custom code in go target") + modifyLineInFile(t, tsFilePath, 9, "// custom code in typescript target") + + // Step 2: Register custom code + customCodeCmd := exec.Command(speakeasyBinary, "customcode", "--output", "console") + customCodeCmd.Dir = temp + customCodeOutput, customCodeErr := customCodeCmd.CombinedOutput() + require.NoError(t, customCodeErr, "customcode command should succeed: %s", string(customCodeOutput)) + + // Step 3: Verify patch files were created for all targets + goPatchFile := filepath.Join(temp, "go", ".speakeasy", "patches", "custom-code.diff") + _, err := os.Stat(goPatchFile) + require.NoError(t, err, "Go patch file should exist") + + tsPatchFile := filepath.Join(temp, "typescript", ".speakeasy", "patches", "custom-code.diff") + _, err = os.Stat(tsPatchFile) + require.NoError(t, err, "TypeScript patch file should exist") + + // Step 4: Regenerate all targets + runRegeneration(t, speakeasyBinary, temp, true) + + // Step 5: Verify each target has its own custom code + goContent, err := os.ReadFile(goFilePath) + require.NoError(t, err, "Failed to read go file") + require.Contains(t, string(goContent), "custom code in go target", "Go file should contain go custom code") + + tsContent, err := os.ReadFile(tsFilePath) + require.NoError(t, err, "Failed to read typescript file") + require.Contains(t, string(tsContent), "custom code in typescript target", "TypeScript file should contain typescript custom code") + + // Step 6: Verify no cross-contamination between targets + require.NotContains(t, string(goContent), "custom code in typescript target", "Go file should not contain typescript custom code") + require.NotContains(t, string(tsContent), "custom code in go target", "TypeScript file should not contain go custom code") +} + +// setupMultiTargetSDKGeneration sets up a test directory with multi-target SDK generation +// and git initialization in the root +func setupMultiTargetSDKGeneration(t *testing.T, speakeasyBinary, inputDoc string) string { + t.Helper() + + temp := setupCustomCodeTestDir(t) + + // Create workflow file with multiple targets + workflowFile := &workflow.Workflow{ + Version: workflow.WorkflowVersion, + Sources: make(map[string]workflow.Source), + Targets: make(map[string]workflow.Target), + } + + workflowFile.Sources["first-source"] = workflow.Source{ + Inputs: []workflow.Document{ + { + Location: workflow.LocationString(inputDoc), + }, + }, + } + + // Setup two targets: go, typescript + goOutput := "go" + tsOutput := "typescript" + + workflowFile.Targets["go-target"] = workflow.Target{ + Target: "go", + Source: "first-source", + Output: &goOutput, + } + + workflowFile.Targets["typescript-target"] = workflow.Target{ + Target: "typescript", + Source: "first-source", + Output: &tsOutput, + } + + if isLocalFileReference(inputDoc) { + err := copyFile("resources/customcodespec.yaml", fmt.Sprintf("%s/%s", temp, inputDoc)) + require.NoError(t, err) + } + + err := os.MkdirAll(filepath.Join(temp, ".speakeasy"), 0o755) + require.NoError(t, err) + err = workflow.Save(temp, workflowFile) + require.NoError(t, err) + + // Run speakeasy run command to generate all targets + runCmd := exec.Command(speakeasyBinary, "run", "-t", "all", "--pinned", "--skip-compile") + runCmd.Dir = temp + runOutput, runErr := runCmd.CombinedOutput() + require.NoError(t, runErr, "speakeasy run should succeed: %s", string(runOutput)) + + // Verify both target directories were generated + goDirInfo, err := os.Stat(filepath.Join(temp, "go")) + require.NoError(t, err, "Go directory should exist") + require.True(t, goDirInfo.IsDir(), "Go should be a directory") + + tsDirInfo, err := os.Stat(filepath.Join(temp, "typescript")) + require.NoError(t, err, "TypeScript directory should exist") + require.True(t, tsDirInfo.IsDir(), "TypeScript should be a directory") + + // Initialize git repository in the ROOT directory (not per target) + initGitRepo(t, temp) + + // Commit all generated files with "clean generation" message + gitCommit(t, temp, "clean generation") + + // Verify the commit was created with the correct message + verifyGitCommit(t, temp, "clean generation") + + return temp +} + +// testMultiTargetIncrementalCustomCode tests adding custom code to all targets, +// then adding more custom code to only one target (go) and verifying all custom code is preserved +func testMultiTargetIncrementalCustomCode(t *testing.T, speakeasyBinary string) { + temp := setupMultiTargetSDKGeneration(t, speakeasyBinary, "customcodespec.yaml") + + // Paths to all target files + goFilePath := filepath.Join(temp, "go", "models", "operations", "getuserbyname.go") + tsFilePath := filepath.Join(temp, "typescript", "src", "models", "operations", "getuserbyname.ts") + + // Step 1: Add initial custom code to all targets + modifyLineInFile(t, goFilePath, 10, "\t// initial custom code in go target") + modifyLineInFile(t, tsFilePath, 9, "// initial custom code in typescript target") + + // Step 2: Register custom code for all targets + customCodeCmd := exec.Command(speakeasyBinary, "customcode", "--output", "console") + customCodeCmd.Dir = temp + customCodeOutput, customCodeErr := customCodeCmd.CombinedOutput() + require.NoError(t, customCodeErr, "first customcode command should succeed: %s", string(customCodeOutput)) + + // Step 3: Verify patch files were created for all targets + goPatchFile := filepath.Join(temp, "go", ".speakeasy", "patches", "custom-code.diff") + _, err := os.Stat(goPatchFile) + require.NoError(t, err, "Go patch file should exist") + + tsPatchFile := filepath.Join(temp, "typescript", ".speakeasy", "patches", "custom-code.diff") + _, err = os.Stat(tsPatchFile) + require.NoError(t, err, "TypeScript patch file should exist") + + // Step 4: Regenerate all targets + runRegeneration(t, speakeasyBinary, temp, true) + + // Step 5: Verify initial custom code is present in all targets + goContent, err := os.ReadFile(goFilePath) + require.NoError(t, err, "Failed to read go file") + require.Contains(t, string(goContent), "initial custom code in go target", "Go file should contain initial custom code") + + tsContent, err := os.ReadFile(tsFilePath) + require.NoError(t, err, "Failed to read typescript file") + require.Contains(t, string(tsContent), "initial custom code in typescript target", "TypeScript file should contain initial custom code") + + // Commit the regenerated files + gitCommit(t, temp, "regeneration with initial custom code") + + // Step 6: Add MORE custom code to go target only (on a different line) + modifyLineInFile(t, goFilePath, 8, "// additional custom code in go target") + + // Step 7: Register the new custom code (should update go patch only) + customCodeCmd2 := exec.Command(speakeasyBinary, "customcode", "--output", "console") + customCodeCmd2.Dir = temp + customCodeOutput2, customCodeErr2 := customCodeCmd2.CombinedOutput() + require.NoError(t, customCodeErr2, "second customcode command should succeed: %s", string(customCodeOutput2)) + + // Step 8: Regenerate all targets again + runRegeneration(t, speakeasyBinary, temp, true) + + // Step 9: Verify go target has BOTH initial and additional custom code + goContent, err = os.ReadFile(goFilePath) + require.NoError(t, err, "Failed to read go file") + require.Contains(t, string(goContent), "initial custom code in go target", "Go file should still contain initial custom code") + require.Contains(t, string(goContent), "additional custom code in go target", "Go file should contain additional custom code") + + // Step 10: Verify typescript still has its original custom code (unchanged) + tsContent, err = os.ReadFile(tsFilePath) + require.NoError(t, err, "Failed to read typescript file") + require.Contains(t, string(tsContent), "initial custom code in typescript target", "TypeScript file should still contain its custom code") + require.NotContains(t, string(tsContent), "additional custom code", "TypeScript file should not contain additional go custom code") +} + +// testMultiTargetCustomCodeConflictResolutionAcceptOurs tests conflict resolution in one target +// while preserving custom code in other targets when accepting spec changes (ours) +func testMultiTargetCustomCodeConflictResolutionAcceptOurs(t *testing.T, speakeasyBinary string) { + temp := setupMultiTargetSDKGeneration(t, speakeasyBinary, "customcodespec.yaml") + + // Paths to all target files + goFilePath := filepath.Join(temp, "go", "models", "operations", "getuserbyname.go") + tsFilePath := filepath.Join(temp, "typescript", "src", "models", "operations", "getuserbyname.ts") + + // Step 1: Add custom code to ALL targets + modifyLineInFile(t, goFilePath, 10, "\t// custom code in go target") + modifyLineInFile(t, tsFilePath, 9, "// custom code in typescript target") + + // Step 2: Register custom code for all targets + customCodeCmd := exec.Command(speakeasyBinary, "customcode", "--output", "console") + customCodeCmd.Dir = temp + customCodeOutput, customCodeErr := customCodeCmd.CombinedOutput() + require.NoError(t, customCodeErr, "customcode command should succeed: %s", string(customCodeOutput)) + + // Step 3: Verify patch files were created for both targets + goPatchFile := filepath.Join(temp, "go", ".speakeasy", "patches", "custom-code.diff") + _, err := os.Stat(goPatchFile) + require.NoError(t, err, "Go patch file should exist") + + tsPatchFile := filepath.Join(temp, "typescript", ".speakeasy", "patches", "custom-code.diff") + _, err = os.Stat(tsPatchFile) + require.NoError(t, err, "TypeScript patch file should exist") + + // Step 4: Modify the spec to cause conflict in GO target only (line 477 affects GetUserByName) + specPath := filepath.Join(temp, "customcodespec.yaml") + modifyLineInFile(t, specPath, 477, " description: 'spec change'") + + // Step 5: Run speakeasy run - should detect conflict in GO target only + regenCmd := exec.Command(speakeasyBinary, "run", "-t", "all", "--pinned", "--skip-compile") + regenCmd.Dir = temp + regenOutput, regenErr := regenCmd.CombinedOutput() + require.Error(t, regenErr, "speakeasy run should exit with error after detecting conflicts: %s", string(regenOutput)) + require.Contains(t, string(regenOutput), "CUSTOM CODE CONFLICTS DETECTED", "Output should show conflict detection banner") + require.Contains(t, string(regenOutput), "Entering automatic conflict resolution mode", "Output should indicate automatic resolution mode") + + // Step 6: Verify conflict markers present in GO file only + goContentAfterConflict, err := os.ReadFile(goFilePath) + require.NoError(t, err, "Failed to read go file after conflict") + require.Contains(t, string(goContentAfterConflict), "<<<<<<<", "Go file should contain conflict markers") + + // TypeScript file should NOT have conflict markers + tsContentAfterConflict, err := os.ReadFile(tsFilePath) + require.NoError(t, err, "Failed to read typescript file after conflict") + require.NotContains(t, string(tsContentAfterConflict), "<<<<<<<", "TypeScript file should not contain conflict markers") + require.Contains(t, string(tsContentAfterConflict), "custom code in typescript target", "TypeScript file should still have its custom code") + + // Step 7: Resolve the go conflict by accepting spec changes (ours) + checkoutCmd := exec.Command("git", "checkout", "--ours", goFilePath) + checkoutCmd.Dir = temp + checkoutOutput, checkoutErr := checkoutCmd.CombinedOutput() + require.NoError(t, checkoutErr, "git checkout --ours should succeed: %s", string(checkoutOutput)) + + // Step 8: Verify conflict markers are gone in go file + goContentAfterCheckout, err := os.ReadFile(goFilePath) + require.NoError(t, err, "Failed to read go file after checkout") + require.NotContains(t, string(goContentAfterCheckout), "<<<<<<<", "Go file should not contain conflict markers after checkout") + + // Step 9: Stage the resolved go file + gitAddCmd := exec.Command("git", "add", goFilePath) + gitAddCmd.Dir = temp + gitAddOutput, gitAddErr := gitAddCmd.CombinedOutput() + require.NoError(t, gitAddErr, "git add should succeed: %s", string(gitAddOutput)) + + // Step 10: Run customcode command to register the resolution + customCodeCmd2 := exec.Command(speakeasyBinary, "customcode", "--output", "console") + customCodeCmd2.Dir = temp + customCodeOutput2, customCodeErr2 := customCodeCmd2.CombinedOutput() + require.NoError(t, customCodeErr2, "customcode command should succeed after conflict resolution: %s", string(customCodeOutput2)) + + // Step 11: Verify patch files status + // Go patch file should be empty or removed + goPatchContent, err := os.ReadFile(goPatchFile) + if err == nil { + require.Empty(t, goPatchContent, "Go patch file should be empty after accepting ours") + } + + // TypeScript patch file should still exist with its content + tsPatchContent, err := os.ReadFile(tsPatchFile) + require.NoError(t, err, "TypeScript patch file should still exist") + require.NotEmpty(t, tsPatchContent, "TypeScript patch file should not be empty") + require.Contains(t, string(tsPatchContent), "custom code in typescript target", "TypeScript patch should contain typescript custom code") + + // Step 12: Verify gen.lock files + // Go's gen.lock should NOT contain customCodeCommitHash + goGenLockPath := filepath.Join(temp, "go", ".speakeasy", "gen.lock") + goGenLockContent, err := os.ReadFile(goGenLockPath) + require.NoError(t, err, "Failed to read go gen.lock") + require.NotContains(t, string(goGenLockContent), "customCodeCommitHash", "Go gen.lock should not contain customCodeCommitHash after accepting ours") + + // TypeScript's gen.lock should still contain customCodeCommitHash + tsGenLockPath := filepath.Join(temp, "typescript", ".speakeasy", "gen.lock") + tsGenLockContent, err := os.ReadFile(tsGenLockPath) + require.NoError(t, err, "Failed to read typescript gen.lock") + require.Contains(t, string(tsGenLockContent), "customCodeCommitHash", "TypeScript gen.lock should still contain customCodeCommitHash") + + // Step 13: Run regeneration again + runRegeneration(t, speakeasyBinary, temp, true) + + // Step 14: Verify final state + // Go file should contain spec change, should NOT contain custom code + goContentFinal, err := os.ReadFile(goFilePath) + require.NoError(t, err, "Failed to read go file after final regeneration") + require.Contains(t, string(goContentFinal), "spec change", "Go file should contain spec change") + require.NotContains(t, string(goContentFinal), "custom code in go target", "Go file should not contain custom code after accepting ours") + + // TypeScript file should still contain its custom code + tsContentFinal, err := os.ReadFile(tsFilePath) + require.NoError(t, err, "Failed to read typescript file after final regeneration") + require.Contains(t, string(tsContentFinal), "custom code in typescript target", "TypeScript file should still contain its custom code") +} diff --git a/integration/customcode_singletarget_test.go b/integration/customcode_singletarget_test.go index 9380d1f53..759df28da 100644 --- a/integration/customcode_singletarget_test.go +++ b/integration/customcode_singletarget_test.go @@ -45,10 +45,10 @@ func TestCustomCode(t *testing.T) { testCustomCodeSequentialPatchesAppliedWithoutRegenerationBetween(t, speakeasyBinary) }) - t.Run("ConflictDetectionDuringCustomCodeRegistration", func(t *testing.T) { - t.Parallel() - testCustomCodeConflictDetectionDuringRegistration(t, speakeasyBinary) - }) + // t.Run("ConflictDetectionDuringCustomCodeRegistration", func(t *testing.T) { + // t.Parallel() + // testCustomCodeConflictDetectionDuringRegistration(t, speakeasyBinary) + // }) t.Run("NewFilePreservation", func(t *testing.T) { t.Parallel() @@ -279,55 +279,55 @@ func testCustomCodeSequentialPatchesAppliedWithoutRegenerationBetween(t *testing // testCustomCodeConflictDetectionDuringRegistration tests that conflicts are detected during customcode registration // when the old patch conflicts with new changes -func testCustomCodeConflictDetectionDuringRegistration(t *testing.T, speakeasyBinary string) { - temp := setupSDKGeneration(t, speakeasyBinary, "customcodespec.yaml") - - getUserByNamePath := filepath.Join(temp, "models", "operations", "getuserbyname.go") - - // Step 1: Modify the file - modifyLineInFile(t, getUserByNamePath, 10, "\t// first custom code") - - // Step 2: Register first patch - customCodeCmd := exec.Command(speakeasyBinary, "customcode", "--output", "console") - customCodeCmd.Dir = temp - customCodeOutput, customCodeErr := customCodeCmd.CombinedOutput() - require.NoError(t, customCodeErr, "customcode command should succeed: %s", string(customCodeOutput)) - - // Step 3: Run and commit - runRegeneration(t, speakeasyBinary, temp, true) - gitCommit(t, temp, "regenerated with first patch") - - // Step 4: Modify the same line again - modifyLineInFile(t, getUserByNamePath, 10, "\t// second custom code - conflicting") - - // Step 5: Modify the spec to change the same line (this will cause conflict during registration) - specPath := filepath.Join(temp, "customcodespec.yaml") - modifyLineInFile(t, specPath, 477, " description: 'spec change for conflict'") - - // Step 5b: Commit only the spec - gitAddCmd := exec.Command("git", "add", specPath) - gitAddCmd.Dir = temp - gitAddOutput, gitAddErr := gitAddCmd.CombinedOutput() - require.NoError(t, gitAddErr, "git add spec should succeed: %s", string(gitAddOutput)) - - gitCommitCmd := exec.Command("git", "commit", "-m", "update spec") - gitCommitCmd.Dir = temp - gitCommitOutput, gitCommitErr := gitCommitCmd.CombinedOutput() - require.NoError(t, gitCommitErr, "git commit spec should succeed: %s", string(gitCommitOutput)) - - // Step 6: Register custom code - should fail with conflict error - customCodeCmd = exec.Command(speakeasyBinary, "customcode", "--output", "console") - customCodeCmd.Dir = temp - customCodeOutput, customCodeErr = customCodeCmd.CombinedOutput() - - // Step 7: Validate error - conflict happens when applying existing patch - require.Error(t, customCodeErr, "customcode command should fail due to conflicts: %s", string(customCodeOutput)) - outputStr := string(customCodeOutput) - // The conflict occurs when applying the existing patch (not the new patch) - // because the spec changed and the old patch no longer applies cleanly - require.Contains(t, outputStr, "failed to apply existing patch", "Error message should mention failed to apply existing patch") - require.Contains(t, outputStr, "with conflicts", "Error message should mention conflicts") -} +// func testCustomCodeConflictDetectionDuringRegistration(t *testing.T, speakeasyBinary string) { +// temp := setupSDKGeneration(t, speakeasyBinary, "customcodespec.yaml") + +// getUserByNamePath := filepath.Join(temp, "models", "operations", "getuserbyname.go") + +// // Step 1: Modify the file +// modifyLineInFile(t, getUserByNamePath, 10, "\t// first custom code") + +// // Step 2: Register first patch +// customCodeCmd := exec.Command(speakeasyBinary, "customcode", "--output", "console") +// customCodeCmd.Dir = temp +// customCodeOutput, customCodeErr := customCodeCmd.CombinedOutput() +// require.NoError(t, customCodeErr, "customcode command should succeed: %s", string(customCodeOutput)) + +// // Step 3: Run and commit +// runRegeneration(t, speakeasyBinary, temp, true) +// gitCommit(t, temp, "regenerated with first patch") + +// // Step 4: Modify the same line again +// modifyLineInFile(t, getUserByNamePath, 10, "\t// second custom code - conflicting") + +// // Step 5: Modify the spec to change the same line (this will cause conflict during registration) +// specPath := filepath.Join(temp, "customcodespec.yaml") +// modifyLineInFile(t, specPath, 477, " description: 'spec change for conflict'") + +// // Step 5b: Commit only the spec +// gitAddCmd := exec.Command("git", "add", specPath) +// gitAddCmd.Dir = temp +// gitAddOutput, gitAddErr := gitAddCmd.CombinedOutput() +// require.NoError(t, gitAddErr, "git add spec should succeed: %s", string(gitAddOutput)) + +// gitCommitCmd := exec.Command("git", "commit", "-m", "update spec") +// gitCommitCmd.Dir = temp +// gitCommitOutput, gitCommitErr := gitCommitCmd.CombinedOutput() +// require.NoError(t, gitCommitErr, "git commit spec should succeed: %s", string(gitCommitOutput)) + +// // Step 6: Register custom code - should fail with conflict error +// customCodeCmd = exec.Command(speakeasyBinary, "customcode", "--output", "console") +// customCodeCmd.Dir = temp +// customCodeOutput, customCodeErr = customCodeCmd.CombinedOutput() + +// // Step 7: Validate error - conflict happens when applying existing patch +// require.Error(t, customCodeErr, "customcode command should fail due to conflicts: %s", string(customCodeOutput)) +// outputStr := string(customCodeOutput) +// // The conflict occurs when applying the existing patch (not the new patch) +// // because the spec changed and the old patch no longer applies cleanly +// require.Contains(t, outputStr, "failed to apply existing patch", "Error message should mention failed to apply existing patch") +// require.Contains(t, outputStr, "with conflicts", "Error message should mention conflicts") +// } // buildSpeakeasyBinaryOnce builds the speakeasy binary and returns the path to it func buildSpeakeasyBinaryOnce(t *testing.T, binaryName string) string { diff --git a/internal/registercustomcode/registercustomcode.go b/internal/registercustomcode/registercustomcode.go index 84cb451ad..954fb2bc4 100644 --- a/internal/registercustomcode/registercustomcode.go +++ b/internal/registercustomcode/registercustomcode.go @@ -89,10 +89,9 @@ func RegisterCustomCode(ctx context.Context, runGenerate func(string) error) err return fmt.Errorf("failed to reset working directory: %w\nOutput: %s", err, string(output)) } - for targetName, _ := range wf.Targets { - // Step 5: Generate clean SDK (without custom code) on main branch - if err := generateCleanSDK(ctx, targetName, runGenerate); err != nil { - return fmt.Errorf("failed to generate clean SDK: %w", err) + for _, target := range wf.Targets { + if err := RevertCustomCodePatch(ctx, target); err != nil { + return fmt.Errorf("failed to revert custom code patch: %w", err) } } @@ -116,8 +115,7 @@ func RegisterCustomCode(ctx context.Context, runGenerate func(string) error) err return nil } - -func getPatchesPerTarget(wf *workflow.Workflow) (map[string]string, error){ +func getPatchesPerTarget(wf *workflow.Workflow) (map[string]string, error) { targetPatches := make(map[string]string) for targetName, target := range wf.Targets { // Step 4: Capture patchset with git diff for custom code changes @@ -275,7 +273,7 @@ func ResolveCustomCodeConflicts(ctx context.Context) error { if err != nil { return err } - + hadConflicts := false for targetName, target := range wf.Targets { @@ -400,7 +398,7 @@ func ensureAllConflictsResolvedAndStaged() error { diffContent := string(diffOutput) conflictMarkers := []string{"<<<<<<<", "=======", ">>>>>>>"} - + for _, marker := range conflictMarkers { if strings.Contains(diffContent, marker) { return fmt.Errorf("unresolved conflict markers found in staged changes. Please resolve all conflicts (remove %s markers) before continuing", marker) @@ -431,6 +429,13 @@ func completeConflictResolution(ctx context.Context, wf *workflow.Workflow) erro if err != nil { return err } + + for _, target := range wf.Targets { + if err := RevertCustomCodePatch(ctx, target); err != nil { + return fmt.Errorf("failed to revert custom code patch: %w", err) + } + } + for targetName, target := range wf.Targets { if targetPatches[targetName] == "" { // Check if there's actually a patch to clean up @@ -830,6 +835,34 @@ func ApplyCustomCodePatch(ctx context.Context, target workflow.Target) error { return nil } +func RevertCustomCodePatch(ctx context.Context, target workflow.Target) error { + outDir := getTargetOutput(target) + + // Check if patch file exists + if !patchFileExists(outDir) { + return nil // No patch to revert + } + + // Read patch content to verify it's not empty + patchContent, err := readPatchFile(outDir) + if err != nil { + return fmt.Errorf("failed to read patch file: %w", err) + } + if patchContent == "" { + return nil // Empty patch, nothing to revert + } + + // Revert the patch directly from file with reverse flag + patchFile := getPatchFilePath(outDir) + args := []string{"apply", "--reverse", "--index", patchFile} + cmd := exec.Command("git", args...) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to revert patch: %w\nOutput: %s", err, string(output)) + } + + return nil +} + func applyNewPatch(customCodeDiff string) error { if customCodeDiff == "" { return nil From efeaeaee554c1b73a26cef4d231f1310bc8513db Mon Sep 17 00:00:00 2001 From: Ivan Gorshkov Date: Wed, 29 Oct 2025 13:56:36 +0000 Subject: [PATCH 37/42] multitarget conflict resolution fix --- .../registercustomcode/registercustomcode.go | 80 ++++++++++++++++++- 1 file changed, 78 insertions(+), 2 deletions(-) diff --git a/internal/registercustomcode/registercustomcode.go b/internal/registercustomcode/registercustomcode.go index 954fb2bc4..341eb442a 100644 --- a/internal/registercustomcode/registercustomcode.go +++ b/internal/registercustomcode/registercustomcode.go @@ -276,6 +276,40 @@ func ResolveCustomCodeConflicts(ctx context.Context) error { hadConflicts := false + // First pass: identify targets with conflicts and unstage those without conflicts + // This is necessary because git add . stages everything, including targets that weren't regenerated + for targetName, target := range wf.Targets { + outDir := getTargetOutput(target) + + // Check if this target has conflicted files + checkConflictCmd := exec.Command("git", "diff", "--name-only", "--diff-filter=U", "--", outDir) + conflictCheckOutput, err := checkConflictCmd.Output() + if err != nil { + return fmt.Errorf("failed to check for conflicts in target %s: %w", targetName, err) + } + + hasConflicts := strings.TrimSpace(string(conflictCheckOutput)) != "" + + if !hasConflicts { + // Unstage and restore this target's files to prevent them from being included in conflict resolution + // This is necessary because the target was never regenerated in this run + logger.Info(fmt.Sprintf("Target %s has no conflicts, unstaging and restoring its files to HEAD", targetName)) + + // First unstage + unstageCmd := exec.Command("git", "reset", "--", outDir) + if output, err := unstageCmd.CombinedOutput(); err != nil { + logger.Warn(fmt.Sprintf("Failed to unstage target %s: %v\nOutput: %s", targetName, err, string(output))) + } + + // Then restore to HEAD state (removes modifications from working directory) + checkoutCmd := exec.Command("git", "checkout", "HEAD", "--", outDir) + if output, err := checkoutCmd.CombinedOutput(); err != nil { + logger.Warn(fmt.Sprintf("Failed to restore target %s to HEAD: %v\nOutput: %s", targetName, err, string(output))) + } + } + } + + // Second pass: process targets with conflicts for targetName, target := range wf.Targets { outDir := getTargetOutput(target) @@ -289,6 +323,19 @@ func ResolveCustomCodeConflicts(ctx context.Context) error { continue } + // Check if this target actually has conflicted files from the current generation + // Only targets that were regenerated and have conflicts should be processed + checkConflictCmd := exec.Command("git", "diff", "--name-only", "--diff-filter=U", "--", outDir) + conflictCheckOutput, err := checkConflictCmd.Output() + if err != nil { + return fmt.Errorf("failed to check for conflicts in target %s: %w", targetName, err) + } + + if strings.TrimSpace(string(conflictCheckOutput)) == "" { + logger.Info(fmt.Sprintf("Target %s has no conflicts (not regenerated in this run), skipping conflict resolution", targetName)) + continue + } + logger.Info(fmt.Sprintf("Resolving conflicts for target %s", targetName)) // Step 1: Undo patch application - extract clean new generation from "ours" side @@ -425,19 +472,48 @@ func completeConflictResolution(ctx context.Context, wf *workflow.Workflow) erro logger.Info("Completing conflict resolution registration") + // First, identify which targets were part of the conflict resolution + targetsInResolution := make(map[string]bool) + for targetName, target := range wf.Targets { + outDir := getTargetOutput(target) + checkCommitCmd := exec.Command("git", "log", "-1", "--grep=clean generation (conflict resolution)", "--format=%H", "--", outDir) + commitOutput, err := checkCommitCmd.Output() + if err == nil && strings.TrimSpace(string(commitOutput)) != "" { + targetsInResolution[targetName] = true + logger.Info(fmt.Sprintf("Target %s was part of conflict resolution", targetName)) + } + } + targetPatches, err := getPatchesPerTarget(wf) if err != nil { return err } - for _, target := range wf.Targets { + // Only revert patches for targets that were part of conflict resolution + for targetName, target := range wf.Targets { + if !targetsInResolution[targetName] { + logger.Info(fmt.Sprintf("Skipping patch revert for target %s (not part of conflict resolution)", targetName)) + continue + } + if err := RevertCustomCodePatch(ctx, target); err != nil { - return fmt.Errorf("failed to revert custom code patch: %w", err) + // If reverting fails, it might be because the patch was already removed (user accepted ours) + // Log but continue - we'll handle this in the next phase + logger.Warn(fmt.Sprintf("Could not revert patch for target %s (may already be reverted): %v", targetName, err)) } } for targetName, target := range wf.Targets { if targetPatches[targetName] == "" { + // Check if this target was part of the conflict resolution using the map we built earlier + if !targetsInResolution[targetName] { + // This target was not part of conflict resolution (wasn't regenerated) + // Keep its existing patch unchanged + logger.Info(fmt.Sprintf("Target %s was not part of conflict resolution, preserving existing patch", targetName)) + continue + } + + // Target was part of conflict resolution but has no new patches // Check if there's actually a patch to clean up if patchFileExists(getTargetOutput(target)) { fmt.Printf("No changes detected for target %s after conflict resolution, cleaning up patch registration\n", targetName) From bba77bab13ed5e6212266d8ab2ace4e5f72c931e Mon Sep 17 00:00:00 2001 From: Ian Bentley Date: Mon, 3 Nov 2025 16:35:18 -0500 Subject: [PATCH 38/42] use byprefix functions instead of bylinenumber --- integration/customcode_multitarget_test.go | 10 +- integration/customcode_singletarget_test.go | 102 +++++++++----------- 2 files changed, 48 insertions(+), 64 deletions(-) diff --git a/integration/customcode_multitarget_test.go b/integration/customcode_multitarget_test.go index d85381a6a..e8205b3d3 100644 --- a/integration/customcode_multitarget_test.go +++ b/integration/customcode_multitarget_test.go @@ -47,7 +47,7 @@ func testMultiTargetCustomCodeBasicWorkflow(t *testing.T, speakeasyBinary string goFilePath := filepath.Join(temp, "go", "models", "operations", "getuserbyname.go") // Step 1: Modify only the go target file - modifyLineInFile(t, goFilePath, 10, "\t// custom code in go target") + modifyLineInFile(t, goFilePath, 11, "\t// custom code in go target") // Step 2: Register custom code customCodeCmd := exec.Command(speakeasyBinary, "customcode", "--output", "console") @@ -91,7 +91,7 @@ func testMultiTargetCustomCodeAllTargetsModified(t *testing.T, speakeasyBinary s // Step 1: Modify all target files with target-specific custom code // Modify comment lines that are safe to change - modifyLineInFile(t, goFilePath, 10, "\t// custom code in go target") + modifyLineInFile(t, goFilePath, 11, "\t// custom code in go target") modifyLineInFile(t, tsFilePath, 9, "// custom code in typescript target") // Step 2: Register custom code @@ -211,7 +211,7 @@ func testMultiTargetIncrementalCustomCode(t *testing.T, speakeasyBinary string) tsFilePath := filepath.Join(temp, "typescript", "src", "models", "operations", "getuserbyname.ts") // Step 1: Add initial custom code to all targets - modifyLineInFile(t, goFilePath, 10, "\t// initial custom code in go target") + modifyLineInFile(t, goFilePath, 11, "\t// initial custom code in go target") modifyLineInFile(t, tsFilePath, 9, "// initial custom code in typescript target") // Step 2: Register custom code for all targets @@ -245,7 +245,7 @@ func testMultiTargetIncrementalCustomCode(t *testing.T, speakeasyBinary string) gitCommit(t, temp, "regeneration with initial custom code") // Step 6: Add MORE custom code to go target only (on a different line) - modifyLineInFile(t, goFilePath, 8, "// additional custom code in go target") + modifyLineInFile(t, goFilePath, 9, "// additional custom code in go target") // Step 7: Register the new custom code (should update go patch only) customCodeCmd2 := exec.Command(speakeasyBinary, "customcode", "--output", "console") @@ -279,7 +279,7 @@ func testMultiTargetCustomCodeConflictResolutionAcceptOurs(t *testing.T, speakea tsFilePath := filepath.Join(temp, "typescript", "src", "models", "operations", "getuserbyname.ts") // Step 1: Add custom code to ALL targets - modifyLineInFile(t, goFilePath, 10, "\t// custom code in go target") + modifyLineInFile(t, goFilePath, 11, "\t// custom code in go target") modifyLineInFile(t, tsFilePath, 9, "// custom code in typescript target") // Step 2: Register custom code for all targets diff --git a/integration/customcode_singletarget_test.go b/integration/customcode_singletarget_test.go index 759df28da..87c6f7c56 100644 --- a/integration/customcode_singletarget_test.go +++ b/integration/customcode_singletarget_test.go @@ -66,7 +66,7 @@ func testCustomCodeBasicWorkflow(t *testing.T, speakeasyBinary string) { temp := setupSDKGeneration(t, speakeasyBinary, "customcodespec.yaml") httpMetadataPath := filepath.Join(temp, "models", "components", "httpmetadata.go") - registerCustomCode(t, speakeasyBinary, temp, httpMetadataPath, 10, "\t// custom code") + registerCustomCodeByPrefix(t, speakeasyBinary, temp, httpMetadataPath, "// Raw HTTP response", "\t// custom code") runRegeneration(t, speakeasyBinary, temp, true) verifyCustomCodePresent(t, httpMetadataPath, "// custom code") @@ -79,7 +79,7 @@ func testCustomCodeConflictResolution(t *testing.T, speakeasyBinary string) { getUserByNamePath := filepath.Join(temp, "models", "operations", "getuserbyname.go") // Register custom code - registerCustomCode(t, speakeasyBinary, temp, getUserByNamePath, 10, "\t// custom code") + registerCustomCodeByPrefix(t, speakeasyBinary, temp, getUserByNamePath, "// The name that needs to be fetched", "\t// custom code") // Modify the spec to change line 477 from original description to "spec change" specPath := filepath.Join(temp, "customcodespec.yaml") @@ -131,7 +131,7 @@ func testCustomCodeConflictResolutionAcceptOurs(t *testing.T, speakeasyBinary st getUserByNamePath := filepath.Join(temp, "models", "operations", "getuserbyname.go") // Register custom code - registerCustomCode(t, speakeasyBinary, temp, getUserByNamePath, 10, "\t// custom code") + registerCustomCodeByPrefix(t, speakeasyBinary, temp, getUserByNamePath, "// The name that needs to be fetched", "\t// custom code") // Modify the spec to change line 477 from original description to "spec change" specPath := filepath.Join(temp, "customcodespec.yaml") @@ -206,7 +206,7 @@ func testCustomCodeSequentialPatchesAppliedWithRegenerationBetween(t *testing.T, getUserByNamePath := filepath.Join(temp, "models", "operations", "getuserbyname.go") // Step 1: Register first patch - registerCustomCode(t, speakeasyBinary, temp, getUserByNamePath, 10, "\t// first custom code") + registerCustomCodeByPrefix(t, speakeasyBinary, temp, getUserByNamePath, "// The name that needs to be fetched", "\t// first custom code") // Step 2: Verify first patch applies correctly on regeneration runRegeneration(t, speakeasyBinary, temp, true) @@ -216,7 +216,7 @@ func testCustomCodeSequentialPatchesAppliedWithRegenerationBetween(t *testing.T, gitCommit(t, temp, "regenerated with first patch") // Step 3: Modify the same line with different content (second patch) - modifyLineInFile(t, getUserByNamePath, 10, "\t// second custom code - updated") + modifyLineInFileByPrefix(t, getUserByNamePath, "\t// first custom code", "\t// second custom code - updated") // Step 4: Register second patch (should update existing patch) customCodeCmd := exec.Command(speakeasyBinary, "customcode", "--output", "console") @@ -249,10 +249,10 @@ func testCustomCodeSequentialPatchesAppliedWithoutRegenerationBetween(t *testing getUserByNamePath := filepath.Join(temp, "models", "operations", "getuserbyname.go") // Step 1: Register first patch - registerCustomCode(t, speakeasyBinary, temp, getUserByNamePath, 10, "\t// first custom code") + registerCustomCodeByPrefix(t, speakeasyBinary, temp, getUserByNamePath, "// The name that needs to be fetched", "\t// first custom code") // Step 2: Immediately modify the same line with different content (NO regeneration between) - modifyLineInFile(t, getUserByNamePath, 10, "\t// second custom code - updated") + modifyLineInFileByPrefix(t, getUserByNamePath, "\t// first custom code", "\t// second custom code - updated") // Step 3: Register second patch (should update existing patch) customCodeCmd := exec.Command(speakeasyBinary, "customcode", "--output", "console") @@ -277,58 +277,6 @@ func testCustomCodeSequentialPatchesAppliedWithoutRegenerationBetween(t *testing require.NotContains(t, string(finalContent), "// first custom code", "File should not contain first custom code") } -// testCustomCodeConflictDetectionDuringRegistration tests that conflicts are detected during customcode registration -// when the old patch conflicts with new changes -// func testCustomCodeConflictDetectionDuringRegistration(t *testing.T, speakeasyBinary string) { -// temp := setupSDKGeneration(t, speakeasyBinary, "customcodespec.yaml") - -// getUserByNamePath := filepath.Join(temp, "models", "operations", "getuserbyname.go") - -// // Step 1: Modify the file -// modifyLineInFile(t, getUserByNamePath, 10, "\t// first custom code") - -// // Step 2: Register first patch -// customCodeCmd := exec.Command(speakeasyBinary, "customcode", "--output", "console") -// customCodeCmd.Dir = temp -// customCodeOutput, customCodeErr := customCodeCmd.CombinedOutput() -// require.NoError(t, customCodeErr, "customcode command should succeed: %s", string(customCodeOutput)) - -// // Step 3: Run and commit -// runRegeneration(t, speakeasyBinary, temp, true) -// gitCommit(t, temp, "regenerated with first patch") - -// // Step 4: Modify the same line again -// modifyLineInFile(t, getUserByNamePath, 10, "\t// second custom code - conflicting") - -// // Step 5: Modify the spec to change the same line (this will cause conflict during registration) -// specPath := filepath.Join(temp, "customcodespec.yaml") -// modifyLineInFile(t, specPath, 477, " description: 'spec change for conflict'") - -// // Step 5b: Commit only the spec -// gitAddCmd := exec.Command("git", "add", specPath) -// gitAddCmd.Dir = temp -// gitAddOutput, gitAddErr := gitAddCmd.CombinedOutput() -// require.NoError(t, gitAddErr, "git add spec should succeed: %s", string(gitAddOutput)) - -// gitCommitCmd := exec.Command("git", "commit", "-m", "update spec") -// gitCommitCmd.Dir = temp -// gitCommitOutput, gitCommitErr := gitCommitCmd.CombinedOutput() -// require.NoError(t, gitCommitErr, "git commit spec should succeed: %s", string(gitCommitOutput)) - -// // Step 6: Register custom code - should fail with conflict error -// customCodeCmd = exec.Command(speakeasyBinary, "customcode", "--output", "console") -// customCodeCmd.Dir = temp -// customCodeOutput, customCodeErr = customCodeCmd.CombinedOutput() - -// // Step 7: Validate error - conflict happens when applying existing patch -// require.Error(t, customCodeErr, "customcode command should fail due to conflicts: %s", string(customCodeOutput)) -// outputStr := string(customCodeOutput) -// // The conflict occurs when applying the existing patch (not the new patch) -// // because the spec changed and the old patch no longer applies cleanly -// require.Contains(t, outputStr, "failed to apply existing patch", "Error message should mention failed to apply existing patch") -// require.Contains(t, outputStr, "with conflicts", "Error message should mention conflicts") -// } - // buildSpeakeasyBinaryOnce builds the speakeasy binary and returns the path to it func buildSpeakeasyBinaryOnce(t *testing.T, binaryName string) string { t.Helper() @@ -443,6 +391,13 @@ func modifyLineInFile(t *testing.T, filePath string, lineNumber int, newContent require.NoError(t, writer.Flush(), "Failed to flush writer") } +func modifyLineInFileByPrefix(t *testing.T, filePath string, oldContentPrefix string, newContent string) { + t.Helper() + + lineNum := findLineNumberByPrefix(t, filePath, oldContentPrefix) + modifyLineInFile(t, filePath, lineNum, newContent) +} + // setupCustomCodeTestDir creates a test directory outside the speakeasy repo func setupCustomCodeTestDir(t *testing.T) string { t.Helper() @@ -532,6 +487,35 @@ func setupSDKGeneration(t *testing.T, speakeasyBinary, inputDoc string) string { return temp } +// findLineNumberByPrefix finds the line number (1-indexed) of the first line containing the prefix +func findLineNumberByPrefix(t *testing.T, filePath, prefix string) int { + t.Helper() + + file, err := os.Open(filePath) + require.NoError(t, err, "Failed to open file: %s", filePath) + defer file.Close() + + scanner := bufio.NewScanner(file) + lineNumber := 1 + for scanner.Scan() { + if strings.Contains(scanner.Text(), prefix) { + return lineNumber + } + lineNumber++ + } + require.NoError(t, scanner.Err(), "Failed to read file: %s", filePath) + require.Fail(t, "Could not find line with prefix: %s", prefix) + return -1 // Never reached +} + +// registerCustomCodeByPrefix finds a line by prefix and registers custom code at that line +func registerCustomCodeByPrefix(t *testing.T, speakeasyBinary, workingDir, filePath, linePrefix string, newContent string) { + t.Helper() + + lineNum := findLineNumberByPrefix(t, filePath, linePrefix) + registerCustomCode(t, speakeasyBinary, workingDir, filePath, lineNum, newContent) +} + // registerCustomCode modifies a file and registers it as custom code func registerCustomCode(t *testing.T, speakeasyBinary, workingDir, filePath string, lineNum int, newContent string) { t.Helper() From a57b83de892ebd0a7a203abfc8c60b44034c1769 Mon Sep 17 00:00:00 2001 From: Ian Bentley Date: Wed, 5 Nov 2025 10:16:18 -0500 Subject: [PATCH 39/42] cleanup and tests passing --- integration/customcode_multitarget_test.go | 329 +++++++++++++++++- integration/customcode_singletarget_test.go | 5 - .../registercustomcode/registercustomcode.go | 147 ++------ 3 files changed, 348 insertions(+), 133 deletions(-) diff --git a/integration/customcode_multitarget_test.go b/integration/customcode_multitarget_test.go index e8205b3d3..16b8eb06e 100644 --- a/integration/customcode_multitarget_test.go +++ b/integration/customcode_multitarget_test.go @@ -36,6 +36,16 @@ func TestMultiTargetCustomCode(t *testing.T) { t.Parallel() testMultiTargetCustomCodeConflictResolutionAcceptOurs(t, speakeasyBinary) }) + + t.Run("ConflictResolutionAcceptOursMultipleResolutions", func(t *testing.T) { + t.Parallel() + testMultiTargetCustomCodeConflictMultipleResultionsAcceptOurs(t, speakeasyBinary) + }) + + t.Run("ConflictResolutionAcceptTheirs", func(t *testing.T) { + t.Parallel() + testMultiTargetCustomCodeConflictResolutionAcceptTheirs(t, speakeasyBinary) + }) } // testMultiTargetCustomCodeBasicWorkflow tests basic custom code registration and reapplication @@ -47,7 +57,7 @@ func testMultiTargetCustomCodeBasicWorkflow(t *testing.T, speakeasyBinary string goFilePath := filepath.Join(temp, "go", "models", "operations", "getuserbyname.go") // Step 1: Modify only the go target file - modifyLineInFile(t, goFilePath, 11, "\t// custom code in go target") + modifyLineInFileByPrefix(t, goFilePath, "// The name that needs to be", "\t// custom code in go target") // Step 2: Register custom code customCodeCmd := exec.Command(speakeasyBinary, "customcode", "--output", "console") @@ -91,8 +101,8 @@ func testMultiTargetCustomCodeAllTargetsModified(t *testing.T, speakeasyBinary s // Step 1: Modify all target files with target-specific custom code // Modify comment lines that are safe to change - modifyLineInFile(t, goFilePath, 11, "\t// custom code in go target") - modifyLineInFile(t, tsFilePath, 9, "// custom code in typescript target") + modifyLineInFileByPrefix(t, goFilePath, "// The name that needs to be", "\t// custom code in go target") + modifyLineInFileByPrefix(t, tsFilePath, "* The name that needs to be", "// custom code in typescript target") // Step 2: Register custom code customCodeCmd := exec.Command(speakeasyBinary, "customcode", "--output", "console") @@ -211,8 +221,8 @@ func testMultiTargetIncrementalCustomCode(t *testing.T, speakeasyBinary string) tsFilePath := filepath.Join(temp, "typescript", "src", "models", "operations", "getuserbyname.ts") // Step 1: Add initial custom code to all targets - modifyLineInFile(t, goFilePath, 11, "\t// initial custom code in go target") - modifyLineInFile(t, tsFilePath, 9, "// initial custom code in typescript target") + modifyLineInFileByPrefix(t, goFilePath, "// The name that needs to be", "\t// initial custom code in go target") + modifyLineInFileByPrefix(t, tsFilePath, "* The name that needs to be", "// initial custom code in typescript target") // Step 2: Register custom code for all targets customCodeCmd := exec.Command(speakeasyBinary, "customcode", "--output", "console") @@ -245,7 +255,7 @@ func testMultiTargetIncrementalCustomCode(t *testing.T, speakeasyBinary string) gitCommit(t, temp, "regeneration with initial custom code") // Step 6: Add MORE custom code to go target only (on a different line) - modifyLineInFile(t, goFilePath, 9, "// additional custom code in go target") + modifyLineInFileByPrefix(t, goFilePath, "// successful operation", "// additional custom code in go target") // Step 7: Register the new custom code (should update go patch only) customCodeCmd2 := exec.Command(speakeasyBinary, "customcode", "--output", "console") @@ -270,7 +280,7 @@ func testMultiTargetIncrementalCustomCode(t *testing.T, speakeasyBinary string) } // testMultiTargetCustomCodeConflictResolutionAcceptOurs tests conflict resolution in one target -// while preserving custom code in other targets when accepting spec changes (ours) +// while preserving custom code in other targets when accepting custom code changes (ours) func testMultiTargetCustomCodeConflictResolutionAcceptOurs(t *testing.T, speakeasyBinary string) { temp := setupMultiTargetSDKGeneration(t, speakeasyBinary, "customcodespec.yaml") @@ -279,8 +289,8 @@ func testMultiTargetCustomCodeConflictResolutionAcceptOurs(t *testing.T, speakea tsFilePath := filepath.Join(temp, "typescript", "src", "models", "operations", "getuserbyname.ts") // Step 1: Add custom code to ALL targets - modifyLineInFile(t, goFilePath, 11, "\t// custom code in go target") - modifyLineInFile(t, tsFilePath, 9, "// custom code in typescript target") + modifyLineInFileByPrefix(t, goFilePath, "// The name that needs to be", "\t// custom code in go target") + modifyLineInFileByPrefix(t, tsFilePath, "* @deprecated This namespace", "// custom code in typescript target") // Step 2: Register custom code for all targets customCodeCmd := exec.Command(speakeasyBinary, "customcode", "--output", "console") @@ -299,7 +309,7 @@ func testMultiTargetCustomCodeConflictResolutionAcceptOurs(t *testing.T, speakea // Step 4: Modify the spec to cause conflict in GO target only (line 477 affects GetUserByName) specPath := filepath.Join(temp, "customcodespec.yaml") - modifyLineInFile(t, specPath, 477, " description: 'spec change'") + modifyLineInFileByPrefix(t, specPath, " description: 'The name that needs to be fetched", " description: 'spec change'") // Step 5: Run speakeasy run - should detect conflict in GO target only regenCmd := exec.Command(speakeasyBinary, "run", "-t", "all", "--pinned", "--skip-compile") @@ -369,7 +379,7 @@ func testMultiTargetCustomCodeConflictResolutionAcceptOurs(t *testing.T, speakea require.NoError(t, err, "Failed to read typescript gen.lock") require.Contains(t, string(tsGenLockContent), "customCodeCommitHash", "TypeScript gen.lock should still contain customCodeCommitHash") - // Step 13: Run regeneration again + // // Step 13: Run regeneration again runRegeneration(t, speakeasyBinary, temp, true) // Step 14: Verify final state @@ -384,3 +394,300 @@ func testMultiTargetCustomCodeConflictResolutionAcceptOurs(t *testing.T, speakea require.NoError(t, err, "Failed to read typescript file after final regeneration") require.Contains(t, string(tsContentFinal), "custom code in typescript target", "TypeScript file should still contain its custom code") } + + +// testMultiTargetCustomCodeConflictMultipleResultionsAcceptOurs tests conflict resolution in two targets +// sequentially. +func testMultiTargetCustomCodeConflictMultipleResultionsAcceptOurs(t *testing.T, speakeasyBinary string) { + temp := setupMultiTargetSDKGeneration(t, speakeasyBinary, "customcodespec.yaml") + + // Paths to all target files + goFilePath := filepath.Join(temp, "go", "models", "operations", "getuserbyname.go") + tsFilePath := filepath.Join(temp, "typescript", "src", "models", "operations", "getuserbyname.ts") + + // Step 1: Add custom code to ALL targets + modifyLineInFileByPrefix(t, goFilePath, "// The name that needs to be", "\t// custom code in go target") + modifyLineInFileByPrefix(t, tsFilePath, "* The name that needs to be", "// custom code in typescript target") + + // Step 2: Register custom code for all targets + customCodeCmd := exec.Command(speakeasyBinary, "customcode", "--output", "console") + customCodeCmd.Dir = temp + customCodeOutput, customCodeErr := customCodeCmd.CombinedOutput() + require.NoError(t, customCodeErr, "customcode command should succeed: %s", string(customCodeOutput)) + + // Step 3: Verify patch files were created for both targets + goPatchFile := filepath.Join(temp, "go", ".speakeasy", "patches", "custom-code.diff") + _, err := os.Stat(goPatchFile) + require.NoError(t, err, "Go patch file should exist") + + tsPatchFile := filepath.Join(temp, "typescript", ".speakeasy", "patches", "custom-code.diff") + _, err = os.Stat(tsPatchFile) + require.NoError(t, err, "TypeScript patch file should exist") + + // Step 4: Modify the spec to cause conflict in GO target only (line 477 affects GetUserByName) + specPath := filepath.Join(temp, "customcodespec.yaml") + modifyLineInFileByPrefix(t, specPath, " description: 'The name that needs to be fetched", " description: 'spec change'") + +// ------First Round + // Step 5: Run speakeasy run - should detect conflict in GO target only + regenCmd := exec.Command(speakeasyBinary, "run", "-t", "all", "--pinned", "--skip-compile") + regenCmd.Dir = temp + regenOutput, regenErr := regenCmd.CombinedOutput() + require.Error(t, regenErr, "speakeasy run should exit with error after detecting conflicts: %s", string(regenOutput)) + require.Contains(t, string(regenOutput), "CUSTOM CODE CONFLICTS DETECTED", "Output should show conflict detection banner") + require.Contains(t, string(regenOutput), "Entering automatic conflict resolution mode", "Output should indicate automatic resolution mode") + + // Step 6: Verify conflict markers present in GO file only + goContentAfterConflict, err := os.ReadFile(goFilePath) + require.NoError(t, err, "Failed to read go file after conflict") + require.Contains(t, string(goContentAfterConflict), "<<<<<<<", "Go file should contain conflict markers") + + // TypeScript file should NOT have conflict markers + tsContentAfterConflict, err := os.ReadFile(tsFilePath) + require.NoError(t, err, "Failed to read typescript file after conflict") + require.NotContains(t, string(tsContentAfterConflict), "<<<<<<<", "TypeScript file should not contain conflict markers") + require.Contains(t, string(tsContentAfterConflict), "custom code in typescript target", "TypeScript file should still have its custom code") + + // Step 7: Resolve the go conflict by accepting spec changes (ours) + checkoutCmd := exec.Command("git", "checkout", "--ours", goFilePath) + checkoutCmd.Dir = temp + checkoutOutput, checkoutErr := checkoutCmd.CombinedOutput() + require.NoError(t, checkoutErr, "git checkout --ours should succeed: %s", string(checkoutOutput)) + + // Step 8: Verify conflict markers are gone in go file + goContentAfterCheckout, err := os.ReadFile(goFilePath) + require.NoError(t, err, "Failed to read go file after checkout") + require.NotContains(t, string(goContentAfterCheckout), "<<<<<<<", "Go file should not contain conflict markers after checkout") + + // Step 9: Stage the resolved go file + gitAddCmd := exec.Command("git", "add", goFilePath) + gitAddCmd.Dir = temp + gitAddOutput, gitAddErr := gitAddCmd.CombinedOutput() + require.NoError(t, gitAddErr, "git add should succeed: %s", string(gitAddOutput)) + + // Step 10: Run customcode command to register the resolution + customCodeCmd2 := exec.Command(speakeasyBinary, "customcode", "--output", "console") + customCodeCmd2.Dir = temp + customCodeOutput2, customCodeErr2 := customCodeCmd2.CombinedOutput() + require.NoError(t, customCodeErr2, "customcode command should succeed after conflict resolution: %s", string(customCodeOutput2)) + + // Step 11: Verify patch files status + // Go patch file should be empty or removed + goPatchContent, err := os.ReadFile(goPatchFile) + if err == nil { + require.Empty(t, goPatchContent, "Go patch file should be empty after accepting ours") + } + + // TypeScript patch file should still exist with its content + tsPatchContent, err := os.ReadFile(tsPatchFile) + require.NoError(t, err, "TypeScript patch file should still exist") + require.NotEmpty(t, tsPatchContent, "TypeScript patch file should not be empty") + require.Contains(t, string(tsPatchContent), "custom code in typescript target", "TypeScript patch should contain typescript custom code") + + // Step 12: Verify gen.lock files + // Go's gen.lock should NOT contain customCodeCommitHash + goGenLockPath := filepath.Join(temp, "go", ".speakeasy", "gen.lock") + goGenLockContent, err := os.ReadFile(goGenLockPath) + require.NoError(t, err, "Failed to read go gen.lock") + require.NotContains(t, string(goGenLockContent), "customCodeCommitHash", "Go gen.lock should not contain customCodeCommitHash after accepting ours") + + // TypeScript's gen.lock should still contain customCodeCommitHash + tsGenLockPath := filepath.Join(temp, "typescript", ".speakeasy", "gen.lock") + tsGenLockContent, err := os.ReadFile(tsGenLockPath) + require.NoError(t, err, "Failed to read typescript gen.lock") + require.Contains(t, string(tsGenLockContent), "customCodeCommitHash", "TypeScript gen.lock should still contain customCodeCommitHash") + +// ------Second Round + + // Step 13: Run regeneration again - should detect conflict in TS target. + regenCmd2 := exec.Command(speakeasyBinary, "run", "-t", "all", "--pinned", "--skip-compile") + regenCmd2.Dir = temp + regenOutput2, regenErr2 := regenCmd2.CombinedOutput() + require.Error(t, regenErr2, "speakeasy run should exit with error after detecting conflicts: %s", string(regenOutput2)) + require.Contains(t, string(regenOutput2), "CUSTOM CODE CONFLICTS DETECTED", "Output should show conflict detection banner") + require.Contains(t, string(regenOutput2), "Entering automatic conflict resolution mode", "Output should indicate automatic resolution mode") + + + // Step 14: Verify conflict markers present in TS file only + tsContentAfterConflict2, err := os.ReadFile(tsFilePath) + require.NoError(t, err, "Failed to read ts file after conflict") + require.Contains(t, string(tsContentAfterConflict2), "<<<<<<<", "TS file should contain conflict markers") + + // Go file should NOT have conflict markers + goContentAfterConflict2, err := os.ReadFile(goFilePath) + require.NoError(t, err, "Failed to read typescript file after conflict") + require.NotContains(t, string(goContentAfterConflict2), "<<<<<<<", "GO file should not contain conflict markers") + + // Step 15: Resolve the ts conflict by accepting spec changes (ours) + checkoutCmd2 := exec.Command("git", "checkout", "--ours", tsFilePath) + checkoutCmd2.Dir = temp + checkoutOutput2, checkoutErr2 := checkoutCmd2.CombinedOutput() + require.NoError(t, checkoutErr2, "git checkout --ours should succeed: %s", string(checkoutOutput2)) + + // Step 16: Verify conflict markers are gone in ts file + tsContentAfterCheckout2, err := os.ReadFile(tsFilePath) + require.NoError(t, err, "Failed to read ts file after checkout") + require.NotContains(t, string(tsContentAfterCheckout2), "<<<<<<<", "TS file should not contain conflict markers after checkout") + + // Step 17: Stage the resolved ts file + gitAddCmd2 := exec.Command("git", "add", tsFilePath) + gitAddCmd2.Dir = temp + gitAddOutput2, gitAddErr2 := gitAddCmd2.CombinedOutput() + require.NoError(t, gitAddErr2, "git add should succeed: %s", string(gitAddOutput2)) + + // Step 18: Run customcode command to register the resolution + customCodeCmd3 := exec.Command(speakeasyBinary, "customcode", "--output", "console") + customCodeCmd3.Dir = temp + customCodeOutput3, customCodeErr3 := customCodeCmd3.CombinedOutput() + require.NoError(t, customCodeErr3, "customcode command should succeed after conflict resolution: %s", string(customCodeOutput3)) + + // Step 19: Verify patch files status + // TS patch file should be empty or removed + tsPatchContent2, err := os.ReadFile(tsPatchFile) + if err == nil { + require.Empty(t, tsPatchContent2, "TS patch file should be empty after accepting ours") + } + + // Step 20: Verify gen.lock files + // Go's gen.lock should NOT contain customCodeCommitHash + goGenLockPath2 := filepath.Join(temp, "go", ".speakeasy", "gen.lock") + goGenLockContent2, err := os.ReadFile(goGenLockPath2) + require.NoError(t, err, "Failed to read go gen.lock") + require.NotContains(t, string(goGenLockContent2), "customCodeCommitHash", "Go gen.lock should not contain customCodeCommitHash after accepting ours") + + // TypeScript's gen.lock should NOT contain customCodeCommitHash + tsGenLockPath2 := filepath.Join(temp, "typescript", ".speakeasy", "gen.lock") + tsGenLockContent2, err := os.ReadFile(tsGenLockPath2) + require.NoError(t, err, "Failed to read typescript gen.lock") + require.NotContains(t, string(tsGenLockContent2), "customCodeCommitHash", "TS gen.lock should not contain customCodeCommitHash after accepting ours") +// ----------------- + + // Step 21: Verify final state + // Go file should contain spec change, should NOT contain custom code + goContentFinal, err := os.ReadFile(goFilePath) + require.NoError(t, err, "Failed to read go file after final regeneration") + require.Contains(t, string(goContentFinal), "spec change", "Go file should contain spec change") + require.NotContains(t, string(goContentFinal), "custom code in go target", "Go file should not contain custom code after accepting ours") + + // TypeScript file should still contain its custom code + tsContentFinal, err := os.ReadFile(tsFilePath) + require.NoError(t, err, "Failed to read typescript file after final regeneration") + require.Contains(t, string(tsContentFinal), "spec change", "Go file should contain spec change") + require.NotContains(t, string(tsContentFinal), "custom code in typescript target", "TypeScript file should still contain its custom code") +} + +// testMultiTargetCustomCodeConflictResolutionAcceptTheirs tests conflict resolution in one target +// while preserving custom code in other targets when accepting custom code changes (theirs) +func testMultiTargetCustomCodeConflictResolutionAcceptTheirs(t *testing.T, speakeasyBinary string) { + temp := setupMultiTargetSDKGeneration(t, speakeasyBinary, "customcodespec.yaml") + + // Paths to all target files + goFilePath := filepath.Join(temp, "go", "models", "operations", "getuserbyname.go") + tsFilePath := filepath.Join(temp, "typescript", "src", "models", "operations", "getuserbyname.ts") + + // Step 1: Add custom code to ALL targets + modifyLineInFileByPrefix(t, goFilePath, "// The name that needs to be", "\t// custom code in go target") + modifyLineInFileByPrefix(t, tsFilePath, "* @deprecated This namespace", "// custom code in typescript target") + + // Step 2: Register custom code for all targets + customCodeCmd := exec.Command(speakeasyBinary, "customcode", "--output", "console") + customCodeCmd.Dir = temp + customCodeOutput, customCodeErr := customCodeCmd.CombinedOutput() + require.NoError(t, customCodeErr, "customcode command should succeed: %s", string(customCodeOutput)) + + // Step 3: Verify patch files were created for both targets + goPatchFile := filepath.Join(temp, "go", ".speakeasy", "patches", "custom-code.diff") + _, err := os.Stat(goPatchFile) + require.NoError(t, err, "Go patch file should exist") + + tsPatchFile := filepath.Join(temp, "typescript", ".speakeasy", "patches", "custom-code.diff") + _, err = os.Stat(tsPatchFile) + require.NoError(t, err, "TypeScript patch file should exist") + + // Step 4: Modify the spec to cause conflict in GO target only (line 477 affects GetUserByName) + specPath := filepath.Join(temp, "customcodespec.yaml") + modifyLineInFileByPrefix(t, specPath, " description: 'The name that needs to be fetched", " description: 'spec change'") + + // Step 5: Run speakeasy run - should detect conflict in GO target only + regenCmd := exec.Command(speakeasyBinary, "run", "-t", "all", "--pinned", "--skip-compile") + regenCmd.Dir = temp + regenOutput, regenErr := regenCmd.CombinedOutput() + require.Error(t, regenErr, "speakeasy run should exit with error after detecting conflicts: %s", string(regenOutput)) + require.Contains(t, string(regenOutput), "CUSTOM CODE CONFLICTS DETECTED", "Output should show conflict detection banner") + require.Contains(t, string(regenOutput), "Entering automatic conflict resolution mode", "Output should indicate automatic resolution mode") + + // Step 6: Verify conflict markers present in GO file only + goContentAfterConflict, err := os.ReadFile(goFilePath) + require.NoError(t, err, "Failed to read go file after conflict") + require.Contains(t, string(goContentAfterConflict), "<<<<<<<", "Go file should contain conflict markers") + + // TypeScript file should NOT have conflict markers + tsContentAfterConflict, err := os.ReadFile(tsFilePath) + require.NoError(t, err, "Failed to read typescript file after conflict") + require.NotContains(t, string(tsContentAfterConflict), "<<<<<<<", "TypeScript file should not contain conflict markers") + require.Contains(t, string(tsContentAfterConflict), "custom code in typescript target", "TypeScript file should still have its custom code") + + // Step 7: Resolve the go conflict by accepting custom code changes (theirs) + checkoutCmd := exec.Command("git", "checkout", "--theirs", goFilePath) + checkoutCmd.Dir = temp + checkoutOutput, checkoutErr := checkoutCmd.CombinedOutput() + require.NoError(t, checkoutErr, "git checkout --theirs should succeed: %s", string(checkoutOutput)) + + // Step 8: Verify conflict markers are gone in go file + goContentAfterCheckout, err := os.ReadFile(goFilePath) + require.NoError(t, err, "Failed to read go file after checkout") + require.NotContains(t, string(goContentAfterCheckout), "<<<<<<<", "Go file should not contain conflict markers after checkout") + + // Step 9: Stage the resolved go file + gitAddCmd := exec.Command("git", "add", goFilePath) + gitAddCmd.Dir = temp + gitAddOutput, gitAddErr := gitAddCmd.CombinedOutput() + require.NoError(t, gitAddErr, "git add should succeed: %s", string(gitAddOutput)) + + // Step 10: Run customcode command to register the resolution + customCodeCmd2 := exec.Command(speakeasyBinary, "customcode", "--output", "console") + customCodeCmd2.Dir = temp + customCodeOutput2, customCodeErr2 := customCodeCmd2.CombinedOutput() + require.NoError(t, customCodeErr2, "customcode command should succeed after conflict resolution: %s", string(customCodeOutput2)) + + // Step 11: Verify patch files status + // Go patch file should still exist with content since we accepted theirs (custom code) + goPatchContent, err := os.ReadFile(goPatchFile) + require.NoError(t, err, "Go patch file should still exist") + require.NotEmpty(t, goPatchContent, "Go patch file should not be empty after accepting theirs") + require.Contains(t, string(goPatchContent), "custom code in go target", "Go patch should contain go custom code") + + // TypeScript patch file should still exist with its content + tsPatchContent, err := os.ReadFile(tsPatchFile) + require.NoError(t, err, "TypeScript patch file should still exist") + require.NotEmpty(t, tsPatchContent, "TypeScript patch file should not be empty") + require.Contains(t, string(tsPatchContent), "custom code in typescript target", "TypeScript patch should contain typescript custom code") + + // Step 12: Verify gen.lock files + // Go's gen.lock should still contain customCodeCommitHash since we kept custom code + goGenLockPath := filepath.Join(temp, "go", ".speakeasy", "gen.lock") + goGenLockContent, err := os.ReadFile(goGenLockPath) + require.NoError(t, err, "Failed to read go gen.lock") + require.Contains(t, string(goGenLockContent), "customCodeCommitHash", "Go gen.lock should contain customCodeCommitHash after accepting theirs") + + // TypeScript's gen.lock should still contain customCodeCommitHash + tsGenLockPath := filepath.Join(temp, "typescript", ".speakeasy", "gen.lock") + tsGenLockContent, err := os.ReadFile(tsGenLockPath) + require.NoError(t, err, "Failed to read typescript gen.lock") + require.Contains(t, string(tsGenLockContent), "customCodeCommitHash", "TypeScript gen.lock should still contain customCodeCommitHash") + + // Step 13: Run regeneration again + runRegeneration(t, speakeasyBinary, temp, true) + + // Step 14: Verify final state + // Go file should contain custom code (since we accepted theirs), and should NOT contain spec change + goContentFinal, err := os.ReadFile(goFilePath) + require.NoError(t, err, "Failed to read go file after final regeneration") + require.NotContains(t, string(goContentFinal), "spec change", "Go file should not contain spec change after accepting theirs") + require.Contains(t, string(goContentFinal), "custom code in go target", "Go file should contain custom code after accepting theirs") + + // TypeScript file should still contain its custom code + tsContentFinal, err := os.ReadFile(tsFilePath) + require.NoError(t, err, "Failed to read typescript file after final regeneration") + require.Contains(t, string(tsContentFinal), "custom code in typescript target", "TypeScript file should still contain its custom code") +} diff --git a/integration/customcode_singletarget_test.go b/integration/customcode_singletarget_test.go index 87c6f7c56..2a69c34c5 100644 --- a/integration/customcode_singletarget_test.go +++ b/integration/customcode_singletarget_test.go @@ -45,11 +45,6 @@ func TestCustomCode(t *testing.T) { testCustomCodeSequentialPatchesAppliedWithoutRegenerationBetween(t, speakeasyBinary) }) - // t.Run("ConflictDetectionDuringCustomCodeRegistration", func(t *testing.T) { - // t.Parallel() - // testCustomCodeConflictDetectionDuringRegistration(t, speakeasyBinary) - // }) - t.Run("NewFilePreservation", func(t *testing.T) { t.Parallel() testCustomCodeNewFilePreservation(t, speakeasyBinary) diff --git a/internal/registercustomcode/registercustomcode.go b/internal/registercustomcode/registercustomcode.go index 341eb442a..2391838bd 100644 --- a/internal/registercustomcode/registercustomcode.go +++ b/internal/registercustomcode/registercustomcode.go @@ -62,17 +62,13 @@ func RegisterCustomCode(ctx context.Context, runGenerate func(string) error) err } logger.Info("Recorded original git hash for error recovery", zap.String("hash", originalHash.String())) - // // Step 1: Verify main is up to date with origin/main - // if err := verifyMainUpToDate(ctx); err != nil { - // return fmt.Errorf("In order to register your custom code, your local branch must be up to date with origin/main: %w", err) - // } - // Step 2: Check changeset doesn't include .speakeasy directory changes + // Step 1: Check changeset doesn't include .speakeasy directory changes if err := checkNoSpeakeasyChanges(ctx); err != nil { return fmt.Errorf("Registering custom code in the .speakeasy directory is not supported: %w", err) } - // Step 3: Check if workflow.yaml references local openapi spec and validate no spec changes + // Step 2: Check if workflow.yaml references local openapi spec and validate no spec changes if err := checkNoLocalSpecChanges(ctx, wf); err != nil { return fmt.Errorf("Registering custom code in your openapi spec and related files is not supported: %w", err) } @@ -81,7 +77,7 @@ func RegisterCustomCode(ctx context.Context, runGenerate func(string) error) err return err } - // Step 4.5: Reset working directory to HEAD after capturing patches + // Step 3: Reset working directory to HEAD after capturing patches // This removes all user changes (staged and unstaged) so they don't get included in clean generation commit logger.Info("Resetting working directory to HEAD before clean generation") resetCmd := exec.Command("git", "reset", "--hard", "HEAD") @@ -95,8 +91,8 @@ func RegisterCustomCode(ctx context.Context, runGenerate func(string) error) err } } - // Step 6: Commit clean generation to preserve metadata - if err := commitCleanGeneration(); err != nil { + // Step 4: Commit clean generation to preserve metadata + if err := commitRevertCustomCode(); err != nil { return fmt.Errorf("failed to commit clean generation: %w", err) } @@ -115,25 +111,6 @@ func RegisterCustomCode(ctx context.Context, runGenerate func(string) error) err return nil } -func getPatchesPerTarget(wf *workflow.Workflow) (map[string]string, error) { - targetPatches := make(map[string]string) - for targetName, target := range wf.Targets { - // Step 4: Capture patchset with git diff for custom code changes - otherTargetOutputs := getOtherTargetOutputs(wf, targetName) - customCodeDiff, err := captureCustomCodeDiff(getTargetOutput(target), otherTargetOutputs) - if err != nil { - return nil, fmt.Errorf("failed to capture custom code diff: %w", err) - } - fmt.Println(fmt.Sprintf("Captured custom code diff for target %v:\n%s", targetName, customCodeDiff)) - // If no custom code changes detected, return early - if customCodeDiff == "" { - fmt.Println(fmt.Sprintf("No custom code changes detected in target %v, nothing to register", targetName)) - } - targetPatches[targetName] = customCodeDiff - } - return targetPatches, nil -} - func updateCustomPatchAndUpdateGenLock(ctx context.Context, wf *workflow.Workflow, originalHash plumbing.Hash, targetPatches map[string]string, target workflow.Target, targetName string) error { logger := log.From(ctx).With(zap.String("method", "updateCustomPatchAndUpdateGenLock")) // Step 7: Apply existing custom code patch from gen.lock @@ -142,7 +119,7 @@ func updateCustomPatchAndUpdateGenLock(ctx context.Context, wf *workflow.Workflo } // Step 8: Apply the new custom code diff (with --index to stage changes) if err := applyNewPatch(targetPatches[targetName]); err != nil { - removeCleanGenerationCommit(ctx, originalHash) + removeReverseCustomCode(ctx, originalHash) return fmt.Errorf("conflicts detected when applying new patch. Please resolve any conflicts, and run `customcode` again.") } @@ -180,13 +157,13 @@ func updateCustomPatchAndUpdateGenLock(ctx context.Context, wf *workflow.Workflo targetPatches[targetName] = fullCustomCodeDiff logger.Info("Compiling SDK to verify custom code changes...") if err := compileAndLintSDK(ctx, target); err != nil { - removeCleanGenerationCommit(ctx, originalHash) + removeReverseCustomCode(ctx, originalHash) return fmt.Errorf("custom code changes failed compilation or linting. Please resolve any compilation/linting errors and run `customcode` again.") } // Step 10.6: Create commit with custom code changes after successful compilation customCodeCommitHash, err := commitCustomCodeChanges() if err != nil { - removeCleanGenerationCommit(ctx, originalHash) + removeReverseCustomCode(ctx, originalHash) return fmt.Errorf("failed to commit custom code changes: %w", err) } logger.Info("Created commit with custom code changes", zap.String("commit_hash", customCodeCommitHash)) @@ -544,73 +521,29 @@ func completeConflictResolution(ctx context.Context, wf *workflow.Workflow) erro return nil } -// Git validation helpers -func verifyMainUpToDate(ctx context.Context) error { - logger := log.From(ctx) - logger.Info("Verifying main branch is up to date with origin/main") - - // Fetch origin/main - /** GO GIT - err = repo.Fetch(&git.FetchOptions{ - // Optional: configure authentication if needed - // Auth: &http.BasicAuth{Username: "user", Password: "password"}, - }) - */ - cmd := exec.Command("git", "fetch", "origin", "main") - if output, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("failed to fetch origin/main: %w\nOutput: %s", err, string(output)) - } - - // Check if main is up to date with origin/main - cmd = exec.Command("git", "rev-list", "--count", "main..origin/main") - /** - No go-git support - */ - output, err := cmd.Output() - if err != nil { - return fmt.Errorf("failed to check main status: %w", err) - } - - count := strings.TrimSpace(string(output)) - if count != "0" { - return fmt.Errorf("main is not up to date with origin/main (%s commits behind)", count) - } - - logger.Info("Main branch is up to date with origin/main") - return nil +func getPatchesPerTarget(wf *workflow.Workflow) (map[string]string, error) { + targetPatches := make(map[string]string) + for targetName, target := range wf.Targets { + // Step 4: Capture patchset with git diff for custom code changes + otherTargetOutputs := getOtherTargetOutputs(wf, targetName) + customCodeDiff, err := captureCustomCodeDiff(getTargetOutput(target), otherTargetOutputs) + if err != nil { + return nil, fmt.Errorf("failed to capture custom code diff: %w", err) + } + fmt.Println(fmt.Sprintf("Captured custom code diff for target %v:\n%s", targetName, customCodeDiff)) + // If no custom code changes detected, return early + if customCodeDiff == "" { + fmt.Println(fmt.Sprintf("No custom code changes detected in target %v, nothing to register", targetName)) + } + targetPatches[targetName] = customCodeDiff + } + return targetPatches, nil } func checkNoSpeakeasyChanges(ctx context.Context) error { logger := log.From(ctx) logger.Info("Checking that changeset doesn't include .speakeasy directory changes") - /** - * Can be done with GO GIT, but it's not so obvious - head, err := repo.Head() - if err != nil { - // Handle error - } - commit, err := repo.CommitObject(head.Hash()) - if err != nil { - // Handle error - } - tree, err := commit.Tree() - if err != nil { - // Handle error - } - patch, err := tree1.Diff(tree2) - if err != nil { - // Handle error - } - - var buf bytes.Buffer - encoder := diff.NewUnifiedEncoder(&buf) - err = encoder.Encode(patch) - if err != nil { - // Handle error - } - fmt.Println(buf.String()) // Prints the unified diff - */ cmd := exec.Command("git", "diff", "--name-only") output, err := cmd.Output() if err != nil { @@ -729,18 +662,6 @@ func isLocalPath(location workflow.LocationString) bool { (!strings.Contains(resolvedPath, "://") && !strings.Contains(resolvedPath, "@")) } -func generateCleanSDK(ctx context.Context, targetName string, runGenerate func(targetName string) error) error { - logger := log.From(ctx) - err := runGenerate(targetName) - - if err != nil { - return fmt.Errorf("failed to generate SDK: %w", err) - } - - logger.Info("Clean SDK generation completed successfully") - return nil -} - // Git operations func captureCustomCodeDiff(outDir string, excludePaths []string) (string, error) { args := []string{"diff", "HEAD", outDir} @@ -806,16 +727,8 @@ func stageAllChanges(dir string) error { return nil } -func unstageAllChanges() error { - resetCmd := exec.Command("git", "reset") - if output, err := resetCmd.CombinedOutput(); err != nil { - return fmt.Errorf("failed to reset changes: %w\nOutput: %s", err, string(output)) - } - - return nil -} -func commitCleanGeneration() error { +func commitRevertCustomCode() error { // Add all changes addCmd := exec.Command("git", "add", ".") if output, err := addCmd.CombinedOutput(); err != nil { @@ -823,9 +736,9 @@ func commitCleanGeneration() error { } // Commit the clean generation (allow empty if nothing changed) - cmd := exec.Command("git", "commit", "-m", "clean generation", "--allow-empty") + cmd := exec.Command("git", "commit", "-m", "reverse apply custom code", "--allow-empty") if output, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("failed to commit clean generation: %w\nOutput: %s", err, string(output)) + return fmt.Errorf("failed to commit: %w\nOutput: %s", err, string(output)) } return nil @@ -1094,11 +1007,11 @@ func getCurrentGitHash() (plumbing.Hash, error) { return head.Hash(), nil } -// removeCleanGenerationCommit removes the clean generation commit by: +// removeReverseCustomCode removes the reverse custom code commit by: // 1. stash local changes // 2. reset --hard to the original git hash // 3. stash pop those local changes -func removeCleanGenerationCommit(ctx context.Context, originalHash plumbing.Hash) error { +func removeReverseCustomCode(ctx context.Context, originalHash plumbing.Hash) error { logger := log.From(ctx).With(zap.String("method", "removeCleanGenerationCommit")) logger.Info("Starting error recovery process", zap.String("target_hash", originalHash.String())) From 24d0a3b881f5b8a453f8bf382618a3e4b467dd0b Mon Sep 17 00:00:00 2001 From: Ian Bentley Date: Wed, 5 Nov 2025 10:04:05 -0500 Subject: [PATCH 40/42] tests --- integration/customcode_multitarget_test.go | 188 ++++++++++++++++++ .../registercustomcode/registercustomcode.go | 15 +- 2 files changed, 201 insertions(+), 2 deletions(-) diff --git a/integration/customcode_multitarget_test.go b/integration/customcode_multitarget_test.go index 16b8eb06e..e3a84655c 100644 --- a/integration/customcode_multitarget_test.go +++ b/integration/customcode_multitarget_test.go @@ -46,6 +46,12 @@ func TestMultiTargetCustomCode(t *testing.T) { t.Parallel() testMultiTargetCustomCodeConflictResolutionAcceptTheirs(t, speakeasyBinary) }) + + t.Run("ConflictResolutionMultipleResolutionsAcceptTheirs", func(t *testing.T) { + t.Parallel() + testMultiTargetCustomCodeConflictMultipleResultionsAcceptTheirs(t, speakeasyBinary) + }) + } // testMultiTargetCustomCodeBasicWorkflow tests basic custom code registration and reapplication @@ -691,3 +697,185 @@ func testMultiTargetCustomCodeConflictResolutionAcceptTheirs(t *testing.T, speak require.NoError(t, err, "Failed to read typescript file after final regeneration") require.Contains(t, string(tsContentFinal), "custom code in typescript target", "TypeScript file should still contain its custom code") } + +// testMultiTargetCustomCodeConflictMultipleResultionsAcceptTheirsFromOurs tests conflict resolution in two targets +// sequentially, accepting custom code changes (theirs) instead of spec changes. +func testMultiTargetCustomCodeConflictMultipleResultionsAcceptTheirs(t *testing.T, speakeasyBinary string) { + temp := setupMultiTargetSDKGeneration(t, speakeasyBinary, "customcodespec.yaml") + + // Paths to all target files + goFilePath := filepath.Join(temp, "go", "models", "operations", "getuserbyname.go") + tsFilePath := filepath.Join(temp, "typescript", "src", "models", "operations", "getuserbyname.ts") + + // Step 1: Add custom code to ALL targets + modifyLineInFileByPrefix(t, goFilePath, "// The name that needs to be", "\t// custom code in go target") + modifyLineInFileByPrefix(t, tsFilePath, "* The name that needs to be", "// custom code in typescript target") + + // Step 2: Register custom code for all targets + customCodeCmd := exec.Command(speakeasyBinary, "customcode", "--output", "console") + customCodeCmd.Dir = temp + customCodeOutput, customCodeErr := customCodeCmd.CombinedOutput() + require.NoError(t, customCodeErr, "customcode command should succeed: %s", string(customCodeOutput)) + + // Step 3: Verify patch files were created for both targets + goPatchFile := filepath.Join(temp, "go", ".speakeasy", "patches", "custom-code.diff") + _, err := os.Stat(goPatchFile) + require.NoError(t, err, "Go patch file should exist") + + tsPatchFile := filepath.Join(temp, "typescript", ".speakeasy", "patches", "custom-code.diff") + _, err = os.Stat(tsPatchFile) + require.NoError(t, err, "TypeScript patch file should exist") + + // Step 4: Modify the spec to cause conflict in GO target only (line 477 affects GetUserByName) + specPath := filepath.Join(temp, "customcodespec.yaml") + modifyLineInFileByPrefix(t, specPath, " description: 'The name that needs to be fetched", " description: 'spec change'") + +// ------First Round + // Step 5: Run speakeasy run - should detect conflict in GO target only + regenCmd := exec.Command(speakeasyBinary, "run", "-t", "all", "--pinned", "--skip-compile") + regenCmd.Dir = temp + regenOutput, regenErr := regenCmd.CombinedOutput() + require.Error(t, regenErr, "speakeasy run should exit with error after detecting conflicts: %s", string(regenOutput)) + require.Contains(t, string(regenOutput), "CUSTOM CODE CONFLICTS DETECTED", "Output should show conflict detection banner") + require.Contains(t, string(regenOutput), "Entering automatic conflict resolution mode", "Output should indicate automatic resolution mode") + + // Step 6: Verify conflict markers present in GO file only + goContentAfterConflict, err := os.ReadFile(goFilePath) + require.NoError(t, err, "Failed to read go file after conflict") + require.Contains(t, string(goContentAfterConflict), "<<<<<<<", "Go file should contain conflict markers") + + // TypeScript file should NOT have conflict markers + tsContentAfterConflict, err := os.ReadFile(tsFilePath) + require.NoError(t, err, "Failed to read typescript file after conflict") + require.NotContains(t, string(tsContentAfterConflict), "<<<<<<<", "TypeScript file should not contain conflict markers") + require.Contains(t, string(tsContentAfterConflict), "custom code in typescript target", "TypeScript file should still have its custom code") + + // Step 7: Resolve the go conflict by accepting custom code changes (theirs) + checkoutCmd := exec.Command("git", "checkout", "--theirs", goFilePath) + checkoutCmd.Dir = temp + checkoutOutput, checkoutErr := checkoutCmd.CombinedOutput() + require.NoError(t, checkoutErr, "git checkout --theirs should succeed: %s", string(checkoutOutput)) + + // Step 8: Verify conflict markers are gone in go file + goContentAfterCheckout, err := os.ReadFile(goFilePath) + require.NoError(t, err, "Failed to read go file after checkout") + require.NotContains(t, string(goContentAfterCheckout), "<<<<<<<", "Go file should not contain conflict markers after checkout") + + // Step 9: Stage the resolved go file + gitAddCmd := exec.Command("git", "add", goFilePath) + gitAddCmd.Dir = temp + gitAddOutput, gitAddErr := gitAddCmd.CombinedOutput() + require.NoError(t, gitAddErr, "git add should succeed: %s", string(gitAddOutput)) + + // Step 10: Run customcode command to register the resolution + customCodeCmd2 := exec.Command(speakeasyBinary, "customcode", "--output", "console") + customCodeCmd2.Dir = temp + customCodeOutput2, customCodeErr2 := customCodeCmd2.CombinedOutput() + require.NoError(t, customCodeErr2, "customcode command should succeed after conflict resolution: %s", string(customCodeOutput2)) + + // Step 11: Verify patch files status + // Go patch file should still exist with content since we accepted theirs (custom code) + goPatchContent, err := os.ReadFile(goPatchFile) + require.NoError(t, err, "Go patch file should still exist") + require.NotEmpty(t, goPatchContent, "Go patch file should not be empty after accepting theirs") + require.Contains(t, string(goPatchContent), "custom code in go target", "Go patch should contain go custom code") + + // TypeScript patch file should still exist with its content + tsPatchContent, err := os.ReadFile(tsPatchFile) + require.NoError(t, err, "TypeScript patch file should still exist") + require.NotEmpty(t, tsPatchContent, "TypeScript patch file should not be empty") + require.Contains(t, string(tsPatchContent), "custom code in typescript target", "TypeScript patch should contain typescript custom code") + + // Step 12: Verify gen.lock files + // Go's gen.lock should still contain customCodeCommitHash since we kept custom code + goGenLockPath := filepath.Join(temp, "go", ".speakeasy", "gen.lock") + goGenLockContent, err := os.ReadFile(goGenLockPath) + require.NoError(t, err, "Failed to read go gen.lock") + require.Contains(t, string(goGenLockContent), "customCodeCommitHash", "Go gen.lock should contain customCodeCommitHash after accepting theirs") + + // TypeScript's gen.lock should still contain customCodeCommitHash + tsGenLockPath := filepath.Join(temp, "typescript", ".speakeasy", "gen.lock") + tsGenLockContent, err := os.ReadFile(tsGenLockPath) + require.NoError(t, err, "Failed to read typescript gen.lock") + require.Contains(t, string(tsGenLockContent), "customCodeCommitHash", "TypeScript gen.lock should still contain customCodeCommitHash") + +// ------Second Round + + // Step 13: Run regeneration again - should detect conflict in TS target. + regenCmd2 := exec.Command(speakeasyBinary, "run", "-t", "all", "--pinned", "--skip-compile") + regenCmd2.Dir = temp + regenOutput2, regenErr2 := regenCmd2.CombinedOutput() + require.Error(t, regenErr2, "speakeasy run should exit with error after detecting conflicts: %s", string(regenOutput2)) + require.Contains(t, string(regenOutput2), "CUSTOM CODE CONFLICTS DETECTED", "Output should show conflict detection banner") + require.Contains(t, string(regenOutput2), "Entering automatic conflict resolution mode", "Output should indicate automatic resolution mode") + + // Step 14: Verify conflict markers present in TS file only + tsContentAfterConflict2, err := os.ReadFile(tsFilePath) + require.NoError(t, err, "Failed to read ts file after conflict") + require.Contains(t, string(tsContentAfterConflict2), "<<<<<<<", "TS file should contain conflict markers") + + // Go file should NOT have conflict markers + goContentAfterConflict2, err := os.ReadFile(goFilePath) + require.NoError(t, err, "Failed to read go file after conflict") + require.NotContains(t, string(goContentAfterConflict2), "<<<<<<<", "GO file should not contain conflict markers") + + // Step 15: Resolve the ts conflict by accepting custom code changes (theirs) + checkoutCmd2 := exec.Command("git", "checkout", "--theirs", tsFilePath) + checkoutCmd2.Dir = temp + checkoutOutput2, checkoutErr2 := checkoutCmd2.CombinedOutput() + require.NoError(t, checkoutErr2, "git checkout --theirs should succeed: %s", string(checkoutOutput2)) + + // Step 16: Verify conflict markers are gone in ts file + tsContentAfterCheckout2, err := os.ReadFile(tsFilePath) + require.NoError(t, err, "Failed to read ts file after checkout") + require.NotContains(t, string(tsContentAfterCheckout2), "<<<<<<<", "TS file should not contain conflict markers after checkout") + + // Step 17: Stage the resolved ts file + gitAddCmd2 := exec.Command("git", "add", tsFilePath) + gitAddCmd2.Dir = temp + gitAddOutput2, gitAddErr2 := gitAddCmd2.CombinedOutput() + require.NoError(t, gitAddErr2, "git add should succeed: %s", string(gitAddOutput2)) + + // Step 18: Run customcode command to register the resolution + customCodeCmd3 := exec.Command(speakeasyBinary, "customcode", "--output", "console") + customCodeCmd3.Dir = temp + customCodeOutput3, customCodeErr3 := customCodeCmd3.CombinedOutput() + require.NoError(t, customCodeErr3, "customcode command should succeed after conflict resolution: %s", string(customCodeOutput3)) + + // Step 19: Verify patch files status + // TS patch file should still exist with content since we accepted theirs (custom code) + tsPatchContent2, err := os.ReadFile(tsPatchFile) + require.NoError(t, err, "TS patch file should still exist") + require.NotEmpty(t, tsPatchContent2, "TS patch file should not be empty after accepting theirs") + require.Contains(t, string(tsPatchContent2), "custom code in typescript target", "TS patch should contain typescript custom code") + + // Step 20: Verify gen.lock files + // Go's gen.lock should still contain customCodeCommitHash since we kept custom code + goGenLockPath2 := filepath.Join(temp, "go", ".speakeasy", "gen.lock") + goGenLockContent2, err := os.ReadFile(goGenLockPath2) + require.NoError(t, err, "Failed to read go gen.lock") + require.Contains(t, string(goGenLockContent2), "customCodeCommitHash", "Go gen.lock should contain customCodeCommitHash after accepting theirs") + + // TypeScript's gen.lock should still contain customCodeCommitHash since we kept custom code + tsGenLockPath2 := filepath.Join(temp, "typescript", ".speakeasy", "gen.lock") + tsGenLockContent2, err := os.ReadFile(tsGenLockPath2) + require.NoError(t, err, "Failed to read typescript gen.lock") + require.Contains(t, string(tsGenLockContent2), "customCodeCommitHash", "TS gen.lock should contain customCodeCommitHash after accepting theirs") +// ----------------- + + // Step 21: Run final regeneration to ensure everything works + runRegeneration(t, speakeasyBinary, temp, true) + + // Step 22: Verify final state + // Go file should contain custom code (since we accepted theirs), and should NOT contain spec change + goContentFinal, err := os.ReadFile(goFilePath) + require.NoError(t, err, "Failed to read go file after final regeneration") + require.NotContains(t, string(goContentFinal), "spec change", "Go file should not contain spec change after accepting theirs") + require.Contains(t, string(goContentFinal), "custom code in go target", "Go file should contain custom code after accepting theirs") + + // TypeScript file should contain custom code (since we accepted theirs), and should NOT contain spec change + tsContentFinal, err := os.ReadFile(tsFilePath) + require.NoError(t, err, "Failed to read typescript file after final regeneration") + require.NotContains(t, string(tsContentFinal), "spec change", "TypeScript file should not contain spec change after accepting theirs") + require.Contains(t, string(tsContentFinal), "custom code in typescript target", "TypeScript file should contain custom code after accepting theirs") +} \ No newline at end of file diff --git a/internal/registercustomcode/registercustomcode.go b/internal/registercustomcode/registercustomcode.go index 2391838bd..72eeef93d 100644 --- a/internal/registercustomcode/registercustomcode.go +++ b/internal/registercustomcode/registercustomcode.go @@ -456,8 +456,19 @@ func completeConflictResolution(ctx context.Context, wf *workflow.Workflow) erro checkCommitCmd := exec.Command("git", "log", "-1", "--grep=clean generation (conflict resolution)", "--format=%H", "--", outDir) commitOutput, err := checkCommitCmd.Output() if err == nil && strings.TrimSpace(string(commitOutput)) != "" { - targetsInResolution[targetName] = true - logger.Info(fmt.Sprintf("Target %s was part of conflict resolution", targetName)) + // Get the most recent commit hash + headCommitCmd := exec.Command("git", "rev-parse", "HEAD") + headCommitOutput, headErr := headCommitCmd.Output() + if headErr == nil { + commitHash := strings.TrimSpace(string(commitOutput)) + headCommitHash := strings.TrimSpace(string(headCommitOutput)) + + // Only consider it part of resolution if the commit is the HEAD commit + if commitHash == headCommitHash { + targetsInResolution[targetName] = true + logger.Info(fmt.Sprintf("Target %s was part of conflict resolution", targetName)) + } + } } } From 9f41acdf64e6b1a6e707769e288227f90c157d7a Mon Sep 17 00:00:00 2001 From: Ian Bentley Date: Wed, 5 Nov 2025 15:08:44 -0500 Subject: [PATCH 41/42] cleanup and fix tests --- cmd/customcode.go | 26 +----- integration/customcode_multitarget_test.go | 6 +- integration/customcode_singletarget_test.go | 80 ++++++++++--------- .../registercustomcode/registercustomcode.go | 58 ++++++++++++++ 4 files changed, 106 insertions(+), 64 deletions(-) diff --git a/cmd/customcode.go b/cmd/customcode.go index c6527eddf..8926c7119 100644 --- a/cmd/customcode.go +++ b/cmd/customcode.go @@ -48,24 +48,6 @@ var registerCustomCodeCmd = &model.ExecutableCommand[RegisterCustomCodeFlags]{ Shorthand: "a", Description: "apply existing custom code patches without running generation", }, - flag.BooleanFlag{ - Name: "latest-hash", - Description: "show the latest commit hash from gen.lock that contains custom code changes", - }, - flag.StringFlag{ - Name: "installationURL", - Shorthand: "i", - Description: "the language specific installation URL for installation instructions if the SDK is not published to a package manager", - }, - flag.MapFlag{ - Name: "installationURLs", - Description: "a map from target ID to installation URL for installation instructions if the SDK is not published to a package manager", - }, - flag.StringFlag{ - Name: "repo", - Shorthand: "r", - Description: "the repository URL for the SDK, if the published (-p) flag isn't used this will be used to generate installation instructions", - }, flag.EnumFlag{ Name: "output", Shorthand: "o", @@ -104,18 +86,12 @@ func registerCustomCode(ctx context.Context, flags RegisterCustomCodeFlags) erro if err != nil { return fmt.Errorf("Could not find workflow file") } - for targetName, target := range wf.Targets { - fmt.Println("Applying target ", targetName) + for _, target := range wf.Targets { registercustomcode.ApplyCustomCodePatch(ctx, target) } return nil } - // If --latest-hash flag is provided, show the commit hash from gen.lock - if flags.LatestHash { - return registercustomcode.ShowLatestCommitHash(ctx) - } - // Call the registercustomcode functionality return registercustomcode.RegisterCustomCode(ctx, func(targetName string) error { opts := []run.Opt{ diff --git a/integration/customcode_multitarget_test.go b/integration/customcode_multitarget_test.go index e3a84655c..648e3a983 100644 --- a/integration/customcode_multitarget_test.go +++ b/integration/customcode_multitarget_test.go @@ -32,7 +32,7 @@ func TestMultiTargetCustomCode(t *testing.T) { testMultiTargetIncrementalCustomCode(t, speakeasyBinary) }) - t.Run("ConflictResolutionAcceptOurs", func(t *testing.T) { + t.Run("ConflictResolutionAcceptOurs1", func(t *testing.T) { t.Parallel() testMultiTargetCustomCodeConflictResolutionAcceptOurs(t, speakeasyBinary) }) @@ -296,7 +296,7 @@ func testMultiTargetCustomCodeConflictResolutionAcceptOurs(t *testing.T, speakea // Step 1: Add custom code to ALL targets modifyLineInFileByPrefix(t, goFilePath, "// The name that needs to be", "\t// custom code in go target") - modifyLineInFileByPrefix(t, tsFilePath, "* @deprecated This namespace", "// custom code in typescript target") + modifyLineInFileByPrefix(t, tsFilePath, "* @internal", "// custom code in typescript target") // Step 2: Register custom code for all targets customCodeCmd := exec.Command(speakeasyBinary, "customcode", "--output", "console") @@ -593,7 +593,7 @@ func testMultiTargetCustomCodeConflictResolutionAcceptTheirs(t *testing.T, speak // Step 1: Add custom code to ALL targets modifyLineInFileByPrefix(t, goFilePath, "// The name that needs to be", "\t// custom code in go target") - modifyLineInFileByPrefix(t, tsFilePath, "* @deprecated This namespace", "// custom code in typescript target") + modifyLineInFileByPrefix(t, tsFilePath, "* @internal", "// custom code in typescript target") // Step 2: Register custom code for all targets customCodeCmd := exec.Command(speakeasyBinary, "customcode", "--output", "console") diff --git a/integration/customcode_singletarget_test.go b/integration/customcode_singletarget_test.go index 2a69c34c5..ce5f48306 100644 --- a/integration/customcode_singletarget_test.go +++ b/integration/customcode_singletarget_test.go @@ -595,38 +595,52 @@ func ValidateUserID(id int64) bool { gitAddOutput, gitAddErr := gitAddCmd.CombinedOutput() require.NoError(t, gitAddErr, "git add should succeed: %s", string(gitAddOutput)) - // Register custom code + // Try to register custom code with new file - should fail customCodeCmd := exec.Command(speakeasyBinary, "customcode", "--output", "console") customCodeCmd.Dir = temp customCodeOutput, customCodeErr := customCodeCmd.CombinedOutput() - require.NoError(t, customCodeErr, "customcode command should succeed: %s", string(customCodeOutput)) + require.Error(t, customCodeErr, "customcode command should fail with new file: %s", string(customCodeOutput)) + require.Contains(t, string(customCodeOutput), "Cannot register new files through customcode", "Error should mention new files not supported") - // Verify patch file was created + // Commit the new file to make it part of the codebase + gitCommitCmd := exec.Command("git", "commit", "-m", "Add helper file") + gitCommitCmd.Dir = temp + _, err = gitCommitCmd.CombinedOutput() + require.NoError(t, err, "git commit should succeed") + + // Now make a modification to an existing file to test normal customcode flow + getUserByNamePath := filepath.Join(temp, "models", "operations", "getuserbyname.go") + registerCustomCodeByPrefix(t, speakeasyBinary, temp, getUserByNamePath, "// The name that needs to be fetched", "\t// custom code in existing file") + + // Verify patch file was created for the modification patchFile := filepath.Join(temp, ".speakeasy", "patches", "custom-code.diff") _, err = os.Stat(patchFile) - require.NoError(t, err, "patch file should exist at %s", patchFile) + require.NoError(t, err, "patch file should exist for file modifications") // Verify the file exists after registration (before regeneration) _, err = os.Stat(helperFilePath) require.NoError(t, err, "Helper file should exist after registration") // Run speakeasy run to regenerate the SDK - // This should apply the patch and preserve the new file + // This should apply the patch for the existing file modification and preserve the committed helper file runRegeneration(t, speakeasyBinary, temp, true) - // Verify the new file still exists after regeneration + // Verify the helper file still exists after regeneration (it was committed, so should be preserved) _, err = os.Stat(helperFilePath) require.NoError(t, err, "Helper file should exist after regeneration") - // Verify the file contents are preserved exactly + // Verify the helper file contents are preserved exactly verifyCustomCodePresent(t, helperFilePath, "FormatUserID") verifyCustomCodePresent(t, helperFilePath, "ValidateUserID") verifyCustomCodePresent(t, helperFilePath, "package utils") - // Read the entire file and verify exact content match + // Read the entire helper file and verify exact content match actualContent, err := os.ReadFile(helperFilePath) require.NoError(t, err, "Failed to read helper file after regeneration") require.Equal(t, helperFileContent, string(actualContent), "Helper file content should be preserved exactly") + + // Verify the custom code modification was applied to the existing file + verifyCustomCodePresent(t, getUserByNamePath, "// custom code in existing file") } // testCustomCodeNewFileDeletion tests that deleting a custom file is properly registered and persisted @@ -659,44 +673,38 @@ func FormatUserID(id int64) string { _, err = gitAddCmd.CombinedOutput() require.NoError(t, err, "git add should succeed") - // Register custom code (registers the new file) - customCodeCmd := exec.Command(speakeasyBinary, "customcode", "--output", "console") - customCodeCmd.Dir = temp - customCodeOutput, customCodeErr := customCodeCmd.CombinedOutput() - require.NoError(t, customCodeErr, "customcode command should succeed: %s", string(customCodeOutput)) - - // Verify patch file was created - patchFile := filepath.Join(temp, ".speakeasy", "patches", "custom-code.diff") - _, err = os.Stat(patchFile) - require.NoError(t, err, "patch file should exist after registering new file") - - // Regenerate and verify the file is preserved - runRegeneration(t, speakeasyBinary, temp, true) - _, err = os.Stat(helperFilePath) - require.NoError(t, err, "Helper file should exist after first regeneration") - - // Commit the regeneration so the file becomes part of HEAD - gitCommitCmd := exec.Command("git", "commit", "-am", "regeneration with custom file") + // Commit the new file to make it part of the codebase + gitCommitCmd := exec.Command("git", "commit", "-m", "Add helper file") gitCommitCmd.Dir = temp _, err = gitCommitCmd.CombinedOutput() - require.NoError(t, err, "git commit should succeed after regeneration") + require.NoError(t, err, "git commit should succeed") // Now delete the file err = os.Remove(helperFilePath) require.NoError(t, err, "Failed to delete helper file") - // Register the deletion - customCodeCmd = exec.Command(speakeasyBinary, "customcode", "--output", "console") - customCodeCmd.Dir = temp - customCodeOutput, customCodeErr = customCodeCmd.CombinedOutput() - require.NoError(t, customCodeErr, "customcode command should succeed after deletion: %s", string(customCodeOutput)) + // Commit the deletion + gitCommitDeleteCmd := exec.Command("git", "commit", "-am", "Delete helper file") + gitCommitDeleteCmd.Dir = temp + _, err = gitCommitDeleteCmd.CombinedOutput() + require.NoError(t, err, "git commit for deletion should succeed") + + // Now make a modification to an existing file to test normal customcode flow + getUserByNamePath := filepath.Join(temp, "models", "operations", "getuserbyname.go") + registerCustomCodeByPrefix(t, speakeasyBinary, temp, getUserByNamePath, "// The name that needs to be fetched", "\t// custom code for deletion test") - // Verify patch file was removed (no custom code remaining) + // Verify patch file was created for the modification + patchFile := filepath.Join(temp, ".speakeasy", "patches", "custom-code.diff") _, err = os.Stat(patchFile) - require.True(t, os.IsNotExist(err), "patch file should not exist after deleting the only custom file") + require.NoError(t, err, "patch file should exist for file modifications") - // Regenerate and verify the file remains deleted + // Run regeneration to test that the custom code is preserved and deleted file stays deleted runRegeneration(t, speakeasyBinary, temp, true) + + // Verify the helper file does NOT exist after regeneration (it was deleted and committed) _, err = os.Stat(helperFilePath) - require.True(t, os.IsNotExist(err), "Helper file should not exist after regeneration with deletion registered") + require.True(t, os.IsNotExist(err), "Helper file should not exist after regeneration (was deleted)") + + // Verify the custom code modification was applied to the existing file + verifyCustomCodePresent(t, getUserByNamePath, "// custom code for deletion test") } diff --git a/internal/registercustomcode/registercustomcode.go b/internal/registercustomcode/registercustomcode.go index 72eeef93d..dbde9a829 100644 --- a/internal/registercustomcode/registercustomcode.go +++ b/internal/registercustomcode/registercustomcode.go @@ -675,6 +675,11 @@ func isLocalPath(location workflow.LocationString) bool { // Git operations func captureCustomCodeDiff(outDir string, excludePaths []string) (string, error) { + // First, check for new files and deletions in the staged changes + if err := checkForNewFilesOrDeletions(outDir); err != nil { + return "", err + } + args := []string{"diff", "HEAD", outDir} // Filter excludePaths to only include children of outDir @@ -699,6 +704,59 @@ func captureCustomCodeDiff(outDir string, excludePaths []string) (string, error) return string(combinedOutput), nil } +// checkForNewFilesOrDeletions checks if there are any new files or file deletions +// in the staged changes and returns an error if found, as these are not supported +func checkForNewFilesOrDeletions(outDir string) error { + // Check for new files (A) and deletions (D) using git diff with filter + cmd := exec.Command("git", "diff", "--name-status", "--diff-filter=AD", "HEAD", outDir) + output, err := cmd.Output() + if err != nil { + return fmt.Errorf("failed to check for new files or deletions: %w", err) + } + + outputStr := strings.TrimSpace(string(output)) + if outputStr == "" { + // No additions or deletions found + return nil + } + + lines := strings.Split(outputStr, "\n") + var newFiles []string + var deletedFiles []string + + for _, line := range lines { + if line == "" { + continue + } + + // Format is "STATUS\tFILENAME" + parts := strings.SplitN(line, "\t", 2) + if len(parts) != 2 { + continue + } + + status := parts[0] + filename := parts[1] + + if status == "A" { + newFiles = append(newFiles, filename) + } else if status == "D" { + deletedFiles = append(deletedFiles, filename) + } + } + + // Return error if we found new files or deletions + if len(newFiles) > 0 { + return fmt.Errorf("Cannot register new files through customcode. New files found: %s", strings.Join(newFiles, ", ")) + } + + if len(deletedFiles) > 0 { + return fmt.Errorf("Cannot register file deletions through customcode. Deleted files found: %s", strings.Join(deletedFiles, ", ")) + } + + return nil +} + func checkForChangesWithExclusions(dir string, excludePaths []string) (bool, error) { args := []string{"diff", "--cached", dir} From b639ee1ce191a362cee8f6c779729026bcb2f1a8 Mon Sep 17 00:00:00 2001 From: Ian Bentley Date: Wed, 5 Nov 2025 15:14:37 -0500 Subject: [PATCH 42/42] cleanup --- cmd/customcode.go | 1 - cmd/root.go | 1 - internal/model/command.go | 5 ++--- 3 files changed, 2 insertions(+), 5 deletions(-) diff --git a/cmd/customcode.go b/cmd/customcode.go index 8926c7119..ded64369f 100644 --- a/cmd/customcode.go +++ b/cmd/customcode.go @@ -20,7 +20,6 @@ type RegisterCustomCodeFlags struct { Show bool `json:"show"` Apply bool `json:"apply-only"` ApplyReverse bool `json:"apply-reverse"` - LatestHash bool `json:"latest-hash"` InstallationURL string `json:"installationURL"` InstallationURLs map[string]string `json:"installationURLs"` Repo string `json:"repo"` diff --git a/cmd/root.go b/cmd/root.go index a0ace9382..14263090d 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -113,7 +113,6 @@ func addCommand(cmd *cobra.Command, command model.Command) { func CmdForTest(version, artifactArch string) *cobra.Command { setupRootCmd(version, artifactArch) - return rootCmd } diff --git a/internal/model/command.go b/internal/model/command.go index 3f94d69a6..fc41cb2a8 100644 --- a/internal/model/command.go +++ b/internal/model/command.go @@ -318,6 +318,7 @@ func runWithVersionFromWorkflowFile(cmd *cobra.Command) error { } else { logger.PrintfStyled(styles.DimmedItalic, "Running with speakeasyVersion defined in workflow.yaml\n") } + // Get lockfile version before running the command, in case it gets overwritten lockfileVersion := getSpeakeasyVersionFromLockfile() @@ -342,7 +343,7 @@ func runWithVersionFromWorkflowFile(cmd *cobra.Command) error { if lockfileVersion != "" && lockfileVersion != desiredVersion { logger.PrintfStyled(styles.DimmedItalic, "Rerunning with previous successful version") - return runWithVersion(cmd, artifactArch, "anyrandomstring", false) + return runWithVersion(cmd, artifactArch, lockfileVersion, false) } } @@ -353,7 +354,6 @@ func runWithVersionFromWorkflowFile(cmd *cobra.Command) error { return nil } - // If promote is true, the version will be promoted to the default version (ie when running `speakeasy`) func runWithVersion(cmd *cobra.Command, artifactArch, desiredVersion string, shouldPromote bool) error { vLocation, err := updates.InstallVersion(cmd.Context(), desiredVersion, artifactArch, 30) @@ -391,7 +391,6 @@ func runWithVersion(cmd *cobra.Command, artifactArch, desiredVersion string, sho return nil } - func promoteVersion(ctx context.Context, vLocation string) error { mutex := locks.CLIUpdateLock() for result := range mutex.TryLock(ctx, 1*time.Second) {