diff --git a/cmd/customcode.go b/cmd/customcode.go new file mode 100644 index 000000000..ded64369f --- /dev/null +++ b/cmd/customcode.go @@ -0,0 +1,139 @@ +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" + "go.uber.org/zap" +) + +type RegisterCustomCodeFlags struct { + Show bool `json:"show"` + Apply bool `json:"apply-only"` + ApplyReverse bool `json:"apply-reverse"` + 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: "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.BooleanFlag{ + Name: "show", + Shorthand: "s", + Description: "show custom code patches", + }, + flag.BooleanFlag{ + Name: "apply-only", + Shorthand: "a", + Description: "apply existing custom code patches without running generation", + }, + 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 { + logger := log.From(ctx).With(zap.String("method", "RegisterCustomCode")) + + // If --show flag is provided, show existing customcode + if flags.Show { + 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-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 { + registercustomcode.ApplyCustomCodePatch(ctx, target) + } + return nil + } + + // Call the registercustomcode functionality + 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) + 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/cmd/quickstart.go b/cmd/quickstart.go index 3fb486da3..e26633e2b 100644 --- a/cmd/quickstart.go +++ b/cmd/quickstart.go @@ -484,6 +484,10 @@ func retryWithSampleSpec(ctx context.Context, workflowFile *workflow.Workflow, i run.WithTarget(initialTarget), run.WithShouldCompile(!skipCompile), ) + if err != nil { + return false, err + } + wf.FromQuickstart = true if err != nil { return false, fmt.Errorf("failed to parse workflow: %w", err) diff --git a/cmd/root.go b/cmd/root.go index 7954b81c4..14263090d 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -96,7 +96,9 @@ func Init(version, artifactArch string) { addCommand(rootCmd, AskCmd) addCommand(rootCmd, reproCmd) addCommand(rootCmd, orphanedFilesCmd) + addCommand(rootCmd, registerCustomCodeCmd) pullInit() + // addCommand(rootCmd, pullCmd) } func addCommand(cmd *cobra.Command, command model.Command) { diff --git a/cmd/run.go b/cmd/run.go index 7e7de7bc4..308e49ae8 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -36,6 +36,7 @@ type RunFlags struct { SkipTesting bool `json:"skip-testing"` SkipVersioning bool `json:"skip-versioning"` SkipUploadSpec bool `json:"skip-upload-spec"` + SkipCustomCode bool `json:"skip-custom-code"` FrozenWorkflowLock bool `json:"frozen-workflow-lockfile"` Force bool `json:"force"` Output string `json:"output"` @@ -129,6 +130,10 @@ var runCmd = &model.ExecutableCommand[RunFlags]{ Name: "skip-upload-spec", Description: "skip uploading the spec to the registry", }, + flag.BooleanFlag{ + Name: "skip-custom-code", + Description: "skip applying custom code patches during generation", + }, flag.BooleanFlag{ Name: "frozen-workflow-lockfile", Description: "executes using the stored inputs from the workflow.lock, such that no OAS change occurs", @@ -439,6 +444,10 @@ func runNonInteractive(ctx context.Context, flags RunFlags) error { run.WithSourceLocation(flags.SourceLocation), } + if flags.SkipCustomCode { + opts = append(opts, run.WithSkipApplyCustomCode()) + } + if flags.Minimal { opts = append(opts, minimalOpts...) } @@ -502,6 +511,10 @@ func runInteractive(ctx context.Context, flags RunFlags) error { run.WithSourceLocation(flags.SourceLocation), } + if flags.SkipCustomCode { + opts = append(opts, run.WithSkipApplyCustomCode()) + } + if flags.Minimal { opts = append(opts, minimalOpts...) } diff --git a/go.mod b/go.mod index d7879c1e4..868ba1fb5 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,8 @@ replace github.com/pb33f/doctor => github.com/speakeasy-api/doctor v0.20.0-fixva replace github.com/pb33f/libopenapi => github.com/speakeasy-api/libopenapi v0.21.9-fixhiddencomps-fixed +replace github.com/speakeasy-api/openapi-generation/v2 => ../openapi-generation + require ( github.com/AlekSi/pointer v1.2.0 github.com/KimMachineGun/automemlimit v0.7.1 diff --git a/integration/customcode_multitarget_test.go b/integration/customcode_multitarget_test.go new file mode 100644 index 000000000..648e3a983 --- /dev/null +++ b/integration/customcode_multitarget_test.go @@ -0,0 +1,881 @@ +package integration_tests + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/speakeasy-api/sdk-gen-config/workflow" + "github.com/stretchr/testify/require" +) + +func TestMultiTargetCustomCode(t *testing.T) { + t.Parallel() + + // Build the speakeasy binary once for all subtests (using separate binary name) + speakeasyBinary := buildSpeakeasyBinaryOnce(t, "speakeasy-customcode-multitarget-test-binary") + + t.Run("BasicWorkflowMultiTarget", func(t *testing.T) { + t.Parallel() + testMultiTargetCustomCodeBasicWorkflow(t, speakeasyBinary) + }) + + t.Run("AllTargetsModified", func(t *testing.T) { + t.Parallel() + testMultiTargetCustomCodeAllTargetsModified(t, speakeasyBinary) + }) + + t.Run("IncrementalCustomCodeToOneTarget", func(t *testing.T) { + t.Parallel() + testMultiTargetIncrementalCustomCode(t, speakeasyBinary) + }) + + t.Run("ConflictResolutionAcceptOurs1", func(t *testing.T) { + t.Parallel() + testMultiTargetCustomCodeConflictResolutionAcceptOurs(t, speakeasyBinary) + }) + + t.Run("ConflictResolutionAcceptOursMultipleResolutions", func(t *testing.T) { + t.Parallel() + testMultiTargetCustomCodeConflictMultipleResultionsAcceptOurs(t, speakeasyBinary) + }) + + t.Run("ConflictResolutionAcceptTheirs", func(t *testing.T) { + t.Parallel() + testMultiTargetCustomCodeConflictResolutionAcceptTheirs(t, speakeasyBinary) + }) + + t.Run("ConflictResolutionMultipleResolutionsAcceptTheirs", func(t *testing.T) { + t.Parallel() + testMultiTargetCustomCodeConflictMultipleResultionsAcceptTheirs(t, speakeasyBinary) + }) + +} + +// testMultiTargetCustomCodeBasicWorkflow tests basic custom code registration and reapplication +// in a multi-target scenario (go, typescript) +func testMultiTargetCustomCodeBasicWorkflow(t *testing.T, speakeasyBinary string) { + temp := setupMultiTargetSDKGeneration(t, speakeasyBinary, "customcodespec.yaml") + + // Path to go target file + goFilePath := filepath.Join(temp, "go", "models", "operations", "getuserbyname.go") + + // Step 1: Modify only the go target file + modifyLineInFileByPrefix(t, goFilePath, "// The name that needs to be", "\t// custom code in go target") + + // Step 2: Register custom code + customCodeCmd := exec.Command(speakeasyBinary, "customcode", "--output", "console") + customCodeCmd.Dir = temp + customCodeOutput, customCodeErr := customCodeCmd.CombinedOutput() + require.NoError(t, customCodeErr, "customcode command should succeed: %s", string(customCodeOutput)) + + // Step 3: Verify patch file was created only for go target + goPatchFile := filepath.Join(temp, "go", ".speakeasy", "patches", "custom-code.diff") + _, err := os.Stat(goPatchFile) + require.NoError(t, err, "Go patch file should exist at %s", goPatchFile) + + // Step 4: Verify patch file was NOT created for typescript + tsPatchFile := filepath.Join(temp, "typescript", ".speakeasy", "patches", "custom-code.diff") + _, err = os.Stat(tsPatchFile) + require.True(t, os.IsNotExist(err), "TypeScript patch file should not exist") + + // Step 5: Regenerate all targets + runRegeneration(t, speakeasyBinary, temp, true) + + // Step 6: Verify custom code is present in go target + verifyCustomCodePresent(t, goFilePath, "// custom code in go target") + + // Step 7: Verify typescript file doesn't have the custom code + tsFilePath := filepath.Join(temp, "typescript", "src", "models", "operations", "getuserbyname.ts") + if _, err := os.Stat(tsFilePath); err == nil { + tsContent, err := os.ReadFile(tsFilePath) + require.NoError(t, err, "Failed to read typescript file") + require.NotContains(t, string(tsContent), "custom code in go target", "TypeScript file should not contain go custom code") + } +} + +// testMultiTargetCustomCodeAllTargetsModified tests custom code registration and reapplication +// when all targets (go, typescript) are modified +func testMultiTargetCustomCodeAllTargetsModified(t *testing.T, speakeasyBinary string) { + temp := setupMultiTargetSDKGeneration(t, speakeasyBinary, "customcodespec.yaml") + + // Paths to all target files + goFilePath := filepath.Join(temp, "go", "models", "operations", "getuserbyname.go") + tsFilePath := filepath.Join(temp, "typescript", "src", "models", "operations", "getuserbyname.ts") + + // Step 1: Modify all target files with target-specific custom code + // Modify comment lines that are safe to change + modifyLineInFileByPrefix(t, goFilePath, "// The name that needs to be", "\t// custom code in go target") + modifyLineInFileByPrefix(t, tsFilePath, "* The name that needs to be", "// custom code in typescript target") + + // Step 2: Register custom code + customCodeCmd := exec.Command(speakeasyBinary, "customcode", "--output", "console") + customCodeCmd.Dir = temp + customCodeOutput, customCodeErr := customCodeCmd.CombinedOutput() + require.NoError(t, customCodeErr, "customcode command should succeed: %s", string(customCodeOutput)) + + // Step 3: Verify patch files were created for all targets + goPatchFile := filepath.Join(temp, "go", ".speakeasy", "patches", "custom-code.diff") + _, err := os.Stat(goPatchFile) + require.NoError(t, err, "Go patch file should exist") + + tsPatchFile := filepath.Join(temp, "typescript", ".speakeasy", "patches", "custom-code.diff") + _, err = os.Stat(tsPatchFile) + require.NoError(t, err, "TypeScript patch file should exist") + + // Step 4: Regenerate all targets + runRegeneration(t, speakeasyBinary, temp, true) + + // Step 5: Verify each target has its own custom code + goContent, err := os.ReadFile(goFilePath) + require.NoError(t, err, "Failed to read go file") + require.Contains(t, string(goContent), "custom code in go target", "Go file should contain go custom code") + + tsContent, err := os.ReadFile(tsFilePath) + require.NoError(t, err, "Failed to read typescript file") + require.Contains(t, string(tsContent), "custom code in typescript target", "TypeScript file should contain typescript custom code") + + // Step 6: Verify no cross-contamination between targets + require.NotContains(t, string(goContent), "custom code in typescript target", "Go file should not contain typescript custom code") + require.NotContains(t, string(tsContent), "custom code in go target", "TypeScript file should not contain go custom code") +} + +// setupMultiTargetSDKGeneration sets up a test directory with multi-target SDK generation +// and git initialization in the root +func setupMultiTargetSDKGeneration(t *testing.T, speakeasyBinary, inputDoc string) string { + t.Helper() + + temp := setupCustomCodeTestDir(t) + + // Create workflow file with multiple targets + workflowFile := &workflow.Workflow{ + Version: workflow.WorkflowVersion, + Sources: make(map[string]workflow.Source), + Targets: make(map[string]workflow.Target), + } + + workflowFile.Sources["first-source"] = workflow.Source{ + Inputs: []workflow.Document{ + { + Location: workflow.LocationString(inputDoc), + }, + }, + } + + // Setup two targets: go, typescript + goOutput := "go" + tsOutput := "typescript" + + workflowFile.Targets["go-target"] = workflow.Target{ + Target: "go", + Source: "first-source", + Output: &goOutput, + } + + workflowFile.Targets["typescript-target"] = workflow.Target{ + Target: "typescript", + Source: "first-source", + Output: &tsOutput, + } + + if isLocalFileReference(inputDoc) { + err := copyFile("resources/customcodespec.yaml", fmt.Sprintf("%s/%s", temp, inputDoc)) + require.NoError(t, err) + } + + err := os.MkdirAll(filepath.Join(temp, ".speakeasy"), 0o755) + require.NoError(t, err) + err = workflow.Save(temp, workflowFile) + require.NoError(t, err) + + // Run speakeasy run command to generate all targets + runCmd := exec.Command(speakeasyBinary, "run", "-t", "all", "--pinned", "--skip-compile") + runCmd.Dir = temp + runOutput, runErr := runCmd.CombinedOutput() + require.NoError(t, runErr, "speakeasy run should succeed: %s", string(runOutput)) + + // Verify both target directories were generated + goDirInfo, err := os.Stat(filepath.Join(temp, "go")) + require.NoError(t, err, "Go directory should exist") + require.True(t, goDirInfo.IsDir(), "Go should be a directory") + + tsDirInfo, err := os.Stat(filepath.Join(temp, "typescript")) + require.NoError(t, err, "TypeScript directory should exist") + require.True(t, tsDirInfo.IsDir(), "TypeScript should be a directory") + + // Initialize git repository in the ROOT directory (not per target) + initGitRepo(t, temp) + + // Commit all generated files with "clean generation" message + gitCommit(t, temp, "clean generation") + + // Verify the commit was created with the correct message + verifyGitCommit(t, temp, "clean generation") + + return temp +} + +// testMultiTargetIncrementalCustomCode tests adding custom code to all targets, +// then adding more custom code to only one target (go) and verifying all custom code is preserved +func testMultiTargetIncrementalCustomCode(t *testing.T, speakeasyBinary string) { + temp := setupMultiTargetSDKGeneration(t, speakeasyBinary, "customcodespec.yaml") + + // Paths to all target files + goFilePath := filepath.Join(temp, "go", "models", "operations", "getuserbyname.go") + tsFilePath := filepath.Join(temp, "typescript", "src", "models", "operations", "getuserbyname.ts") + + // Step 1: Add initial custom code to all targets + modifyLineInFileByPrefix(t, goFilePath, "// The name that needs to be", "\t// initial custom code in go target") + modifyLineInFileByPrefix(t, tsFilePath, "* The name that needs to be", "// initial custom code in typescript target") + + // Step 2: Register custom code for all targets + customCodeCmd := exec.Command(speakeasyBinary, "customcode", "--output", "console") + customCodeCmd.Dir = temp + customCodeOutput, customCodeErr := customCodeCmd.CombinedOutput() + require.NoError(t, customCodeErr, "first customcode command should succeed: %s", string(customCodeOutput)) + + // Step 3: Verify patch files were created for all targets + goPatchFile := filepath.Join(temp, "go", ".speakeasy", "patches", "custom-code.diff") + _, err := os.Stat(goPatchFile) + require.NoError(t, err, "Go patch file should exist") + + tsPatchFile := filepath.Join(temp, "typescript", ".speakeasy", "patches", "custom-code.diff") + _, err = os.Stat(tsPatchFile) + require.NoError(t, err, "TypeScript patch file should exist") + + // Step 4: Regenerate all targets + runRegeneration(t, speakeasyBinary, temp, true) + + // Step 5: Verify initial custom code is present in all targets + goContent, err := os.ReadFile(goFilePath) + require.NoError(t, err, "Failed to read go file") + require.Contains(t, string(goContent), "initial custom code in go target", "Go file should contain initial custom code") + + tsContent, err := os.ReadFile(tsFilePath) + require.NoError(t, err, "Failed to read typescript file") + require.Contains(t, string(tsContent), "initial custom code in typescript target", "TypeScript file should contain initial custom code") + + // Commit the regenerated files + gitCommit(t, temp, "regeneration with initial custom code") + + // Step 6: Add MORE custom code to go target only (on a different line) + modifyLineInFileByPrefix(t, goFilePath, "// successful operation", "// additional custom code in go target") + + // Step 7: Register the new custom code (should update go patch only) + customCodeCmd2 := exec.Command(speakeasyBinary, "customcode", "--output", "console") + customCodeCmd2.Dir = temp + customCodeOutput2, customCodeErr2 := customCodeCmd2.CombinedOutput() + require.NoError(t, customCodeErr2, "second customcode command should succeed: %s", string(customCodeOutput2)) + + // Step 8: Regenerate all targets again + runRegeneration(t, speakeasyBinary, temp, true) + + // Step 9: Verify go target has BOTH initial and additional custom code + goContent, err = os.ReadFile(goFilePath) + require.NoError(t, err, "Failed to read go file") + require.Contains(t, string(goContent), "initial custom code in go target", "Go file should still contain initial custom code") + require.Contains(t, string(goContent), "additional custom code in go target", "Go file should contain additional custom code") + + // Step 10: Verify typescript still has its original custom code (unchanged) + tsContent, err = os.ReadFile(tsFilePath) + require.NoError(t, err, "Failed to read typescript file") + require.Contains(t, string(tsContent), "initial custom code in typescript target", "TypeScript file should still contain its custom code") + require.NotContains(t, string(tsContent), "additional custom code", "TypeScript file should not contain additional go custom code") +} + +// testMultiTargetCustomCodeConflictResolutionAcceptOurs tests conflict resolution in one target +// while preserving custom code in other targets when accepting custom code changes (ours) +func testMultiTargetCustomCodeConflictResolutionAcceptOurs(t *testing.T, speakeasyBinary string) { + temp := setupMultiTargetSDKGeneration(t, speakeasyBinary, "customcodespec.yaml") + + // Paths to all target files + goFilePath := filepath.Join(temp, "go", "models", "operations", "getuserbyname.go") + tsFilePath := filepath.Join(temp, "typescript", "src", "models", "operations", "getuserbyname.ts") + + // Step 1: Add custom code to ALL targets + modifyLineInFileByPrefix(t, goFilePath, "// The name that needs to be", "\t// custom code in go target") + modifyLineInFileByPrefix(t, tsFilePath, "* @internal", "// custom code in typescript target") + + // Step 2: Register custom code for all targets + customCodeCmd := exec.Command(speakeasyBinary, "customcode", "--output", "console") + customCodeCmd.Dir = temp + customCodeOutput, customCodeErr := customCodeCmd.CombinedOutput() + require.NoError(t, customCodeErr, "customcode command should succeed: %s", string(customCodeOutput)) + + // Step 3: Verify patch files were created for both targets + goPatchFile := filepath.Join(temp, "go", ".speakeasy", "patches", "custom-code.diff") + _, err := os.Stat(goPatchFile) + require.NoError(t, err, "Go patch file should exist") + + tsPatchFile := filepath.Join(temp, "typescript", ".speakeasy", "patches", "custom-code.diff") + _, err = os.Stat(tsPatchFile) + require.NoError(t, err, "TypeScript patch file should exist") + + // Step 4: Modify the spec to cause conflict in GO target only (line 477 affects GetUserByName) + specPath := filepath.Join(temp, "customcodespec.yaml") + modifyLineInFileByPrefix(t, specPath, " description: 'The name that needs to be fetched", " description: 'spec change'") + + // Step 5: Run speakeasy run - should detect conflict in GO target only + regenCmd := exec.Command(speakeasyBinary, "run", "-t", "all", "--pinned", "--skip-compile") + regenCmd.Dir = temp + regenOutput, regenErr := regenCmd.CombinedOutput() + require.Error(t, regenErr, "speakeasy run should exit with error after detecting conflicts: %s", string(regenOutput)) + require.Contains(t, string(regenOutput), "CUSTOM CODE CONFLICTS DETECTED", "Output should show conflict detection banner") + require.Contains(t, string(regenOutput), "Entering automatic conflict resolution mode", "Output should indicate automatic resolution mode") + + // Step 6: Verify conflict markers present in GO file only + goContentAfterConflict, err := os.ReadFile(goFilePath) + require.NoError(t, err, "Failed to read go file after conflict") + require.Contains(t, string(goContentAfterConflict), "<<<<<<<", "Go file should contain conflict markers") + + // TypeScript file should NOT have conflict markers + tsContentAfterConflict, err := os.ReadFile(tsFilePath) + require.NoError(t, err, "Failed to read typescript file after conflict") + require.NotContains(t, string(tsContentAfterConflict), "<<<<<<<", "TypeScript file should not contain conflict markers") + require.Contains(t, string(tsContentAfterConflict), "custom code in typescript target", "TypeScript file should still have its custom code") + + // Step 7: Resolve the go conflict by accepting spec changes (ours) + checkoutCmd := exec.Command("git", "checkout", "--ours", goFilePath) + checkoutCmd.Dir = temp + checkoutOutput, checkoutErr := checkoutCmd.CombinedOutput() + require.NoError(t, checkoutErr, "git checkout --ours should succeed: %s", string(checkoutOutput)) + + // Step 8: Verify conflict markers are gone in go file + goContentAfterCheckout, err := os.ReadFile(goFilePath) + require.NoError(t, err, "Failed to read go file after checkout") + require.NotContains(t, string(goContentAfterCheckout), "<<<<<<<", "Go file should not contain conflict markers after checkout") + + // Step 9: Stage the resolved go file + gitAddCmd := exec.Command("git", "add", goFilePath) + gitAddCmd.Dir = temp + gitAddOutput, gitAddErr := gitAddCmd.CombinedOutput() + require.NoError(t, gitAddErr, "git add should succeed: %s", string(gitAddOutput)) + + // Step 10: Run customcode command to register the resolution + customCodeCmd2 := exec.Command(speakeasyBinary, "customcode", "--output", "console") + customCodeCmd2.Dir = temp + customCodeOutput2, customCodeErr2 := customCodeCmd2.CombinedOutput() + require.NoError(t, customCodeErr2, "customcode command should succeed after conflict resolution: %s", string(customCodeOutput2)) + + // Step 11: Verify patch files status + // Go patch file should be empty or removed + goPatchContent, err := os.ReadFile(goPatchFile) + if err == nil { + require.Empty(t, goPatchContent, "Go patch file should be empty after accepting ours") + } + + // TypeScript patch file should still exist with its content + tsPatchContent, err := os.ReadFile(tsPatchFile) + require.NoError(t, err, "TypeScript patch file should still exist") + require.NotEmpty(t, tsPatchContent, "TypeScript patch file should not be empty") + require.Contains(t, string(tsPatchContent), "custom code in typescript target", "TypeScript patch should contain typescript custom code") + + // Step 12: Verify gen.lock files + // Go's gen.lock should NOT contain customCodeCommitHash + goGenLockPath := filepath.Join(temp, "go", ".speakeasy", "gen.lock") + goGenLockContent, err := os.ReadFile(goGenLockPath) + require.NoError(t, err, "Failed to read go gen.lock") + require.NotContains(t, string(goGenLockContent), "customCodeCommitHash", "Go gen.lock should not contain customCodeCommitHash after accepting ours") + + // TypeScript's gen.lock should still contain customCodeCommitHash + tsGenLockPath := filepath.Join(temp, "typescript", ".speakeasy", "gen.lock") + tsGenLockContent, err := os.ReadFile(tsGenLockPath) + require.NoError(t, err, "Failed to read typescript gen.lock") + require.Contains(t, string(tsGenLockContent), "customCodeCommitHash", "TypeScript gen.lock should still contain customCodeCommitHash") + + // // Step 13: Run regeneration again + runRegeneration(t, speakeasyBinary, temp, true) + + // Step 14: Verify final state + // Go file should contain spec change, should NOT contain custom code + goContentFinal, err := os.ReadFile(goFilePath) + require.NoError(t, err, "Failed to read go file after final regeneration") + require.Contains(t, string(goContentFinal), "spec change", "Go file should contain spec change") + require.NotContains(t, string(goContentFinal), "custom code in go target", "Go file should not contain custom code after accepting ours") + + // TypeScript file should still contain its custom code + tsContentFinal, err := os.ReadFile(tsFilePath) + require.NoError(t, err, "Failed to read typescript file after final regeneration") + require.Contains(t, string(tsContentFinal), "custom code in typescript target", "TypeScript file should still contain its custom code") +} + + +// testMultiTargetCustomCodeConflictMultipleResultionsAcceptOurs tests conflict resolution in two targets +// sequentially. +func testMultiTargetCustomCodeConflictMultipleResultionsAcceptOurs(t *testing.T, speakeasyBinary string) { + temp := setupMultiTargetSDKGeneration(t, speakeasyBinary, "customcodespec.yaml") + + // Paths to all target files + goFilePath := filepath.Join(temp, "go", "models", "operations", "getuserbyname.go") + tsFilePath := filepath.Join(temp, "typescript", "src", "models", "operations", "getuserbyname.ts") + + // Step 1: Add custom code to ALL targets + modifyLineInFileByPrefix(t, goFilePath, "// The name that needs to be", "\t// custom code in go target") + modifyLineInFileByPrefix(t, tsFilePath, "* The name that needs to be", "// custom code in typescript target") + + // Step 2: Register custom code for all targets + customCodeCmd := exec.Command(speakeasyBinary, "customcode", "--output", "console") + customCodeCmd.Dir = temp + customCodeOutput, customCodeErr := customCodeCmd.CombinedOutput() + require.NoError(t, customCodeErr, "customcode command should succeed: %s", string(customCodeOutput)) + + // Step 3: Verify patch files were created for both targets + goPatchFile := filepath.Join(temp, "go", ".speakeasy", "patches", "custom-code.diff") + _, err := os.Stat(goPatchFile) + require.NoError(t, err, "Go patch file should exist") + + tsPatchFile := filepath.Join(temp, "typescript", ".speakeasy", "patches", "custom-code.diff") + _, err = os.Stat(tsPatchFile) + require.NoError(t, err, "TypeScript patch file should exist") + + // Step 4: Modify the spec to cause conflict in GO target only (line 477 affects GetUserByName) + specPath := filepath.Join(temp, "customcodespec.yaml") + modifyLineInFileByPrefix(t, specPath, " description: 'The name that needs to be fetched", " description: 'spec change'") + +// ------First Round + // Step 5: Run speakeasy run - should detect conflict in GO target only + regenCmd := exec.Command(speakeasyBinary, "run", "-t", "all", "--pinned", "--skip-compile") + regenCmd.Dir = temp + regenOutput, regenErr := regenCmd.CombinedOutput() + require.Error(t, regenErr, "speakeasy run should exit with error after detecting conflicts: %s", string(regenOutput)) + require.Contains(t, string(regenOutput), "CUSTOM CODE CONFLICTS DETECTED", "Output should show conflict detection banner") + require.Contains(t, string(regenOutput), "Entering automatic conflict resolution mode", "Output should indicate automatic resolution mode") + + // Step 6: Verify conflict markers present in GO file only + goContentAfterConflict, err := os.ReadFile(goFilePath) + require.NoError(t, err, "Failed to read go file after conflict") + require.Contains(t, string(goContentAfterConflict), "<<<<<<<", "Go file should contain conflict markers") + + // TypeScript file should NOT have conflict markers + tsContentAfterConflict, err := os.ReadFile(tsFilePath) + require.NoError(t, err, "Failed to read typescript file after conflict") + require.NotContains(t, string(tsContentAfterConflict), "<<<<<<<", "TypeScript file should not contain conflict markers") + require.Contains(t, string(tsContentAfterConflict), "custom code in typescript target", "TypeScript file should still have its custom code") + + // Step 7: Resolve the go conflict by accepting spec changes (ours) + checkoutCmd := exec.Command("git", "checkout", "--ours", goFilePath) + checkoutCmd.Dir = temp + checkoutOutput, checkoutErr := checkoutCmd.CombinedOutput() + require.NoError(t, checkoutErr, "git checkout --ours should succeed: %s", string(checkoutOutput)) + + // Step 8: Verify conflict markers are gone in go file + goContentAfterCheckout, err := os.ReadFile(goFilePath) + require.NoError(t, err, "Failed to read go file after checkout") + require.NotContains(t, string(goContentAfterCheckout), "<<<<<<<", "Go file should not contain conflict markers after checkout") + + // Step 9: Stage the resolved go file + gitAddCmd := exec.Command("git", "add", goFilePath) + gitAddCmd.Dir = temp + gitAddOutput, gitAddErr := gitAddCmd.CombinedOutput() + require.NoError(t, gitAddErr, "git add should succeed: %s", string(gitAddOutput)) + + // Step 10: Run customcode command to register the resolution + customCodeCmd2 := exec.Command(speakeasyBinary, "customcode", "--output", "console") + customCodeCmd2.Dir = temp + customCodeOutput2, customCodeErr2 := customCodeCmd2.CombinedOutput() + require.NoError(t, customCodeErr2, "customcode command should succeed after conflict resolution: %s", string(customCodeOutput2)) + + // Step 11: Verify patch files status + // Go patch file should be empty or removed + goPatchContent, err := os.ReadFile(goPatchFile) + if err == nil { + require.Empty(t, goPatchContent, "Go patch file should be empty after accepting ours") + } + + // TypeScript patch file should still exist with its content + tsPatchContent, err := os.ReadFile(tsPatchFile) + require.NoError(t, err, "TypeScript patch file should still exist") + require.NotEmpty(t, tsPatchContent, "TypeScript patch file should not be empty") + require.Contains(t, string(tsPatchContent), "custom code in typescript target", "TypeScript patch should contain typescript custom code") + + // Step 12: Verify gen.lock files + // Go's gen.lock should NOT contain customCodeCommitHash + goGenLockPath := filepath.Join(temp, "go", ".speakeasy", "gen.lock") + goGenLockContent, err := os.ReadFile(goGenLockPath) + require.NoError(t, err, "Failed to read go gen.lock") + require.NotContains(t, string(goGenLockContent), "customCodeCommitHash", "Go gen.lock should not contain customCodeCommitHash after accepting ours") + + // TypeScript's gen.lock should still contain customCodeCommitHash + tsGenLockPath := filepath.Join(temp, "typescript", ".speakeasy", "gen.lock") + tsGenLockContent, err := os.ReadFile(tsGenLockPath) + require.NoError(t, err, "Failed to read typescript gen.lock") + require.Contains(t, string(tsGenLockContent), "customCodeCommitHash", "TypeScript gen.lock should still contain customCodeCommitHash") + +// ------Second Round + + // Step 13: Run regeneration again - should detect conflict in TS target. + regenCmd2 := exec.Command(speakeasyBinary, "run", "-t", "all", "--pinned", "--skip-compile") + regenCmd2.Dir = temp + regenOutput2, regenErr2 := regenCmd2.CombinedOutput() + require.Error(t, regenErr2, "speakeasy run should exit with error after detecting conflicts: %s", string(regenOutput2)) + require.Contains(t, string(regenOutput2), "CUSTOM CODE CONFLICTS DETECTED", "Output should show conflict detection banner") + require.Contains(t, string(regenOutput2), "Entering automatic conflict resolution mode", "Output should indicate automatic resolution mode") + + + // Step 14: Verify conflict markers present in TS file only + tsContentAfterConflict2, err := os.ReadFile(tsFilePath) + require.NoError(t, err, "Failed to read ts file after conflict") + require.Contains(t, string(tsContentAfterConflict2), "<<<<<<<", "TS file should contain conflict markers") + + // Go file should NOT have conflict markers + goContentAfterConflict2, err := os.ReadFile(goFilePath) + require.NoError(t, err, "Failed to read typescript file after conflict") + require.NotContains(t, string(goContentAfterConflict2), "<<<<<<<", "GO file should not contain conflict markers") + + // Step 15: Resolve the ts conflict by accepting spec changes (ours) + checkoutCmd2 := exec.Command("git", "checkout", "--ours", tsFilePath) + checkoutCmd2.Dir = temp + checkoutOutput2, checkoutErr2 := checkoutCmd2.CombinedOutput() + require.NoError(t, checkoutErr2, "git checkout --ours should succeed: %s", string(checkoutOutput2)) + + // Step 16: Verify conflict markers are gone in ts file + tsContentAfterCheckout2, err := os.ReadFile(tsFilePath) + require.NoError(t, err, "Failed to read ts file after checkout") + require.NotContains(t, string(tsContentAfterCheckout2), "<<<<<<<", "TS file should not contain conflict markers after checkout") + + // Step 17: Stage the resolved ts file + gitAddCmd2 := exec.Command("git", "add", tsFilePath) + gitAddCmd2.Dir = temp + gitAddOutput2, gitAddErr2 := gitAddCmd2.CombinedOutput() + require.NoError(t, gitAddErr2, "git add should succeed: %s", string(gitAddOutput2)) + + // Step 18: Run customcode command to register the resolution + customCodeCmd3 := exec.Command(speakeasyBinary, "customcode", "--output", "console") + customCodeCmd3.Dir = temp + customCodeOutput3, customCodeErr3 := customCodeCmd3.CombinedOutput() + require.NoError(t, customCodeErr3, "customcode command should succeed after conflict resolution: %s", string(customCodeOutput3)) + + // Step 19: Verify patch files status + // TS patch file should be empty or removed + tsPatchContent2, err := os.ReadFile(tsPatchFile) + if err == nil { + require.Empty(t, tsPatchContent2, "TS patch file should be empty after accepting ours") + } + + // Step 20: Verify gen.lock files + // Go's gen.lock should NOT contain customCodeCommitHash + goGenLockPath2 := filepath.Join(temp, "go", ".speakeasy", "gen.lock") + goGenLockContent2, err := os.ReadFile(goGenLockPath2) + require.NoError(t, err, "Failed to read go gen.lock") + require.NotContains(t, string(goGenLockContent2), "customCodeCommitHash", "Go gen.lock should not contain customCodeCommitHash after accepting ours") + + // TypeScript's gen.lock should NOT contain customCodeCommitHash + tsGenLockPath2 := filepath.Join(temp, "typescript", ".speakeasy", "gen.lock") + tsGenLockContent2, err := os.ReadFile(tsGenLockPath2) + require.NoError(t, err, "Failed to read typescript gen.lock") + require.NotContains(t, string(tsGenLockContent2), "customCodeCommitHash", "TS gen.lock should not contain customCodeCommitHash after accepting ours") +// ----------------- + + // Step 21: Verify final state + // Go file should contain spec change, should NOT contain custom code + goContentFinal, err := os.ReadFile(goFilePath) + require.NoError(t, err, "Failed to read go file after final regeneration") + require.Contains(t, string(goContentFinal), "spec change", "Go file should contain spec change") + require.NotContains(t, string(goContentFinal), "custom code in go target", "Go file should not contain custom code after accepting ours") + + // TypeScript file should still contain its custom code + tsContentFinal, err := os.ReadFile(tsFilePath) + require.NoError(t, err, "Failed to read typescript file after final regeneration") + require.Contains(t, string(tsContentFinal), "spec change", "Go file should contain spec change") + require.NotContains(t, string(tsContentFinal), "custom code in typescript target", "TypeScript file should still contain its custom code") +} + +// testMultiTargetCustomCodeConflictResolutionAcceptTheirs tests conflict resolution in one target +// while preserving custom code in other targets when accepting custom code changes (theirs) +func testMultiTargetCustomCodeConflictResolutionAcceptTheirs(t *testing.T, speakeasyBinary string) { + temp := setupMultiTargetSDKGeneration(t, speakeasyBinary, "customcodespec.yaml") + + // Paths to all target files + goFilePath := filepath.Join(temp, "go", "models", "operations", "getuserbyname.go") + tsFilePath := filepath.Join(temp, "typescript", "src", "models", "operations", "getuserbyname.ts") + + // Step 1: Add custom code to ALL targets + modifyLineInFileByPrefix(t, goFilePath, "// The name that needs to be", "\t// custom code in go target") + modifyLineInFileByPrefix(t, tsFilePath, "* @internal", "// custom code in typescript target") + + // Step 2: Register custom code for all targets + customCodeCmd := exec.Command(speakeasyBinary, "customcode", "--output", "console") + customCodeCmd.Dir = temp + customCodeOutput, customCodeErr := customCodeCmd.CombinedOutput() + require.NoError(t, customCodeErr, "customcode command should succeed: %s", string(customCodeOutput)) + + // Step 3: Verify patch files were created for both targets + goPatchFile := filepath.Join(temp, "go", ".speakeasy", "patches", "custom-code.diff") + _, err := os.Stat(goPatchFile) + require.NoError(t, err, "Go patch file should exist") + + tsPatchFile := filepath.Join(temp, "typescript", ".speakeasy", "patches", "custom-code.diff") + _, err = os.Stat(tsPatchFile) + require.NoError(t, err, "TypeScript patch file should exist") + + // Step 4: Modify the spec to cause conflict in GO target only (line 477 affects GetUserByName) + specPath := filepath.Join(temp, "customcodespec.yaml") + modifyLineInFileByPrefix(t, specPath, " description: 'The name that needs to be fetched", " description: 'spec change'") + + // Step 5: Run speakeasy run - should detect conflict in GO target only + regenCmd := exec.Command(speakeasyBinary, "run", "-t", "all", "--pinned", "--skip-compile") + regenCmd.Dir = temp + regenOutput, regenErr := regenCmd.CombinedOutput() + require.Error(t, regenErr, "speakeasy run should exit with error after detecting conflicts: %s", string(regenOutput)) + require.Contains(t, string(regenOutput), "CUSTOM CODE CONFLICTS DETECTED", "Output should show conflict detection banner") + require.Contains(t, string(regenOutput), "Entering automatic conflict resolution mode", "Output should indicate automatic resolution mode") + + // Step 6: Verify conflict markers present in GO file only + goContentAfterConflict, err := os.ReadFile(goFilePath) + require.NoError(t, err, "Failed to read go file after conflict") + require.Contains(t, string(goContentAfterConflict), "<<<<<<<", "Go file should contain conflict markers") + + // TypeScript file should NOT have conflict markers + tsContentAfterConflict, err := os.ReadFile(tsFilePath) + require.NoError(t, err, "Failed to read typescript file after conflict") + require.NotContains(t, string(tsContentAfterConflict), "<<<<<<<", "TypeScript file should not contain conflict markers") + require.Contains(t, string(tsContentAfterConflict), "custom code in typescript target", "TypeScript file should still have its custom code") + + // Step 7: Resolve the go conflict by accepting custom code changes (theirs) + checkoutCmd := exec.Command("git", "checkout", "--theirs", goFilePath) + checkoutCmd.Dir = temp + checkoutOutput, checkoutErr := checkoutCmd.CombinedOutput() + require.NoError(t, checkoutErr, "git checkout --theirs should succeed: %s", string(checkoutOutput)) + + // Step 8: Verify conflict markers are gone in go file + goContentAfterCheckout, err := os.ReadFile(goFilePath) + require.NoError(t, err, "Failed to read go file after checkout") + require.NotContains(t, string(goContentAfterCheckout), "<<<<<<<", "Go file should not contain conflict markers after checkout") + + // Step 9: Stage the resolved go file + gitAddCmd := exec.Command("git", "add", goFilePath) + gitAddCmd.Dir = temp + gitAddOutput, gitAddErr := gitAddCmd.CombinedOutput() + require.NoError(t, gitAddErr, "git add should succeed: %s", string(gitAddOutput)) + + // Step 10: Run customcode command to register the resolution + customCodeCmd2 := exec.Command(speakeasyBinary, "customcode", "--output", "console") + customCodeCmd2.Dir = temp + customCodeOutput2, customCodeErr2 := customCodeCmd2.CombinedOutput() + require.NoError(t, customCodeErr2, "customcode command should succeed after conflict resolution: %s", string(customCodeOutput2)) + + // Step 11: Verify patch files status + // Go patch file should still exist with content since we accepted theirs (custom code) + goPatchContent, err := os.ReadFile(goPatchFile) + require.NoError(t, err, "Go patch file should still exist") + require.NotEmpty(t, goPatchContent, "Go patch file should not be empty after accepting theirs") + require.Contains(t, string(goPatchContent), "custom code in go target", "Go patch should contain go custom code") + + // TypeScript patch file should still exist with its content + tsPatchContent, err := os.ReadFile(tsPatchFile) + require.NoError(t, err, "TypeScript patch file should still exist") + require.NotEmpty(t, tsPatchContent, "TypeScript patch file should not be empty") + require.Contains(t, string(tsPatchContent), "custom code in typescript target", "TypeScript patch should contain typescript custom code") + + // Step 12: Verify gen.lock files + // Go's gen.lock should still contain customCodeCommitHash since we kept custom code + goGenLockPath := filepath.Join(temp, "go", ".speakeasy", "gen.lock") + goGenLockContent, err := os.ReadFile(goGenLockPath) + require.NoError(t, err, "Failed to read go gen.lock") + require.Contains(t, string(goGenLockContent), "customCodeCommitHash", "Go gen.lock should contain customCodeCommitHash after accepting theirs") + + // TypeScript's gen.lock should still contain customCodeCommitHash + tsGenLockPath := filepath.Join(temp, "typescript", ".speakeasy", "gen.lock") + tsGenLockContent, err := os.ReadFile(tsGenLockPath) + require.NoError(t, err, "Failed to read typescript gen.lock") + require.Contains(t, string(tsGenLockContent), "customCodeCommitHash", "TypeScript gen.lock should still contain customCodeCommitHash") + + // Step 13: Run regeneration again + runRegeneration(t, speakeasyBinary, temp, true) + + // Step 14: Verify final state + // Go file should contain custom code (since we accepted theirs), and should NOT contain spec change + goContentFinal, err := os.ReadFile(goFilePath) + require.NoError(t, err, "Failed to read go file after final regeneration") + require.NotContains(t, string(goContentFinal), "spec change", "Go file should not contain spec change after accepting theirs") + require.Contains(t, string(goContentFinal), "custom code in go target", "Go file should contain custom code after accepting theirs") + + // TypeScript file should still contain its custom code + tsContentFinal, err := os.ReadFile(tsFilePath) + require.NoError(t, err, "Failed to read typescript file after final regeneration") + require.Contains(t, string(tsContentFinal), "custom code in typescript target", "TypeScript file should still contain its custom code") +} + +// testMultiTargetCustomCodeConflictMultipleResultionsAcceptTheirsFromOurs tests conflict resolution in two targets +// sequentially, accepting custom code changes (theirs) instead of spec changes. +func testMultiTargetCustomCodeConflictMultipleResultionsAcceptTheirs(t *testing.T, speakeasyBinary string) { + temp := setupMultiTargetSDKGeneration(t, speakeasyBinary, "customcodespec.yaml") + + // Paths to all target files + goFilePath := filepath.Join(temp, "go", "models", "operations", "getuserbyname.go") + tsFilePath := filepath.Join(temp, "typescript", "src", "models", "operations", "getuserbyname.ts") + + // Step 1: Add custom code to ALL targets + modifyLineInFileByPrefix(t, goFilePath, "// The name that needs to be", "\t// custom code in go target") + modifyLineInFileByPrefix(t, tsFilePath, "* The name that needs to be", "// custom code in typescript target") + + // Step 2: Register custom code for all targets + customCodeCmd := exec.Command(speakeasyBinary, "customcode", "--output", "console") + customCodeCmd.Dir = temp + customCodeOutput, customCodeErr := customCodeCmd.CombinedOutput() + require.NoError(t, customCodeErr, "customcode command should succeed: %s", string(customCodeOutput)) + + // Step 3: Verify patch files were created for both targets + goPatchFile := filepath.Join(temp, "go", ".speakeasy", "patches", "custom-code.diff") + _, err := os.Stat(goPatchFile) + require.NoError(t, err, "Go patch file should exist") + + tsPatchFile := filepath.Join(temp, "typescript", ".speakeasy", "patches", "custom-code.diff") + _, err = os.Stat(tsPatchFile) + require.NoError(t, err, "TypeScript patch file should exist") + + // Step 4: Modify the spec to cause conflict in GO target only (line 477 affects GetUserByName) + specPath := filepath.Join(temp, "customcodespec.yaml") + modifyLineInFileByPrefix(t, specPath, " description: 'The name that needs to be fetched", " description: 'spec change'") + +// ------First Round + // Step 5: Run speakeasy run - should detect conflict in GO target only + regenCmd := exec.Command(speakeasyBinary, "run", "-t", "all", "--pinned", "--skip-compile") + regenCmd.Dir = temp + regenOutput, regenErr := regenCmd.CombinedOutput() + require.Error(t, regenErr, "speakeasy run should exit with error after detecting conflicts: %s", string(regenOutput)) + require.Contains(t, string(regenOutput), "CUSTOM CODE CONFLICTS DETECTED", "Output should show conflict detection banner") + require.Contains(t, string(regenOutput), "Entering automatic conflict resolution mode", "Output should indicate automatic resolution mode") + + // Step 6: Verify conflict markers present in GO file only + goContentAfterConflict, err := os.ReadFile(goFilePath) + require.NoError(t, err, "Failed to read go file after conflict") + require.Contains(t, string(goContentAfterConflict), "<<<<<<<", "Go file should contain conflict markers") + + // TypeScript file should NOT have conflict markers + tsContentAfterConflict, err := os.ReadFile(tsFilePath) + require.NoError(t, err, "Failed to read typescript file after conflict") + require.NotContains(t, string(tsContentAfterConflict), "<<<<<<<", "TypeScript file should not contain conflict markers") + require.Contains(t, string(tsContentAfterConflict), "custom code in typescript target", "TypeScript file should still have its custom code") + + // Step 7: Resolve the go conflict by accepting custom code changes (theirs) + checkoutCmd := exec.Command("git", "checkout", "--theirs", goFilePath) + checkoutCmd.Dir = temp + checkoutOutput, checkoutErr := checkoutCmd.CombinedOutput() + require.NoError(t, checkoutErr, "git checkout --theirs should succeed: %s", string(checkoutOutput)) + + // Step 8: Verify conflict markers are gone in go file + goContentAfterCheckout, err := os.ReadFile(goFilePath) + require.NoError(t, err, "Failed to read go file after checkout") + require.NotContains(t, string(goContentAfterCheckout), "<<<<<<<", "Go file should not contain conflict markers after checkout") + + // Step 9: Stage the resolved go file + gitAddCmd := exec.Command("git", "add", goFilePath) + gitAddCmd.Dir = temp + gitAddOutput, gitAddErr := gitAddCmd.CombinedOutput() + require.NoError(t, gitAddErr, "git add should succeed: %s", string(gitAddOutput)) + + // Step 10: Run customcode command to register the resolution + customCodeCmd2 := exec.Command(speakeasyBinary, "customcode", "--output", "console") + customCodeCmd2.Dir = temp + customCodeOutput2, customCodeErr2 := customCodeCmd2.CombinedOutput() + require.NoError(t, customCodeErr2, "customcode command should succeed after conflict resolution: %s", string(customCodeOutput2)) + + // Step 11: Verify patch files status + // Go patch file should still exist with content since we accepted theirs (custom code) + goPatchContent, err := os.ReadFile(goPatchFile) + require.NoError(t, err, "Go patch file should still exist") + require.NotEmpty(t, goPatchContent, "Go patch file should not be empty after accepting theirs") + require.Contains(t, string(goPatchContent), "custom code in go target", "Go patch should contain go custom code") + + // TypeScript patch file should still exist with its content + tsPatchContent, err := os.ReadFile(tsPatchFile) + require.NoError(t, err, "TypeScript patch file should still exist") + require.NotEmpty(t, tsPatchContent, "TypeScript patch file should not be empty") + require.Contains(t, string(tsPatchContent), "custom code in typescript target", "TypeScript patch should contain typescript custom code") + + // Step 12: Verify gen.lock files + // Go's gen.lock should still contain customCodeCommitHash since we kept custom code + goGenLockPath := filepath.Join(temp, "go", ".speakeasy", "gen.lock") + goGenLockContent, err := os.ReadFile(goGenLockPath) + require.NoError(t, err, "Failed to read go gen.lock") + require.Contains(t, string(goGenLockContent), "customCodeCommitHash", "Go gen.lock should contain customCodeCommitHash after accepting theirs") + + // TypeScript's gen.lock should still contain customCodeCommitHash + tsGenLockPath := filepath.Join(temp, "typescript", ".speakeasy", "gen.lock") + tsGenLockContent, err := os.ReadFile(tsGenLockPath) + require.NoError(t, err, "Failed to read typescript gen.lock") + require.Contains(t, string(tsGenLockContent), "customCodeCommitHash", "TypeScript gen.lock should still contain customCodeCommitHash") + +// ------Second Round + + // Step 13: Run regeneration again - should detect conflict in TS target. + regenCmd2 := exec.Command(speakeasyBinary, "run", "-t", "all", "--pinned", "--skip-compile") + regenCmd2.Dir = temp + regenOutput2, regenErr2 := regenCmd2.CombinedOutput() + require.Error(t, regenErr2, "speakeasy run should exit with error after detecting conflicts: %s", string(regenOutput2)) + require.Contains(t, string(regenOutput2), "CUSTOM CODE CONFLICTS DETECTED", "Output should show conflict detection banner") + require.Contains(t, string(regenOutput2), "Entering automatic conflict resolution mode", "Output should indicate automatic resolution mode") + + // Step 14: Verify conflict markers present in TS file only + tsContentAfterConflict2, err := os.ReadFile(tsFilePath) + require.NoError(t, err, "Failed to read ts file after conflict") + require.Contains(t, string(tsContentAfterConflict2), "<<<<<<<", "TS file should contain conflict markers") + + // Go file should NOT have conflict markers + goContentAfterConflict2, err := os.ReadFile(goFilePath) + require.NoError(t, err, "Failed to read go file after conflict") + require.NotContains(t, string(goContentAfterConflict2), "<<<<<<<", "GO file should not contain conflict markers") + + // Step 15: Resolve the ts conflict by accepting custom code changes (theirs) + checkoutCmd2 := exec.Command("git", "checkout", "--theirs", tsFilePath) + checkoutCmd2.Dir = temp + checkoutOutput2, checkoutErr2 := checkoutCmd2.CombinedOutput() + require.NoError(t, checkoutErr2, "git checkout --theirs should succeed: %s", string(checkoutOutput2)) + + // Step 16: Verify conflict markers are gone in ts file + tsContentAfterCheckout2, err := os.ReadFile(tsFilePath) + require.NoError(t, err, "Failed to read ts file after checkout") + require.NotContains(t, string(tsContentAfterCheckout2), "<<<<<<<", "TS file should not contain conflict markers after checkout") + + // Step 17: Stage the resolved ts file + gitAddCmd2 := exec.Command("git", "add", tsFilePath) + gitAddCmd2.Dir = temp + gitAddOutput2, gitAddErr2 := gitAddCmd2.CombinedOutput() + require.NoError(t, gitAddErr2, "git add should succeed: %s", string(gitAddOutput2)) + + // Step 18: Run customcode command to register the resolution + customCodeCmd3 := exec.Command(speakeasyBinary, "customcode", "--output", "console") + customCodeCmd3.Dir = temp + customCodeOutput3, customCodeErr3 := customCodeCmd3.CombinedOutput() + require.NoError(t, customCodeErr3, "customcode command should succeed after conflict resolution: %s", string(customCodeOutput3)) + + // Step 19: Verify patch files status + // TS patch file should still exist with content since we accepted theirs (custom code) + tsPatchContent2, err := os.ReadFile(tsPatchFile) + require.NoError(t, err, "TS patch file should still exist") + require.NotEmpty(t, tsPatchContent2, "TS patch file should not be empty after accepting theirs") + require.Contains(t, string(tsPatchContent2), "custom code in typescript target", "TS patch should contain typescript custom code") + + // Step 20: Verify gen.lock files + // Go's gen.lock should still contain customCodeCommitHash since we kept custom code + goGenLockPath2 := filepath.Join(temp, "go", ".speakeasy", "gen.lock") + goGenLockContent2, err := os.ReadFile(goGenLockPath2) + require.NoError(t, err, "Failed to read go gen.lock") + require.Contains(t, string(goGenLockContent2), "customCodeCommitHash", "Go gen.lock should contain customCodeCommitHash after accepting theirs") + + // TypeScript's gen.lock should still contain customCodeCommitHash since we kept custom code + tsGenLockPath2 := filepath.Join(temp, "typescript", ".speakeasy", "gen.lock") + tsGenLockContent2, err := os.ReadFile(tsGenLockPath2) + require.NoError(t, err, "Failed to read typescript gen.lock") + require.Contains(t, string(tsGenLockContent2), "customCodeCommitHash", "TS gen.lock should contain customCodeCommitHash after accepting theirs") +// ----------------- + + // Step 21: Run final regeneration to ensure everything works + runRegeneration(t, speakeasyBinary, temp, true) + + // Step 22: Verify final state + // Go file should contain custom code (since we accepted theirs), and should NOT contain spec change + goContentFinal, err := os.ReadFile(goFilePath) + require.NoError(t, err, "Failed to read go file after final regeneration") + require.NotContains(t, string(goContentFinal), "spec change", "Go file should not contain spec change after accepting theirs") + require.Contains(t, string(goContentFinal), "custom code in go target", "Go file should contain custom code after accepting theirs") + + // TypeScript file should contain custom code (since we accepted theirs), and should NOT contain spec change + tsContentFinal, err := os.ReadFile(tsFilePath) + require.NoError(t, err, "Failed to read typescript file after final regeneration") + require.NotContains(t, string(tsContentFinal), "spec change", "TypeScript file should not contain spec change after accepting theirs") + require.Contains(t, string(tsContentFinal), "custom code in typescript target", "TypeScript file should contain custom code after accepting theirs") +} \ No newline at end of file diff --git a/integration/customcode_singletarget_test.go b/integration/customcode_singletarget_test.go new file mode 100644 index 000000000..ce5f48306 --- /dev/null +++ b/integration/customcode_singletarget_test.go @@ -0,0 +1,710 @@ +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, "speakeasy-customcode-test-binary") + + 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) + }) + + t.Run("SequentialPatchesAppliedWithRegenerationBetween", func(t *testing.T) { + t.Parallel() + testCustomCodeSequentialPatchesAppliedWithRegenerationBetween(t, speakeasyBinary) + }) + + t.Run("SequentialPatchesAppliedWithoutRegenerationBetween", func(t *testing.T) { + t.Parallel() + testCustomCodeSequentialPatchesAppliedWithoutRegenerationBetween(t, speakeasyBinary) + }) + + t.Run("NewFilePreservation", func(t *testing.T) { + t.Parallel() + testCustomCodeNewFilePreservation(t, speakeasyBinary) + }) + + t.Run("NewFileDeletion", func(t *testing.T) { + t.Parallel() + testCustomCodeNewFileDeletion(t, speakeasyBinary) + }) +} + +// testCustomCodeBasicWorkflow tests basic custom code registration and reapplication +func testCustomCodeBasicWorkflow(t *testing.T, speakeasyBinary string) { + temp := setupSDKGeneration(t, speakeasyBinary, "customcodespec.yaml") + + httpMetadataPath := filepath.Join(temp, "models", "components", "httpmetadata.go") + registerCustomCodeByPrefix(t, speakeasyBinary, temp, httpMetadataPath, "// Raw HTTP response", "\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 + registerCustomCodeByPrefix(t, speakeasyBinary, temp, getUserByNamePath, "// The name that needs to be fetched", "\t// custom code") + + // Modify the spec to change line 477 from original description to "spec change" + specPath := filepath.Join(temp, "customcodespec.yaml") + modifyLineInFile(t, specPath, 477, " description: 'spec change'") + + // Run speakeasy run to regenerate - this should detect conflict and automatically enter resolution mode + // The process should exit with code 2 after setting up conflict resolution + regenCmd := exec.Command(speakeasyBinary, "run", "-t", "all", "--pinned", "--skip-compile") + regenCmd.Dir = temp + regenOutput, regenErr := regenCmd.CombinedOutput() + require.Error(t, regenErr, "speakeasy run should exit with error after detecting conflicts: %s", string(regenOutput)) + require.Contains(t, string(regenOutput), "CUSTOM CODE CONFLICTS DETECTED", "Output should show conflict detection banner") + require.Contains(t, string(regenOutput), "Entering automatic conflict resolution mode", "Output should indicate automatic resolution mode") + + // Check for conflict markers in the file + getUserByNameContent, err := os.ReadFile(getUserByNamePath) + 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 + registerCustomCodeByPrefix(t, speakeasyBinary, temp, getUserByNamePath, "// The name that needs to be fetched", "\t// custom code") + + // Modify the spec to change line 477 from original description to "spec change" + specPath := filepath.Join(temp, "customcodespec.yaml") + modifyLineInFile(t, specPath, 477, " description: 'spec change'") + + // Run speakeasy run to regenerate - this should detect conflict and automatically enter resolution mode + // The process should exit with code 2 after setting up conflict resolution + regenCmd := exec.Command(speakeasyBinary, "run", "-t", "all", "--pinned", "--skip-compile") + regenCmd.Dir = temp + regenOutput, regenErr := regenCmd.CombinedOutput() + require.Error(t, regenErr, "speakeasy run should exit with error after detecting conflicts: %s", string(regenOutput)) + require.Contains(t, string(regenOutput), "CUSTOM CODE CONFLICTS DETECTED", "Output should show conflict detection banner") + require.Contains(t, string(regenOutput), "Entering automatic conflict resolution mode", "Output should indicate automatic resolution mode") + + // Check for conflict markers in the file + getUserByNameContent, err := os.ReadFile(getUserByNamePath) + 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)) + + // 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) + + // 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") +} + +// testCustomCodeSequentialPatchesAppliedWithRegenerationBetween tests that patches can be updated +// by registering a first patch, regenerating, then registering a second patch on the same line +func testCustomCodeSequentialPatchesAppliedWithRegenerationBetween(t *testing.T, speakeasyBinary string) { + temp := setupSDKGeneration(t, speakeasyBinary, "customcodespec.yaml") + + getUserByNamePath := filepath.Join(temp, "models", "operations", "getuserbyname.go") + + // Step 1: Register first patch + registerCustomCodeByPrefix(t, speakeasyBinary, temp, getUserByNamePath, "// The name that needs to be fetched", "\t// first custom code") + + // Step 2: Verify first patch applies correctly on regeneration + runRegeneration(t, speakeasyBinary, temp, true) + verifyCustomCodePresent(t, getUserByNamePath, "// first custom code") + + // Step 2b: Commit the regenerated code with first patch applied + gitCommit(t, temp, "regenerated with first patch") + + // Step 3: Modify the same line with different content (second patch) + modifyLineInFileByPrefix(t, getUserByNamePath, "\t// first custom code", "\t// second custom code - updated") + + // Step 4: Register second patch (should update existing patch) + customCodeCmd := exec.Command(speakeasyBinary, "customcode", "--output", "console") + customCodeCmd.Dir = temp + customCodeOutput, customCodeErr := customCodeCmd.CombinedOutput() + require.NoError(t, customCodeErr, "customcode command should succeed for second patch: %s", string(customCodeOutput)) + + // Step 5: Verify patch file was updated (not appended) + patchFile := filepath.Join(temp, ".speakeasy", "patches", "custom-code.diff") + patchContent, err := os.ReadFile(patchFile) + require.NoError(t, err, "Failed to read patch file") + require.Contains(t, string(patchContent), "second custom code - updated", "Patch should contain second custom code") + require.NotContains(t, string(patchContent), "first custom code", "Patch should not contain first custom code") + + // Step 6: Verify second patch applies correctly on final regeneration + runRegeneration(t, speakeasyBinary, temp, true) + + // Step 7: Verify final file contains only second patch content + finalContent, err := os.ReadFile(getUserByNamePath) + require.NoError(t, err, "Failed to read getuserbyname.go after final regeneration") + require.Contains(t, string(finalContent), "// second custom code - updated", "File should contain second custom code") + require.NotContains(t, string(finalContent), "// first custom code", "File should not contain first custom code") +} + +// testCustomCodeSequentialPatchesAppliedWithoutRegenerationBetween tests that patches can be updated +// by registering a first patch, then immediately registering a second patch on the same line without regenerating +func testCustomCodeSequentialPatchesAppliedWithoutRegenerationBetween(t *testing.T, speakeasyBinary string) { + temp := setupSDKGeneration(t, speakeasyBinary, "customcodespec.yaml") + + getUserByNamePath := filepath.Join(temp, "models", "operations", "getuserbyname.go") + + // Step 1: Register first patch + registerCustomCodeByPrefix(t, speakeasyBinary, temp, getUserByNamePath, "// The name that needs to be fetched", "\t// first custom code") + + // Step 2: Immediately modify the same line with different content (NO regeneration between) + modifyLineInFileByPrefix(t, getUserByNamePath, "\t// first custom code", "\t// second custom code - updated") + + // Step 3: Register second patch (should update existing patch) + customCodeCmd := exec.Command(speakeasyBinary, "customcode", "--output", "console") + customCodeCmd.Dir = temp + customCodeOutput, customCodeErr := customCodeCmd.CombinedOutput() + require.NoError(t, customCodeErr, "customcode command should succeed for second patch: %s", string(customCodeOutput)) + + // Step 4: Verify patch file was updated (not appended) + patchFile := filepath.Join(temp, ".speakeasy", "patches", "custom-code.diff") + patchContent, err := os.ReadFile(patchFile) + require.NoError(t, err, "Failed to read patch file") + require.Contains(t, string(patchContent), "second custom code - updated", "Patch should contain second custom code") + require.NotContains(t, string(patchContent), "first custom code", "Patch should not contain first custom code") + + // Step 5: Verify second patch applies correctly on regeneration + runRegeneration(t, speakeasyBinary, temp, true) + + // Step 6: Verify final file contains only second patch content + finalContent, err := os.ReadFile(getUserByNamePath) + require.NoError(t, err, "Failed to read getuserbyname.go after final regeneration") + require.Contains(t, string(finalContent), "// second custom code - updated", "File should contain second custom code") + require.NotContains(t, string(finalContent), "// first custom code", "File should not contain first custom code") +} + +// buildSpeakeasyBinaryOnce builds the speakeasy binary and returns the path to it +func buildSpeakeasyBinaryOnce(t *testing.T, binaryName string) string { + t.Helper() + + _, filename, _, _ := runtime.Caller(0) + baseFolder := filepath.Join(filepath.Dir(filename), "..") + binaryPath := filepath.Join(baseFolder, binaryName) + + // 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") +} + +func modifyLineInFileByPrefix(t *testing.T, filePath string, oldContentPrefix string, newContent string) { + t.Helper() + + lineNum := findLineNumberByPrefix(t, filePath, oldContentPrefix) + modifyLineInFile(t, filePath, lineNum, newContent) +} + +// setupCustomCodeTestDir creates a test directory outside the speakeasy repo +func setupCustomCodeTestDir(t *testing.T) string { + t.Helper() + + // 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 +} + +// findLineNumberByPrefix finds the line number (1-indexed) of the first line containing the prefix +func findLineNumberByPrefix(t *testing.T, filePath, prefix string) int { + t.Helper() + + file, err := os.Open(filePath) + require.NoError(t, err, "Failed to open file: %s", filePath) + defer file.Close() + + scanner := bufio.NewScanner(file) + lineNumber := 1 + for scanner.Scan() { + if strings.Contains(scanner.Text(), prefix) { + return lineNumber + } + lineNumber++ + } + require.NoError(t, scanner.Err(), "Failed to read file: %s", filePath) + require.Fail(t, "Could not find line with prefix: %s", prefix) + return -1 // Never reached +} + +// registerCustomCodeByPrefix finds a line by prefix and registers custom code at that line +func registerCustomCodeByPrefix(t *testing.T, speakeasyBinary, workingDir, filePath, linePrefix string, newContent string) { + t.Helper() + + lineNum := findLineNumberByPrefix(t, filePath, linePrefix) + registerCustomCode(t, speakeasyBinary, workingDir, filePath, lineNum, newContent) +} + +// registerCustomCode modifies a file and registers it as custom code +func registerCustomCode(t *testing.T, speakeasyBinary, workingDir, filePath string, lineNum int, newContent string) { + t.Helper() + + // 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") +} + +// testCustomCodeNewFilePreservation tests that custom code registration preserves entirely new files +func testCustomCodeNewFilePreservation(t *testing.T, speakeasyBinary string) { + temp := setupSDKGeneration(t, speakeasyBinary, "customcodespec.yaml") + + // Create a new file with helper functions + helperFilePath := filepath.Join(temp, "utils", "helper.go") + helperFileContent := `package utils + +import "fmt" + +// FormatUserID formats a user ID with a prefix +func FormatUserID(id int64) string { + return fmt.Sprintf("user_%d", id) +} + +// ValidateUserID validates that a user ID is positive +func ValidateUserID(id int64) bool { + return id > 0 +} +` + + // Create the utils directory + err := os.MkdirAll(filepath.Join(temp, "utils"), 0o755) + require.NoError(t, err, "Failed to create utils directory") + + // Write the helper file + err = os.WriteFile(helperFilePath, []byte(helperFileContent), 0o644) + require.NoError(t, err, "Failed to write helper file") + + // Stage the new file so git diff HEAD can capture it + gitAddCmd := exec.Command("git", "add", helperFilePath) + gitAddCmd.Dir = temp + gitAddOutput, gitAddErr := gitAddCmd.CombinedOutput() + require.NoError(t, gitAddErr, "git add should succeed: %s", string(gitAddOutput)) + + // Try to register custom code with new file - should fail + customCodeCmd := exec.Command(speakeasyBinary, "customcode", "--output", "console") + customCodeCmd.Dir = temp + customCodeOutput, customCodeErr := customCodeCmd.CombinedOutput() + require.Error(t, customCodeErr, "customcode command should fail with new file: %s", string(customCodeOutput)) + require.Contains(t, string(customCodeOutput), "Cannot register new files through customcode", "Error should mention new files not supported") + + // Commit the new file to make it part of the codebase + gitCommitCmd := exec.Command("git", "commit", "-m", "Add helper file") + gitCommitCmd.Dir = temp + _, err = gitCommitCmd.CombinedOutput() + require.NoError(t, err, "git commit should succeed") + + // Now make a modification to an existing file to test normal customcode flow + getUserByNamePath := filepath.Join(temp, "models", "operations", "getuserbyname.go") + registerCustomCodeByPrefix(t, speakeasyBinary, temp, getUserByNamePath, "// The name that needs to be fetched", "\t// custom code in existing file") + + // Verify patch file was created for the modification + patchFile := filepath.Join(temp, ".speakeasy", "patches", "custom-code.diff") + _, err = os.Stat(patchFile) + require.NoError(t, err, "patch file should exist for file modifications") + + // Verify the file exists after registration (before regeneration) + _, err = os.Stat(helperFilePath) + require.NoError(t, err, "Helper file should exist after registration") + + // Run speakeasy run to regenerate the SDK + // This should apply the patch for the existing file modification and preserve the committed helper file + runRegeneration(t, speakeasyBinary, temp, true) + + // Verify the helper file still exists after regeneration (it was committed, so should be preserved) + _, err = os.Stat(helperFilePath) + require.NoError(t, err, "Helper file should exist after regeneration") + + // Verify the helper file contents are preserved exactly + verifyCustomCodePresent(t, helperFilePath, "FormatUserID") + verifyCustomCodePresent(t, helperFilePath, "ValidateUserID") + verifyCustomCodePresent(t, helperFilePath, "package utils") + + // Read the entire helper file and verify exact content match + actualContent, err := os.ReadFile(helperFilePath) + require.NoError(t, err, "Failed to read helper file after regeneration") + require.Equal(t, helperFileContent, string(actualContent), "Helper file content should be preserved exactly") + + // Verify the custom code modification was applied to the existing file + verifyCustomCodePresent(t, getUserByNamePath, "// custom code in existing file") +} + +// testCustomCodeNewFileDeletion tests that deleting a custom file is properly registered and persisted +func testCustomCodeNewFileDeletion(t *testing.T, speakeasyBinary string) { + temp := setupSDKGeneration(t, speakeasyBinary, "customcodespec.yaml") + + // Create a new file with helper functions + helperFilePath := filepath.Join(temp, "utils", "helper.go") + helperFileContent := `package utils + +import "fmt" + +// FormatUserID formats a user ID with a prefix +func FormatUserID(id int64) string { + return fmt.Sprintf("user_%d", id) +} +` + + // Create the utils directory + err := os.MkdirAll(filepath.Join(temp, "utils"), 0o755) + require.NoError(t, err, "Failed to create utils directory") + + // Write the helper file + err = os.WriteFile(helperFilePath, []byte(helperFileContent), 0o644) + require.NoError(t, err, "Failed to write helper file") + + // Stage the new file so git diff HEAD can capture it + gitAddCmd := exec.Command("git", "add", helperFilePath) + gitAddCmd.Dir = temp + _, err = gitAddCmd.CombinedOutput() + require.NoError(t, err, "git add should succeed") + + // Commit the new file to make it part of the codebase + gitCommitCmd := exec.Command("git", "commit", "-m", "Add helper file") + gitCommitCmd.Dir = temp + _, err = gitCommitCmd.CombinedOutput() + require.NoError(t, err, "git commit should succeed") + + // Now delete the file + err = os.Remove(helperFilePath) + require.NoError(t, err, "Failed to delete helper file") + + // Commit the deletion + gitCommitDeleteCmd := exec.Command("git", "commit", "-am", "Delete helper file") + gitCommitDeleteCmd.Dir = temp + _, err = gitCommitDeleteCmd.CombinedOutput() + require.NoError(t, err, "git commit for deletion should succeed") + + // Now make a modification to an existing file to test normal customcode flow + getUserByNamePath := filepath.Join(temp, "models", "operations", "getuserbyname.go") + registerCustomCodeByPrefix(t, speakeasyBinary, temp, getUserByNamePath, "// The name that needs to be fetched", "\t// custom code for deletion test") + + // Verify patch file was created for the modification + patchFile := filepath.Join(temp, ".speakeasy", "patches", "custom-code.diff") + _, err = os.Stat(patchFile) + require.NoError(t, err, "patch file should exist for file modifications") + + // Run regeneration to test that the custom code is preserved and deleted file stays deleted + runRegeneration(t, speakeasyBinary, temp, true) + + // Verify the helper file does NOT exist after regeneration (it was deleted and committed) + _, err = os.Stat(helperFilePath) + require.True(t, os.IsNotExist(err), "Helper file should not exist after regeneration (was deleted)") + + // Verify the custom code modification was applied to the existing file + verifyCustomCodePresent(t, getUserByNamePath, "// custom code for deletion test") +} 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' + diff --git a/internal/registercustomcode/registercustomcode.go b/internal/registercustomcode/registercustomcode.go new file mode 100644 index 000000000..dbde9a829 --- /dev/null +++ b/internal/registercustomcode/registercustomcode.go @@ -0,0 +1,1148 @@ +package registercustomcode + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + + "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/speakeasy/internal/log" + "github.com/speakeasy-api/speakeasy/internal/utils" + "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, 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 { + 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: Check changeset doesn't include .speakeasy directory changes + if err := checkNoSpeakeasyChanges(ctx); err != nil { + return fmt.Errorf("Registering custom code in the .speakeasy directory is not supported: %w", err) + } + + // Step 2: Check if workflow.yaml references local openapi spec and validate no spec changes + if err := checkNoLocalSpecChanges(ctx, wf); err != nil { + return fmt.Errorf("Registering custom code in your openapi spec and related files is not supported: %w", err) + } + targetPatches, err := getPatchesPerTarget(wf) + if err != nil { + return err + } + + // Step 3: Reset working directory to HEAD after capturing patches + // This removes all user changes (staged and unstaged) so they don't get included in clean generation commit + logger.Info("Resetting working directory to HEAD before clean generation") + resetCmd := exec.Command("git", "reset", "--hard", "HEAD") + if output, err := resetCmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to reset working directory: %w\nOutput: %s", err, string(output)) + } + + for _, target := range wf.Targets { + if err := RevertCustomCodePatch(ctx, target); err != nil { + return fmt.Errorf("failed to revert custom code patch: %w", err) + } + } + + // Step 4: Commit clean generation to preserve metadata + if err := commitRevertCustomCode(); err != nil { + return fmt.Errorf("failed to commit clean generation: %w", err) + } + + for targetName, target := range wf.Targets { + if targetPatches[targetName] == "" { + continue + } + err = updateCustomPatchAndUpdateGenLock(ctx, wf, originalHash, targetPatches, target, targetName) + if err != nil { + return err + } + + } + + logger.Info("Successfully registered custom code changes. Code changes will be applied on top of your code after generation.") + return nil +} + +func updateCustomPatchAndUpdateGenLock(ctx context.Context, wf *workflow.Workflow, originalHash plumbing.Hash, targetPatches map[string]string, target workflow.Target, targetName string) error { + logger := log.From(ctx).With(zap.String("method", "updateCustomPatchAndUpdateGenLock")) + // Step 7: Apply existing custom code patch from gen.lock + 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 { + removeReverseCustomCode(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 { + // 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 + } + + // 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 { + removeReverseCustomCode(ctx, originalHash) + return fmt.Errorf("custom code changes failed compilation or linting. Please resolve any compilation/linting errors and run `customcode` again.") + } + // Step 10.6: Create commit with custom code changes after successful compilation + customCodeCommitHash, err := commitCustomCodeChanges() + if err != nil { + removeReverseCustomCode(ctx, originalHash) + return fmt.Errorf("failed to commit custom code changes: %w", err) + } + logger.Info("Created commit with custom code changes", zap.String("commit_hash", customCodeCommitHash)) + + // 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 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 patch file +func ShowCustomCodePatch(ctx context.Context, target workflow.Target) error { + logger := log.From(ctx).With(zap.String("method", "ShowCustomCodePatch")) + + outDir := getTargetOutput(target) + + // Read patch from file + patchStr, err := readPatchFile(outDir) + if err != nil { + return fmt.Errorf("failed to read patch file: %w", err) + } + if 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 + 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)) + + return nil +} + +// 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) + } + + // 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.Warn("No custom code commit hash found in gen.lock") + 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")) + + wf, _, err := utils.GetWorkflowAndDir() + if err != nil { + return err + } + + hadConflicts := false + + // First pass: identify targets with conflicts and unstage those without conflicts + // This is necessary because git add . stages everything, including targets that weren't regenerated + for targetName, target := range wf.Targets { + outDir := getTargetOutput(target) + + // Check if this target has conflicted files + checkConflictCmd := exec.Command("git", "diff", "--name-only", "--diff-filter=U", "--", outDir) + conflictCheckOutput, err := checkConflictCmd.Output() + if err != nil { + return fmt.Errorf("failed to check for conflicts in target %s: %w", targetName, err) + } + + hasConflicts := strings.TrimSpace(string(conflictCheckOutput)) != "" + + if !hasConflicts { + // Unstage and restore this target's files to prevent them from being included in conflict resolution + // This is necessary because the target was never regenerated in this run + logger.Info(fmt.Sprintf("Target %s has no conflicts, unstaging and restoring its files to HEAD", targetName)) + + // First unstage + unstageCmd := exec.Command("git", "reset", "--", outDir) + if output, err := unstageCmd.CombinedOutput(); err != nil { + logger.Warn(fmt.Sprintf("Failed to unstage target %s: %v\nOutput: %s", targetName, err, string(output))) + } + + // Then restore to HEAD state (removes modifications from working directory) + checkoutCmd := exec.Command("git", "checkout", "HEAD", "--", outDir) + if output, err := checkoutCmd.CombinedOutput(); err != nil { + logger.Warn(fmt.Sprintf("Failed to restore target %s to HEAD: %v\nOutput: %s", targetName, err, string(output))) + } + } + } + + // Second pass: process targets with conflicts + for targetName, target := range wf.Targets { + outDir := getTargetOutput(target) + + // Check if patch file exists + patchStr, err := readPatchFile(outDir) + if err != nil { + return fmt.Errorf("failed to read patch file for target %s: %w", targetName, err) + } + if patchStr == "" { + logger.Info(fmt.Sprintf("No custom code patch for target %s, skipping", targetName)) + continue + } + + // Check if this target actually has conflicted files from the current generation + // Only targets that were regenerated and have conflicts should be processed + checkConflictCmd := exec.Command("git", "diff", "--name-only", "--diff-filter=U", "--", outDir) + conflictCheckOutput, err := checkConflictCmd.Output() + if err != nil { + return fmt.Errorf("failed to check for conflicts in target %s: %w", targetName, err) + } + + if strings.TrimSpace(string(conflictCheckOutput)) == "" { + logger.Info(fmt.Sprintf("Target %s has no conflicts (not regenerated in this run), skipping conflict resolution", targetName)) + continue + } + + logger.Info(fmt.Sprintf("Resolving conflicts for target %s", targetName)) + + // Step 1: Undo patch application - extract clean new generation from "ours" side + 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 +} + +// 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) + } + + lines := strings.Split(strings.TrimSpace(string(output)), "\n") + var unresolvedConflicts []string + var unstagedFiles []string + + for _, line := range lines { + if len(line) < 3 { + 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 unstaged modifications + if len(statusCode) >= 2 && statusCode[1] == 'M' { + unstagedFiles = append(unstagedFiles, filename) + } + } + + if len(unresolvedConflicts) > 0 { + return fmt.Errorf("unresolved git conflicts found in files: %s. Please resolve conflicts and stage the files", strings.Join(unresolvedConflicts, ", ")) + } + + 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 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) + } + + 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) + } + } + + 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")) + + // Ensure all conflicts are resolved and staged before continuing + if err := ensureAllConflictsResolvedAndStaged(); err != nil { + return 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) + } + + logger.Info("Completing conflict resolution registration") + + // First, identify which targets were part of the conflict resolution + targetsInResolution := make(map[string]bool) + for targetName, target := range wf.Targets { + outDir := getTargetOutput(target) + checkCommitCmd := exec.Command("git", "log", "-1", "--grep=clean generation (conflict resolution)", "--format=%H", "--", outDir) + commitOutput, err := checkCommitCmd.Output() + if err == nil && strings.TrimSpace(string(commitOutput)) != "" { + // Get the most recent commit hash + headCommitCmd := exec.Command("git", "rev-parse", "HEAD") + headCommitOutput, headErr := headCommitCmd.Output() + if headErr == nil { + commitHash := strings.TrimSpace(string(commitOutput)) + headCommitHash := strings.TrimSpace(string(headCommitOutput)) + + // Only consider it part of resolution if the commit is the HEAD commit + if commitHash == headCommitHash { + targetsInResolution[targetName] = true + logger.Info(fmt.Sprintf("Target %s was part of conflict resolution", targetName)) + } + } + } + } + + targetPatches, err := getPatchesPerTarget(wf) + if err != nil { + return err + } + + // Only revert patches for targets that were part of conflict resolution + for targetName, target := range wf.Targets { + if !targetsInResolution[targetName] { + logger.Info(fmt.Sprintf("Skipping patch revert for target %s (not part of conflict resolution)", targetName)) + continue + } + + if err := RevertCustomCodePatch(ctx, target); err != nil { + // If reverting fails, it might be because the patch was already removed (user accepted ours) + // Log but continue - we'll handle this in the next phase + logger.Warn(fmt.Sprintf("Could not revert patch for target %s (may already be reverted): %v", targetName, err)) + } + } + + for targetName, target := range wf.Targets { + if targetPatches[targetName] == "" { + // Check if this target was part of the conflict resolution using the map we built earlier + if !targetsInResolution[targetName] { + // This target was not part of conflict resolution (wasn't regenerated) + // Keep its existing patch unchanged + logger.Info(fmt.Sprintf("Target %s was not part of conflict resolution, preserving existing patch", targetName)) + continue + } + + // Target was part of conflict resolution but has no new patches + // Check if there's actually a patch to clean up + if patchFileExists(getTargetOutput(target)) { + fmt.Printf("No changes detected for target %s after conflict resolution, cleaning up patch registration\n", targetName) + + // 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 + } + } + + fmt.Println("\nSuccessfully registered updated custom code patches.") + fmt.Println("Your custom code is now compatible with the latest 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) + customCodeDiff, err := captureCustomCodeDiff(getTargetOutput(target), otherTargetOutputs) + if err != nil { + return nil, fmt.Errorf("failed to capture custom code diff: %w", err) + } + fmt.Println(fmt.Sprintf("Captured custom code diff for target %v:\n%s", targetName, customCodeDiff)) + // If no custom code changes detected, return early + if customCodeDiff == "" { + fmt.Println(fmt.Sprintf("No custom code changes detected in target %v, nothing to register", targetName)) + } + targetPatches[targetName] = customCodeDiff + } + return targetPatches, nil +} + +func checkNoSpeakeasyChanges(ctx context.Context) error { + logger := log.From(ctx) + logger.Info("Checking that changeset doesn't include .speakeasy directory changes") + + cmd := exec.Command("git", "diff", "--name-only") + 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, workflow *workflow.Workflow) error { + 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 { + 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") + 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(wf *workflow.Workflow) []string { + var paths []string + + // 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) + } + } + } + + // 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) + } + } + } + } + } + + return paths +} + +func isLocalPath(location workflow.LocationString) bool { + resolvedPath := location.Resolve() + + // Check if this is a remote URL + if strings.HasPrefix(resolvedPath, "https://") || strings.HasPrefix(resolvedPath, "http://") { + return false + } + + // 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, "@")) +} + +// Git operations +func captureCustomCodeDiff(outDir string, excludePaths []string) (string, error) { + // First, check for new files and deletions in the staged changes + if err := checkForNewFilesOrDeletions(outDir); err != nil { + return "", err + } + + args := []string{"diff", "HEAD", outDir} + + // Filter excludePaths to only include children of outDir + 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() + + if err != nil { + return "", fmt.Errorf("failed to capture git diff: %w", err) + } + + return string(combinedOutput), nil +} + +// checkForNewFilesOrDeletions checks if there are any new files or file deletions +// in the staged changes and returns an error if found, as these are not supported +func checkForNewFilesOrDeletions(outDir string) error { + // Check for new files (A) and deletions (D) using git diff with filter + cmd := exec.Command("git", "diff", "--name-status", "--diff-filter=AD", "HEAD", outDir) + output, err := cmd.Output() + if err != nil { + return fmt.Errorf("failed to check for new files or deletions: %w", err) + } + + outputStr := strings.TrimSpace(string(output)) + if outputStr == "" { + // No additions or deletions found + return nil + } + + lines := strings.Split(outputStr, "\n") + var newFiles []string + var deletedFiles []string + + for _, line := range lines { + if line == "" { + continue + } + + // Format is "STATUS\tFILENAME" + parts := strings.SplitN(line, "\t", 2) + if len(parts) != 2 { + continue + } + + status := parts[0] + filename := parts[1] + + if status == "A" { + newFiles = append(newFiles, filename) + } else if status == "D" { + deletedFiles = append(deletedFiles, filename) + } + } + + // Return error if we found new files or deletions + if len(newFiles) > 0 { + return fmt.Errorf("Cannot register new files through customcode. New files found: %s", strings.Join(newFiles, ", ")) + } + + if len(deletedFiles) > 0 { + return fmt.Errorf("Cannot register file deletions through customcode. Deleted files found: %s", strings.Join(deletedFiles, ", ")) + } + + return nil +} + +func checkForChangesWithExclusions(dir string, excludePaths []string) (bool, error) { + args := []string{"diff", "--cached", dir} + + // 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 = "." + } + // Add all changes + 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)) + } + + return nil +} + + +func commitRevertCustomCode() 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 (allow empty if nothing changed) + cmd := exec.Command("git", "commit", "-m", "reverse apply custom code", "--allow-empty") + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to commit: %w\nOutput: %s", err, string(output)) + } + + return nil +} + +func commitCustomCodeChanges() (string, error) { + // Commit the staged changes (changes should already be staged by --index operations) + commitMsg := "Apply custom code changes" + 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)) + } + + // 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 commitCustomCodeRegistration(outDir string) error { + // Add gen.lock and patch file + genLockPath := fmt.Sprintf("%v/.speakeasy/gen.lock", outDir) + patchPath := getPatchFilePath(outDir) + + // Always add gen.lock + cmd := exec.Command("git", "add", genLockPath) + if err := cmd.Run(); err != nil { + 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 + 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 custom code registration: %w\nOutput: %s", err, string(output)) + } + + return nil +} + +func ApplyCustomCodePatch(ctx context.Context, target workflow.Target) error { + outDir := getTargetOutput(target) + + // 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 read patch file: %w", err) + } + if patchContent == "" { + return nil // Empty patch, nothing to apply + } + + // 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 +} + +func RevertCustomCodePatch(ctx context.Context, target workflow.Target) error { + outDir := getTargetOutput(target) + + // Check if patch file exists + if !patchFileExists(outDir) { + return nil // No patch to revert + } + + // Read patch content to verify it's not empty + patchContent, err := readPatchFile(outDir) + if err != nil { + return fmt.Errorf("failed to read patch file: %w", err) + } + if patchContent == "" { + return nil // Empty patch, nothing to revert + } + + // Revert the patch directly from file with reverse flag + patchFile := getPatchFilePath(outDir) + args := []string{"apply", "--reverse", "--index", patchFile} + cmd := exec.Command("git", args...) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to revert patch: %w\nOutput: %s", err, string(output)) + } + + return nil +} + +func applyNewPatch(customCodeDiff string) error { + if customCodeDiff == "" { + return nil + } + + // Create a temporary patch file + 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) + } + defer os.Remove(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)) + } + + return nil +} + +// 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 { + 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) + } + + // Write patch to file + if patchset != "" { + 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 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") + } + + // 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 +} + +// compileSDK compiles the SDK to verify custom code changes don't break compilation +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 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 +} + +// 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 +} + +// removeReverseCustomCode removes the reverse custom code commit by: +// 1. stash local changes +// 2. reset --hard to the original git hash +// 3. stash pop those local changes +func removeReverseCustomCode(ctx context.Context, originalHash plumbing.Hash) error { + logger := log.From(ctx).With(zap.String("method", "removeCleanGenerationCommit")) + logger.Info("Starting error recovery process", zap.String("target_hash", originalHash.String())) + + // 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 +} + +// 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)" +} diff --git a/internal/run/source.go b/internal/run/source.go index 137ea77ab..d031cd525 100644 --- a/internal/run/source.go +++ b/internal/run/source.go @@ -41,6 +41,7 @@ const ( // Generator steps SourceStepStart SourceStepID = "Started" SourceStepGenerate SourceStepID = "Generating SDK" + SourceStepApplyCodeChanges SourceStepID = "Applying Code Changes" SourceStepCompile SourceStepID = "Compiling SDK" SourceStepComplete SourceStepID = "Completed" SourceStepCancel SourceStepID = "Cancelling" diff --git a/internal/run/target.go b/internal/run/target.go index 6fb3eb673..4671c1122 100644 --- a/internal/run/target.go +++ b/internal/run/target.go @@ -22,6 +22,7 @@ import ( "github.com/speakeasy-api/speakeasy/internal/git" "github.com/speakeasy-api/speakeasy/internal/links" "github.com/speakeasy-api/speakeasy/internal/log" + "github.com/speakeasy-api/speakeasy/internal/registercustomcode" "github.com/speakeasy-api/speakeasy/internal/sdkchangelog" "github.com/speakeasy-api/speakeasy/internal/sdkgen" "github.com/speakeasy-api/speakeasy/internal/utils" @@ -222,12 +223,34 @@ func (w *Workflow) runTarget(ctx context.Context, target string) (*SourceResult, Compile: w.ShouldCompile, TargetName: target, SkipVersioning: w.SkipVersioning, + SkipCustomCode: w.FromQuickstart || w.SkipApplyCustomCode, CancellableGeneration: w.CancellableGeneration, StreamableGeneration: w.StreamableGeneration, ReleaseNotes: changelogContent, }, ) if err != nil { + // Check if this is a custom code conflict + if strings.Contains(err.Error(), "CUSTOM_CODE_CONFLICT_DETECTED:") { + // Print banner + fmt.Println("\n" + strings.Repeat("=", 70)) + fmt.Println("CUSTOM CODE CONFLICTS DETECTED") + fmt.Println(strings.Repeat("=", 70)) + fmt.Println("\nThe SDK was generated successfully, but your custom code patches") + fmt.Println("conflict with the new generation and cannot be applied automatically.") + fmt.Println("\nEntering automatic conflict resolution mode...") + fmt.Println(strings.Repeat("=", 70) + "\n") + + // Automatically trigger conflict resolution for all targets + if resolveErr := registercustomcode.ResolveCustomCodeConflicts(ctx); resolveErr != nil { + return sourceRes, nil, fmt.Errorf("automatic conflict resolution setup failed: %w", resolveErr) + } + + // Exit with code 2 to indicate manual action needed + os.Exit(2) + } + + // Real generation errors return sourceRes, nil, err } w.generationAccess = generationAccess diff --git a/internal/run/workflow.go b/internal/run/workflow.go index 85279cd0f..8c258b623 100644 --- a/internal/run/workflow.go +++ b/internal/run/workflow.go @@ -63,6 +63,7 @@ type Workflow struct { SourceResults map[string]*SourceResult TargetResults map[string]*TargetResult OnSourceResult SourceResultCallback + SkipApplyCustomCode bool Duration time.Duration criticalWarns []string Error error @@ -303,6 +304,12 @@ func WithSourceUpdates(onSourceResult SourceResultCallback) Opt { } } +func WithSkipApplyCustomCode() Opt { + return func(w *Workflow) { + w.SkipApplyCustomCode = true + } +} + func WithCancellableGeneration(cancellable bool) Opt { return func(w *Workflow) { if cancellable { diff --git a/internal/sdkgen/sdkgen.go b/internal/sdkgen/sdkgen.go index 80017b359..466aeba0b 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 @@ -150,6 +151,10 @@ func Generate(ctx context.Context, opts GenerateOptions) (*GenerationAccess, err generate.WithChangelogReleaseNotes(opts.ReleaseNotes), } + if opts.SkipCustomCode { + generatorOpts = append(generatorOpts, generate.WithSkipApplyCustomCode()) + } + if opts.Verbose { generatorOpts = append(generatorOpts, generate.WithVerboseOutput(true)) } @@ -204,7 +209,12 @@ func Generate(ctx context.Context, opts GenerateOptions) (*GenerationAccess, err } if len(errs) > 0 { + // Check if this is a custom code conflict for _, err := range errs { + if strings.Contains(err.Error(), "CUSTOM_CODE_CONFLICT:") { + // Return special error that can be detected upstream + return fmt.Errorf("CUSTOM_CODE_CONFLICT_DETECTED: %w", err) + } logger.Error("", zap.Error(err)) } 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: diff --git a/internal/usagegen/usagegen.go b/internal/usagegen/usagegen.go index 10bda79bb..354c553d6 100644 --- a/internal/usagegen/usagegen.go +++ b/internal/usagegen/usagegen.go @@ -60,6 +60,7 @@ func Generate( generate.WithFileSystem(&fileSystem{buf: tmpOutput}), generate.WithRunLocation("cli"), generate.WithGenVersion(strings.TrimPrefix(changelog.GetLatestVersion(), "v")), + generate.WithSkipApplyCustomCode(), generate.WithForceGeneration(), }