Skip to content
2 changes: 1 addition & 1 deletion pkg/cli/add_interactive_engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,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{}{}
Expand Down
28 changes: 17 additions & 11 deletions pkg/cli/add_interactive_git.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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
}
Expand Down Expand Up @@ -298,16 +303,17 @@ 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")
// checkCleanWorkingDirectoryForPR verifies the working directory has no user changes
// before the wizard creates a pull request. Repository init files created by the
// wizard itself are ignored because they are part of the pending PR.
func (c *AddInteractiveConfig) checkCleanWorkingDirectoryForPR(initFiles []string) error {
addInteractiveLog.Print("Checking working directory is clean before PR creation")

if err := checkCleanWorkingDirectory(c.Verbose); err != nil {
if err := checkCleanWorkingDirectoryIgnoring(c.Verbose, initFiles); err != nil {
Comment thread
mnkiefer marked this conversation as resolved.
Outdated
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"))
Expand Down
66 changes: 35 additions & 31 deletions pkg/cli/add_interactive_orchestrator.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,14 +90,22 @@ func RunAddInteractive(ctx context.Context, config *AddInteractiveConfig) error

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.showFinalInstructions()
Comment thread
mnkiefer marked this conversation as resolved.
Outdated
return nil
}

if err := config.applyBootstrapConfigIfNeeded(ctx, remainingBootstrapProfile); err != nil {
return err
Expand Down Expand Up @@ -159,46 +167,48 @@ 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) {
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
}

if c.hasWriteAccess && !c.SkipSecret && !c.UseCopilotRequests {
createPR, err = c.confirmChanges(workflowFiles, initFiles)
if err != nil {
return nil, nil, "", "", false, err
}
if createPR {
if err := c.checkCleanWorkingDirectoryForPR(initFiles); err != nil {
return nil, nil, "", "", false, err
}
}
if createPR && c.hasWriteAccess && !c.SkipSecret && !c.UseCopilotRequests {
Comment thread
mnkiefer marked this conversation as resolved.
Outdated
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,
Expand Down Expand Up @@ -324,8 +334,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, "")
Expand All @@ -337,26 +346,21 @@ 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)
return false, fmt.Errorf("confirmation failed: %w", err)
}

if !confirmed {
fmt.Fprintln(os.Stderr, "Operation cancelled.")
return errors.New("user cancelled the operation")
}

return nil
return createPR, nil
}

// showFinalInstructions shows final instructions to the user
Expand Down
54 changes: 54 additions & 0 deletions pkg/cli/add_interactive_orchestrator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
package cli

import (
"context"
"os"
"os/exec"
"path/filepath"
"testing"

"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -227,3 +231,53 @@ 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)
Comment thread
mnkiefer marked this conversation as resolved.
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")
}
2 changes: 1 addition & 1 deletion pkg/cli/add_wizard_command.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion pkg/cli/add_wizard_tuistory_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading