diff --git a/pkg/cli/add_interactive_engine.go b/pkg/cli/add_interactive_engine.go index 63f81f5abbd..96ee85a911a 100644 --- a/pkg/cli/add_interactive_engine.go +++ b/pkg/cli/add_interactive_engine.go @@ -30,7 +30,7 @@ func (c *AddInteractiveConfig) selectAIEngineAndKey() error { // If engine is already overridden, skip selection if c.EngineOverride != "" { fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Using coding agent: "+c.EngineOverride)) - return c.configureEngineAPISecret(c.EngineOverride) + return c.selectEngineAuthMethod(c.EngineOverride) } // Inform user if workflow specifies an engine @@ -62,7 +62,7 @@ func (c *AddInteractiveConfig) selectAIEngineAndKey() error { c.EngineOverride = selectedEngine fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("Selected engine: "+selectedEngine)) - return c.configureEngineAPISecret(selectedEngine) + return c.selectEngineAuthMethod(selectedEngine) } func (c *AddInteractiveConfig) getWorkflowSpecifiedEngine() string { @@ -145,7 +145,31 @@ func prioritizeEngineOption(engineOptions []huh.Option[string], defaultEngine st } } +// selectEngineAuthMethod prompts for engine-specific authentication method choices +// (for example, Copilot org billing vs. a personal access token) that only affect +// generated workflow content and have no remote repository side effects. This runs +// during engine selection, before the user has chosen between the PR and local-write +// paths. Collecting and uploading the actual secret value is deferred to +// configureEngineAPISecret, which must only run after the user commits to the PR +// path and the working directory has been confirmed clean. +func (c *AddInteractiveConfig) selectEngineAuthMethod(engine string) error { + // If --no-secret flag is set, skip auth-method selection entirely. + if c.SkipSecret { + return nil + } + + // For Copilot, ask the user whether to use copilot-requests (org billing) or a PAT. + // Only prompt when an interactive context is available (wizard path); default to PAT otherwise. + if engine == string(constants.CopilotEngine) && c.Ctx != nil { + return c.selectCopilotAuthMethod() + } + + return nil +} + // configureEngineAPISecret collects the API key for the selected engine using the unified engine secrets functions +// and uploads it to the repository. This has remote side effects and must only be called after the user has +// chosen to create a PR and the working directory has been confirmed clean. func (c *AddInteractiveConfig) configureEngineAPISecret(engine string) error { addInteractiveLog.Printf("Collecting API key for engine: %s", engine) @@ -162,15 +186,11 @@ func (c *AddInteractiveConfig) configureEngineAPISecret(engine string) error { return nil } - // For Copilot, ask the user whether to use copilot-requests (org billing) or a PAT. - // Only prompt when an interactive context is available (wizard path); default to PAT otherwise. - if engine == string(constants.CopilotEngine) && c.Ctx != nil { - if err := c.selectCopilotAuthMethod(); err != nil { - return err - } - if c.UseCopilotRequests { - return nil - } + // The Copilot auth-method choice (org billing vs. PAT) was already made in + // selectEngineAuthMethod during engine selection. If the user chose org billing, + // no secret needs to be collected or uploaded. + if engine == string(constants.CopilotEngine) && c.UseCopilotRequests { + return nil } // If user doesn't have write access, skip secrets configuration. @@ -203,7 +223,7 @@ func (c *AddInteractiveConfig) configureEngineAPISecret(engine string) error { } // Update existingSecrets to reflect that the secret was uploaded - // This prevents duplicate secret uploads in createWorkflowPRAndConfigureSecret later + // This prevents duplicate secret uploads in createWorkflowChangesAndConfigureSecret later opt := constants.GetEngineOption(engine) if opt != nil { c.existingSecrets[opt.SecretName] = struct{}{} diff --git a/pkg/cli/add_interactive_git.go b/pkg/cli/add_interactive_git.go index 7f8f22db316..576c2f22ac8 100644 --- a/pkg/cli/add_interactive_git.go +++ b/pkg/cli/add_interactive_git.go @@ -32,13 +32,13 @@ const ( mergeActionExit mergeAction = "exit" ) -// createWorkflowPRAndConfigureSecret creates the PR, merges it, and adds the secret -func (c *AddInteractiveConfig) createWorkflowPRAndConfigureSecret(ctx context.Context, workflowFiles, initFiles []string, secretName, secretValue string) error { +// createWorkflowChangesAndConfigureSecret writes the workflows, optionally creates and merges a PR, and adds the secret. +func (c *AddInteractiveConfig) createWorkflowChangesAndConfigureSecret(ctx context.Context, workflowFiles, initFiles []string, secretName, secretValue string, createPR bool) error { addInteractiveLog.Print("Applying changes") fmt.Fprintln(os.Stderr, "") - // Add the workflow using existing implementation with --create-pull-request + // Add the workflow using the existing implementation. // Pass the resolved workflows to avoid re-fetching them // Pass Quiet=true to suppress detailed output (already shown earlier in interactive mode) // This returns the result including PR number and HasWorkflowDispatch @@ -49,7 +49,7 @@ func (c *AddInteractiveConfig) createWorkflowPRAndConfigureSecret(ctx context.Co Name: "", Force: false, AppendText: c.AppendText, - CreatePR: true, + CreatePR: createPR, NoGitattributes: c.NoGitattributes, WorkflowDir: c.WorkflowDir, NoStopAfter: c.NoStopAfter, @@ -64,6 +64,11 @@ func (c *AddInteractiveConfig) createWorkflowPRAndConfigureSecret(ctx context.Co } c.addResult = result + if !createPR { + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("Workflow files written locally. No pull request was created.")) + return nil + } + if err := c.ensurePullRequestMerged(result.PRNumber, result.PRURL); err != nil { return err } @@ -298,16 +303,22 @@ func (c *AddInteractiveConfig) updateLocalBranch() error { return nil } -// checkCleanWorkingDirectory verifies the working directory has no uncommitted changes. -// This is checked early in the interactive flow to avoid failing later during PR creation. -func (c *AddInteractiveConfig) checkCleanWorkingDirectory() error { - addInteractiveLog.Print("Checking working directory is clean") - - if err := checkCleanWorkingDirectory(c.Verbose); err != nil { +// checkCleanWorkingDirectoryForPR verifies the working directory had no user changes +// before the wizard began repository initialization. It relies on the cleanliness +// snapshot captured in workingDirDirtyBeforeInit (taken before +// ensureAddRepositoryInitializedWithDetails ran) rather than re-checking git status and +// excluding the wizard's init files. Excluding whole init file paths post-hoc would +// wrongly ignore pre-existing, non-conforming files (e.g. a dirty .gitattributes +// missing a required entry) that ensureAddRepositoryInitializedWithDetails rewrites in +// place, letting the PR path silently overwrite or commit pre-existing user edits. +func (c *AddInteractiveConfig) checkCleanWorkingDirectoryForPR() error { + addInteractiveLog.Print("Checking working directory is clean before PR creation") + + if c.workingDirDirtyBeforeInit { fmt.Fprintln(os.Stderr, console.FormatErrorMessage("Working directory is not clean.")) fmt.Fprintln(os.Stderr, "") - fmt.Fprintln(os.Stderr, "The add wizard creates a pull request which requires a clean working directory.") - fmt.Fprintln(os.Stderr, "Please commit or stash your changes first:") + fmt.Fprintln(os.Stderr, "Creating a pull request requires a clean working directory.") + fmt.Fprintln(os.Stderr, "Please commit or stash your changes first, or choose the local write option:") fmt.Fprintln(os.Stderr, "") fmt.Fprintln(os.Stderr, console.FormatCommandMessage(" git stash # Temporarily stash changes")) fmt.Fprintln(os.Stderr, console.FormatCommandMessage(" git add -A && git commit -m 'wip' # Commit changes")) diff --git a/pkg/cli/add_interactive_orchestrator.go b/pkg/cli/add_interactive_orchestrator.go index 26560822aa0..c09871b9b7d 100644 --- a/pkg/cli/add_interactive_orchestrator.go +++ b/pkg/cli/add_interactive_orchestrator.go @@ -62,6 +62,13 @@ type AddInteractiveConfig struct { // resolvedWorkflows holds the pre-resolved workflow data including descriptions // This is populated early in the flow by resolveWorkflows() resolvedWorkflows *ResolvedWorkflows + + // workingDirDirtyBeforeInit records whether the working directory already had + // uncommitted changes before any wizard-driven repository initialization ran. + // It is captured once, early in RunAddInteractive, and used by + // checkCleanWorkingDirectoryForPR so that wizard-modified init markers (which may + // rewrite pre-existing, non-conforming files) are never mistaken for a clean tree. + workingDirDirtyBeforeInit bool } // RunAddInteractive runs the interactive add workflow @@ -88,16 +95,34 @@ func RunAddInteractive(ctx context.Context, config *AddInteractiveConfig) error return err } + // Snapshot working directory cleanliness before any wizard-driven repository + // initialization runs. This is used later, only for the PR path, to detect + // pre-existing user changes without mistaking wizard-modified init markers for + // pre-existing dirty state. + pendingChanges, err := hasPendingChanges() + if err != nil { + return err + } + config.workingDirDirtyBeforeInit = pendingChanges + remainingBootstrapProfile := config.getRemainingBootstrapProfile() - filesToAdd, initFiles, secretName, secretValue, err := config.prepareAndConfirmAddInteractive() + filesToAdd, initFiles, secretName, secretValue, createPR, err := config.prepareAndConfirmAddInteractive() if err != nil { return err } - if err := config.createWorkflowPRAndConfigureSecret(ctx, filesToAdd, initFiles, secretName, secretValue); err != nil { + if err := config.createWorkflowChangesAndConfigureSecret(ctx, filesToAdd, initFiles, secretName, secretValue, createPR); err != nil { return err } + if !createPR { + // Local writes stop before remote-only follow-up: repository secret updates, + // bootstrap mutations, workflow status polling, and optional dispatch all require + // the workflow changes to be present on GitHub. + printBootstrapConfigTODO(os.Stderr, remainingBootstrapProfile) + config.showLocalWriteInstructions() + return nil + } if err := config.applyBootstrapConfigIfNeeded(ctx, remainingBootstrapProfile); err != nil { return err @@ -159,46 +184,61 @@ func (c *AddInteractiveConfig) runInitialAddInteractiveChecks() error { if err := c.checkGitRepository(); err != nil { return err } - if err := c.checkCleanWorkingDirectory(); err != nil { - return err - } if err := c.checkActionsEnabled(); err != nil { return err } return c.checkUserPermissions() } -func (c *AddInteractiveConfig) prepareAndConfirmAddInteractive() (workflowFiles, initFiles []string, secretName, secretValue string, err error) { +func (c *AddInteractiveConfig) prepareAndConfirmAddInteractive() (workflowFiles, initFiles []string, secretName, secretValue string, createPR bool, err error) { + // selectAIEngineAndKey only selects the engine and, for Copilot, the auth method + // (org billing vs. PAT). It does not prompt for or upload any secret value, since + // that has remote repository side effects and must wait until the user has + // chosen the PR path and the working directory has been confirmed clean. if err := c.selectAIEngineAndKey(); err != nil { - return nil, nil, "", "", err + return nil, nil, "", "", false, err } initFiles, err = ensureAddRepositoryInitializedWithDetails(c.EngineOverride, c.Verbose, c.NoGitattributes) if err != nil { - return nil, nil, "", "", err + return nil, nil, "", "", false, err } workflowFiles, _, err = c.determineFilesToAdd() if err != nil { - return nil, nil, "", "", err + return nil, nil, "", "", false, err } if err := c.selectScheduleFrequency(); err != nil { - return nil, nil, "", "", err + return nil, nil, "", "", false, err + } + + createPR, err = c.confirmChanges(workflowFiles, initFiles) + if err != nil { + return nil, nil, "", "", false, err + } + if !createPR { + return workflowFiles, initFiles, "", "", false, nil + } + + if err := c.checkCleanWorkingDirectoryForPR(); err != nil { + return nil, nil, "", "", false, err + } + + // Secret collection and upload only happen once the user has committed to the + // PR path and the clean-tree check has succeeded. + if err := c.configureEngineAPISecret(c.EngineOverride); err != nil { + return nil, nil, "", "", false, err } if c.hasWriteAccess && !c.SkipSecret && !c.UseCopilotRequests { secretName, secretValue, err = c.resolveEngineApiKeyCredential() if err != nil { - return nil, nil, "", "", err + return nil, nil, "", "", false, err } } - if err := c.confirmChanges(workflowFiles, initFiles, secretName, secretValue); err != nil { - return nil, nil, "", "", err - } - - return workflowFiles, initFiles, secretName, secretValue, nil + return workflowFiles, initFiles, secretName, secretValue, createPR, nil } // resolveWorkflows resolves workflow specifications by installing repositories, @@ -324,8 +364,7 @@ func (c *AddInteractiveConfig) primaryWorkflowName() string { } // confirmChanges asks the user to confirm the changes -// secretValue is empty if the secret already exists in the repository -func (c *AddInteractiveConfig) confirmChanges(workflowFiles, initFiles []string, secretName string, secretValue string) error { +func (c *AddInteractiveConfig) confirmChanges(workflowFiles, initFiles []string) (bool, error) { addInteractiveLog.Print("Confirming changes with user") fmt.Fprintln(os.Stderr, "") @@ -337,29 +376,25 @@ func (c *AddInteractiveConfig) confirmChanges(workflowFiles, initFiles []string, fmt.Fprintln(os.Stderr, "") } - confirmed := true // Default to yes + createPR := true // Default to yes form := console.NewConfirmForm( huh.NewConfirm(). - Title("Do you want to proceed with these changes?"). - Description("A pull request will be created with the workflow files"). + Title("Do you want to create a pull request with these changes?"). + Description("Choose No to write the workflow files locally without creating a pull request"). Affirmative("Yes, create pull request"). - Negative("No, cancel"). - Value(&confirmed), + Negative("No, write files locally"). + Value(&createPR), ) if err := form.RunWithContext(c.Ctx); err != nil { - return fmt.Errorf("confirmation failed: %w", err) - } - - if !confirmed { - fmt.Fprintln(os.Stderr, "Operation cancelled.") - return errors.New("user cancelled the operation") + return false, fmt.Errorf("confirmation failed: %w", err) } - return nil + return createPR, nil } -// showFinalInstructions shows final instructions to the user +// showFinalInstructions shows final instructions to the user after a PR was created +// and the workflow files are live on GitHub. func (c *AddInteractiveConfig) showFinalInstructions() { fmt.Fprintln(os.Stderr, "") fmt.Fprintln(os.Stderr, "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") @@ -382,3 +417,34 @@ func (c *AddInteractiveConfig) showFinalInstructions() { fmt.Fprintln(os.Stderr, "Learn more at: https://github.github.com/gh-aw/") fmt.Fprintln(os.Stderr, "") } + +// showLocalWriteInstructions shows final instructions to the user when workflow files +// were written locally without creating a PR. Unlike showFinalInstructions, this does +// not claim the workflow is already running or recommend remote status/run commands, +// since the files only exist in the local checkout and have not been pushed. +func (c *AddInteractiveConfig) showLocalWriteInstructions() { + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("🎉 Files written locally!")) + fmt.Fprintln(os.Stderr, "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + fmt.Fprintln(os.Stderr, "") + + // Show summary with workflow name(s) + if c.resolvedWorkflows != nil && len(c.resolvedWorkflows.Workflows) > 0 { + wf := c.resolvedWorkflows.Workflows[0] + fmt.Fprintf(os.Stderr, "The workflow '%s' has been written to your local checkout. No pull request was created.\n", wf.Spec.WorkflowName) + c.showWorkflowDescriptions() + } + + fmt.Fprintln(os.Stderr, "Commit and push the new files before the workflow can run on GitHub:") + fmt.Fprintln(os.Stderr, console.FormatCommandMessage(" git add -A && git commit -m 'Add agentic workflow'")) + fmt.Fprintln(os.Stderr, console.FormatCommandMessage(" git push")) + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, "Once pushed, these commands will work against the remote repository:") + fmt.Fprintln(os.Stderr, console.FormatCommandMessage(fmt.Sprintf(" %s status # Check workflow status", string(constants.CLIExtensionPrefix)))) + fmt.Fprintln(os.Stderr, console.FormatCommandMessage(fmt.Sprintf(" %s run # Trigger a workflow", string(constants.CLIExtensionPrefix)))) + fmt.Fprintln(os.Stderr, console.FormatCommandMessage(fmt.Sprintf(" %s logs # View workflow logs", string(constants.CLIExtensionPrefix)))) + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, "Learn more at: https://github.github.com/gh-aw/") + fmt.Fprintln(os.Stderr, "") +} diff --git a/pkg/cli/add_interactive_orchestrator_test.go b/pkg/cli/add_interactive_orchestrator_test.go index c36bcdd1583..ae35bf082b8 100644 --- a/pkg/cli/add_interactive_orchestrator_test.go +++ b/pkg/cli/add_interactive_orchestrator_test.go @@ -3,6 +3,10 @@ package cli import ( + "context" + "os" + "os/exec" + "path/filepath" "testing" "github.com/stretchr/testify/assert" @@ -227,3 +231,133 @@ func TestAddInteractiveConfig_showFinalInstructions(t *testing.T) { }) } } + +func TestAddInteractiveConfig_createWorkflowChangesLocallyDoesNotRequireCleanTreeOrCreatePR(t *testing.T) { + tmpDir := t.TempDir() + oldWd, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(tmpDir)) + defer func() { + require.NoError(t, os.Chdir(oldWd)) + }() + + gitInit := exec.Command("git", "init") + gitInit.Dir = tmpDir + require.NoError(t, gitInit.Run()) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "existing-change.txt"), []byte("dirty tree"), 0o644)) + + fakeGH := filepath.Join(tmpDir, "gh") + require.NoError(t, os.WriteFile(fakeGH, []byte("#!/bin/sh\necho unexpected gh invocation >&2\nexit 42\n"), 0o755)) + t.Setenv("PATH", tmpDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + config := &AddInteractiveConfig{ + WorkflowSpecs: []string{"owner/repo/test-workflow"}, + resolvedWorkflows: &ResolvedWorkflows{ + Workflows: []*ResolvedWorkflow{ + { + Spec: &WorkflowSpec{ + RepoSpec: RepoSpec{ + RepoSlug: "owner/repo", + }, + WorkflowName: "test-workflow", + WorkflowPath: "test.md", + }, + Content: []byte("---\non:\n workflow_dispatch:\n---\n# Test workflow\n"), + }, + }, + HasWorkflowDispatch: true, + }, + } + + err = config.createWorkflowChangesAndConfigureSecret(context.Background(), []string{"test-workflow.md", "test-workflow.lock.yml"}, nil, "COPILOT_GITHUB_TOKEN", "secret", false) + require.NoError(t, err) + + require.NotNil(t, config.addResult) + assert.Zero(t, config.addResult.PRNumber) + assert.Empty(t, config.addResult.PRURL) + assert.True(t, config.addResult.HasWorkflowDispatch) + + workflowPath := filepath.Join(tmpDir, ".github", "workflows", "test-workflow.md") + _, err = os.Stat(workflowPath) + require.NoError(t, err, "workflow should be written locally") +} + +// TestAddInteractiveConfig_prepareAndConfirmAddInteractive_localWriteSkipsSecretsAndPRSteps +// drives the actual orchestration in prepareAndConfirmAddInteractive (not just the +// downstream write helper) with a simulated "No, write files locally" answer to the +// PR-vs-local prompt. It asserts that choosing local writes never invokes any gh +// mutation (secret upload, PR creation/merge) and that no secret is returned for the +// caller to configure. +func TestAddInteractiveConfig_prepareAndConfirmAddInteractive_localWriteSkipsSecretsAndPRSteps(t *testing.T) { + tmpDir := t.TempDir() + oldWd, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(tmpDir)) + defer func() { + require.NoError(t, os.Chdir(oldWd)) + }() + + gitInit := exec.Command("git", "init") + gitInit.Dir = tmpDir + require.NoError(t, gitInit.Run()) + + // A fake gh that records every invocation instead of touching the network. Reads + // (e.g. listing existing secrets) are expected and tolerated by the caller, but any + // mutating invocation (secret set, pr create/merge) must never happen on the local + // write path. + ghLog := filepath.Join(tmpDir, "gh-invocations.log") + fakeGH := filepath.Join(tmpDir, "gh") + script := "#!/bin/sh\necho \"$@\" >> " + ghLog + "\nexit 0\n" + require.NoError(t, os.WriteFile(fakeGH, []byte(script), 0o755)) + t.Setenv("PATH", tmpDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + // Drive the huh confirm form via accessible (line-based) mode, answering "no" to + // "Do you want to create a pull request with these changes?". + t.Setenv("ACCESSIBLE", "1") + r, w, err := os.Pipe() + require.NoError(t, err) + _, err = w.WriteString("n\n") + require.NoError(t, err) + require.NoError(t, w.Close()) + oldStdin := os.Stdin + os.Stdin = r + defer func() { os.Stdin = oldStdin }() + + config := &AddInteractiveConfig{ + Ctx: context.Background(), + WorkflowSpecs: []string{"owner/repo/test-workflow"}, + EngineOverride: "copilot", + SkipSecret: true, + hasWriteAccess: true, + RepoOverride: "owner/repo", + resolvedWorkflows: &ResolvedWorkflows{ + Workflows: []*ResolvedWorkflow{ + { + Spec: &WorkflowSpec{ + RepoSpec: RepoSpec{RepoSlug: "owner/repo"}, + WorkflowName: "test-workflow", + WorkflowPath: "test.md", + }, + Content: []byte("---\non:\n workflow_dispatch:\n---\n# Test workflow\n"), + }, + }, + HasWorkflowDispatch: true, + }, + } + + workflowFiles, _, secretName, secretValue, createPR, err := config.prepareAndConfirmAddInteractive() + require.NoError(t, err) + + assert.False(t, createPR, "choosing local writes should report createPR=false") + assert.Empty(t, secretName, "local writes must not resolve a secret to configure") + assert.Empty(t, secretValue, "local writes must not resolve a secret value") + assert.NotEmpty(t, workflowFiles, "workflow files should still be determined for local writes") + + if _, statErr := os.Stat(ghLog); statErr == nil { + logContent, readErr := os.ReadFile(ghLog) + require.NoError(t, readErr) + assert.NotContains(t, string(logContent), "secret set", "local write path must never upload a repository secret") + assert.NotContains(t, string(logContent), "pr create", "local write path must never create a pull request") + assert.NotContains(t, string(logContent), "pr merge", "local write path must never merge a pull request") + } +} diff --git a/pkg/cli/add_wizard_command.go b/pkg/cli/add_wizard_command.go index 96948fa8ce9..0f99e5ed012 100644 --- a/pkg/cli/add_wizard_command.go +++ b/pkg/cli/add_wizard_command.go @@ -21,7 +21,7 @@ func NewAddWizardCommand(validateEngine func(string) error) *cobra.Command { This command walks you through: - Selecting an AI engine (Copilot, Claude, Codex, Gemini, or Pi) - Configuring API keys and secrets - - Creating a pull request with the workflow + - Writing the workflow locally or creating a pull request with it - Optionally running the workflow immediately Use 'add' for non-interactive workflow addition. diff --git a/pkg/cli/add_wizard_tuistory_integration_test.go b/pkg/cli/add_wizard_tuistory_integration_test.go index 69465a110e2..51c7a4e49ae 100644 --- a/pkg/cli/add_wizard_tuistory_integration_test.go +++ b/pkg/cli/add_wizard_tuistory_integration_test.go @@ -373,7 +373,7 @@ func TestTuistoryAddWizardIntegration(t *testing.T) { enterOutput, err := runTuistory(t, "-s", sessionName, "press", "enter") require.NoError(t, err, "Failed to press enter after repository slug. Output: %s", enterOutput) - waitForTuistoryText(t, sessionName, "Do you want to proceed with these changes?", 120000) + waitForTuistoryText(t, sessionName, "Do you want to create a pull request with these changes?", 120000) cancelOutput, err := runTuistory(t, "-s", sessionName, "press", "ctrl", "c") require.NoError(t, err, "Failed to send Ctrl+C to add-wizard session. Output: %s", cancelOutput)