From 43eee4dba93f11f0b659e9c509f6b2dc6a07998e Mon Sep 17 00:00:00 2001 From: Ian Bentley Date: Tue, 23 Sep 2025 09:17:20 -0400 Subject: [PATCH 01/32] 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 bc3b881d3..893f9dc89 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -96,6 +96,8 @@ func Init(version, artifactArch string) { addCommand(rootCmd, AskCmd) addCommand(rootCmd, reproCmd) pullInit() + addCommand(rootCmd, pullCmd) + addCommand(rootCmd, registerCustomCodeCmd) } func addCommand(cmd *cobra.Command, command model.Command) { From 7f40e38e3150892f6e068fd1fe7092c6cdc12a3a Mon Sep 17 00:00:00 2001 From: Ian Bentley Date: Tue, 23 Sep 2025 17:02:16 -0400 Subject: [PATCH 02/32] 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 893f9dc89..7484737dd 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -96,7 +96,7 @@ func Init(version, artifactArch string) { addCommand(rootCmd, AskCmd) addCommand(rootCmd, reproCmd) pullInit() - addCommand(rootCmd, pullCmd) + // addCommand(rootCmd, pullCmd) addCommand(rootCmd, registerCustomCodeCmd) } diff --git a/go.mod b/go.mod index cd95fc3cc..74be10a1e 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 e49569994..79bb8f6d2 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 baeb1c903bf84c4e9083bbe1bfae390f9cf45695 Mon Sep 17 00:00:00 2001 From: Ian Bentley Date: Wed, 24 Sep 2025 15:01:54 -0400 Subject: [PATCH 03/32] 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 baeb01f97..f2d1dc3d2 100644 --- a/internal/model/command.go +++ b/internal/model/command.go @@ -333,7 +333,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 bd331df5df45d173749647949945dd8862d60daf Mon Sep 17 00:00:00 2001 From: Ivan Gorshkov Date: Fri, 26 Sep 2025 18:04:37 +0100 Subject: [PATCH 04/32] 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 40fa9ea94..a6ae3b879 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 // Execute the workflow based on output mode switch output { 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 a4adeea63..67ec116bf 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 a245aa67f40110baf01b6d200b3b1f7bdc610b9d Mon Sep 17 00:00:00 2001 From: Ivan Gorshkov Date: Mon, 29 Sep 2025 11:49:59 +0100 Subject: [PATCH 05/32] 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 2cf8accfb342e72d7c1fad7a45e62c409df14d16 Mon Sep 17 00:00:00 2001 From: Ivan Gorshkov Date: Mon, 29 Sep 2025 13:21:10 +0100 Subject: [PATCH 06/32] 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 a14a325ac0e88a8aae1e4f57af2ae2663d5e7578 Mon Sep 17 00:00:00 2001 From: Ian Bentley Date: Mon, 29 Sep 2025 11:10:30 -0400 Subject: [PATCH 07/32] 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 b386b9680992b3a781fd2301e37b80e2f06048ba Mon Sep 17 00:00:00 2001 From: Ian Bentley Date: Mon, 29 Sep 2025 12:13:45 -0400 Subject: [PATCH 08/32] 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 bf1d042cedb344c818cfd27b7fba746c9b2ad3a0 Mon Sep 17 00:00:00 2001 From: Ian Bentley Date: Mon, 29 Sep 2025 15:25:24 -0400 Subject: [PATCH 09/32] 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 fe95a30bec33d1d83ce545e91add443920e7a81b Mon Sep 17 00:00:00 2001 From: Ian Bentley Date: Mon, 29 Sep 2025 17:19:50 -0400 Subject: [PATCH 10/32] 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 f2d1dc3d2..7a792e5cb 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" @@ -310,74 +308,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 7c5d20f34c7f75c4dc28dbf1806ba0341c424da0 Mon Sep 17 00:00:00 2001 From: Ivan Gorshkov Date: Wed, 1 Oct 2025 11:26:53 +0100 Subject: [PATCH 11/32] 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 774412acb27d5dedccfc1424f46d2424ef2a05bb Mon Sep 17 00:00:00 2001 From: Ian Bentley Date: Wed, 1 Oct 2025 11:24:33 -0400 Subject: [PATCH 12/32] 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 7a792e5cb..a3babe384 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" @@ -349,26 +349,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 c89874452..25a4bed27 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 bd1fe4097a4a4df1ef85668b7cbceb8dbf6d292d Mon Sep 17 00:00:00 2001 From: Ian Bentley Date: Wed, 1 Oct 2025 11:57:53 -0400 Subject: [PATCH 13/32] 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 7484737dd..27484b487 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -95,9 +95,9 @@ func Init(version, artifactArch string) { addCommand(rootCmd, AskCmd) addCommand(rootCmd, reproCmd) + addCommand(rootCmd, registerCustomCodeCmd) pullInit() // addCommand(rootCmd, pullCmd) - addCommand(rootCmd, registerCustomCodeCmd) } func addCommand(cmd *cobra.Command, command model.Command) { @@ -112,6 +112,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 4934fb4d499612119c3c30075e06f3b33eadd9ff Mon Sep 17 00:00:00 2001 From: Ian Bentley Date: Wed, 1 Oct 2025 14:03:07 -0400 Subject: [PATCH 14/32] 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 a3babe384..0e7abf3cd 100644 --- a/internal/model/command.go +++ b/internal/model/command.go @@ -350,9 +350,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 54c9e97ab8b6ddddf2a64b5e11cb5429b8ace216 Mon Sep 17 00:00:00 2001 From: Ian Bentley Date: Wed, 1 Oct 2025 15:24:00 -0400 Subject: [PATCH 15/32] 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 0e7abf3cd..267e5add9 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" @@ -308,48 +310,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:] @@ -366,16 +367,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 67ec116bf..9d7920ff7 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 6864ee12d..592e689ea 100644 --- a/internal/run/workflow.go +++ b/internal/run/workflow.go @@ -62,6 +62,7 @@ type Workflow struct { SourceResults map[string]*SourceResult TargetResults map[string]*TargetResult OnSourceResult SourceResultCallback + SkipApplyCustomCode bool Duration time.Duration criticalWarns []string Error error @@ -290,6 +291,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 9ad5928c9aa84683cbd9a4bf75f349eeca77ef1b Mon Sep 17 00:00:00 2001 From: Ivan Gorshkov Date: Tue, 7 Oct 2025 15:49:14 +0100 Subject: [PATCH 16/32] 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 267e5add9..505d63c05 100644 --- a/internal/model/command.go +++ b/internal/model/command.go @@ -333,7 +333,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 6921d3743ad3006cc140c8c4d3308d06a6e161eb Mon Sep 17 00:00:00 2001 From: Ian Bentley Date: Fri, 3 Oct 2025 09:24:16 -0400 Subject: [PATCH 17/32] 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 f15b70975..d44828ba5 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -34,6 +34,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"` @@ -125,6 +126,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", @@ -355,6 +360,10 @@ func runNonInteractive(ctx context.Context, flags RunFlags) error { run.WithSkipCleanup(), // The studio won't work if we clean up before it launches } + if flags.SkipCustomCode { + opts = append(opts, run.WithSkipApplyCustomCode()) + } + if flags.Minimal { opts = append(opts, minimalOpts...) } @@ -413,6 +422,10 @@ func runInteractive(ctx context.Context, flags RunFlags) error { run.WithSkipCleanup(), // The studio won't work if we clean up before it launches } + 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 505d63c05..b8442aa90 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" @@ -310,26 +310,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") @@ -337,19 +337,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" { @@ -367,7 +367,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 1ccd71ea7fcecd461198470ecff85cc954ab53f4 Mon Sep 17 00:00:00 2001 From: Ian Bentley Date: Tue, 7 Oct 2025 10:31:11 -0400 Subject: [PATCH 18/32] 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 7d4784bd0df97e08831600d9908f25c3c45b80bd Mon Sep 17 00:00:00 2001 From: Ian Bentley Date: Mon, 6 Oct 2025 10:14:00 -0400 Subject: [PATCH 19/32] 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 a8836fb7f7562b8bff5ec616c9d573dc561f9af5 Mon Sep 17 00:00:00 2001 From: Ian Bentley Date: Tue, 7 Oct 2025 10:28:12 -0400 Subject: [PATCH 20/32] 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 1bec3c14df82f615643a944bcb2805c138690e7b Mon Sep 17 00:00:00 2001 From: Ian Bentley Date: Wed, 8 Oct 2025 11:42:18 -0400 Subject: [PATCH 21/32] 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 5f4099f984d5fa553b9276b32fa18880fa891754 Mon Sep 17 00:00:00 2001 From: Ian Bentley Date: Wed, 8 Oct 2025 11:44:26 -0400 Subject: [PATCH 22/32] 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 b8442aa90..59eef9667 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" @@ -308,28 +308,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") @@ -337,19 +336,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" { @@ -367,7 +367,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) } @@ -381,6 +381,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 fb00e40faf2ee66db186e45607b0b3b26314ce6d Mon Sep 17 00:00:00 2001 From: Ian Bentley Date: Wed, 8 Oct 2025 13:02:56 -0400 Subject: [PATCH 23/32] 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 6b307e6a0ca188960a96868ca5a6a01911c5dab0 Mon Sep 17 00:00:00 2001 From: Ian Bentley Date: Wed, 8 Oct 2025 21:28:24 -0400 Subject: [PATCH 24/32] 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 7a94057006e11d90b0304fe1e957bdd0ddc3dfa2 Mon Sep 17 00:00:00 2001 From: Ivan Gorshkov Date: Thu, 9 Oct 2025 14:06:08 +0100 Subject: [PATCH 25/32] 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 518f786ed3ad9877a6b0d2f1b953cf408f7b84bc Mon Sep 17 00:00:00 2001 From: Ivan Gorshkov Date: Fri, 10 Oct 2025 17:43:34 +0100 Subject: [PATCH 26/32] 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 3cc0d057de79caa67a78fe17aa35103fd7724973 Mon Sep 17 00:00:00 2001 From: Ian Bentley Date: Fri, 10 Oct 2025 14:34:57 -0400 Subject: [PATCH 27/32] 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 40895bb0b15352c55dcf4e4857cf2fac76f53b58 Mon Sep 17 00:00:00 2001 From: Ian Bentley Date: Fri, 10 Oct 2025 14:54:06 -0400 Subject: [PATCH 28/32] 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 25f4dd14e40ac0637b5d31bf799cc8cf00de5dc6 Mon Sep 17 00:00:00 2001 From: Ivan Gorshkov Date: Thu, 23 Oct 2025 14:11:34 +0100 Subject: [PATCH 29/32] 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 159d06cbec7b99c3dd694be28f554834936fd04a Mon Sep 17 00:00:00 2001 From: Ivan Gorshkov Date: Thu, 23 Oct 2025 17:53:18 +0100 Subject: [PATCH 30/32] 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 d7de42e7fbf8270b85ba793d608071fe21b2d91d Mon Sep 17 00:00:00 2001 From: Ivan Gorshkov Date: Fri, 24 Oct 2025 17:31:56 +0100 Subject: [PATCH 31/32] 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 1dc7bcd0b2bc55a2019d3bf290104b2ea97c014a Mon Sep 17 00:00:00 2001 From: Ivan Gorshkov Date: Fri, 24 Oct 2025 17:38:07 +0100 Subject: [PATCH 32/32] 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)