From b0817876f0d45dd80af5817f0f0b361158b0200b Mon Sep 17 00:00:00 2001 From: Mara Nikola Kiefer Date: Tue, 14 Jul 2026 18:18:46 +0200 Subject: [PATCH 1/8] feat: add bootstrap command for reusable auth, setup & repository checks --- cmd/gh-aw/main.go | 26 +- pkg/cli/bootstrap.go | 406 ++++++++++++++++++++++++++ pkg/cli/bootstrap_command.go | 81 +++++ pkg/cli/bootstrap_integration_test.go | 136 +++++++++ pkg/cli/bootstrap_test.go | 337 +++++++++++++++++++++ pkg/cli/setup_command.go | 92 ++++++ pkg/cli/setup_command_test.go | 283 ++++++++++++++++++ pkg/cli/setup_repository.go | 337 +++++++++++++++++++++ 8 files changed, 1690 insertions(+), 8 deletions(-) create mode 100644 pkg/cli/bootstrap.go create mode 100644 pkg/cli/bootstrap_command.go create mode 100644 pkg/cli/bootstrap_integration_test.go create mode 100644 pkg/cli/bootstrap_test.go create mode 100644 pkg/cli/setup_command.go create mode 100644 pkg/cli/setup_command_test.go create mode 100644 pkg/cli/setup_repository.go diff --git a/cmd/gh-aw/main.go b/cmd/gh-aw/main.go index c2ea95e7f4b..1bf340514ce 100644 --- a/cmd/gh-aw/main.go +++ b/cmd/gh-aw/main.go @@ -85,14 +85,16 @@ var rootCmd = &cobra.Command{ Long: `GitHub Agentic Workflows from GitHub Next Common Tasks: - gh aw init # Set up a new repository - gh aw add-wizard # Add workflows with interactive guided setup - gh aw new my-workflow # Create your first workflow - gh aw compile # Compile all workflows - gh aw run my-workflow # Execute a workflow - gh aw status # Check workflow status - gh aw logs my-workflow # View execution logs - gh aw audit # Audit and compare workflow runs + gh aw init # Set up a new repository + gh aw setup repo --repo owner/repo # Check auth and repo setup state + gh aw add-wizard # Add workflows with interactive guided setup + gh aw new my-workflow # Create your first workflow + gh aw compile # Compile all workflows + gh aw run my-workflow # Execute a workflow + gh aw status # Check workflow status + gh aw logs my-workflow # View execution logs + gh aw audit # Audit and compare workflow runs + gh aw bootstrap --repo owner/repo # Create or attach a repo and initialize it For detailed help on any command, use: gh aw [command] --help`, @@ -696,6 +698,9 @@ Use "` + string(constants.CLIExtensionPrefix) + ` help all" to show help for all // Create and setup add-wizard command addWizardCmd := cli.NewAddWizardCommand(validateEngine) + // Create and setup bootstrap command + bootstrapCmd := cli.NewBootstrapCommand(validateEngine) + // Create and setup update command updateCmd := cli.NewUpdateCommand(validateEngine) @@ -823,6 +828,7 @@ Use "` + string(constants.CLIExtensionPrefix) + ` help all" to show help for all completionCmd := cli.NewCompletionCommand() hashCmd := cli.NewHashCommand() projectCmd := cli.NewProjectCommand() + setupCmd := cli.NewSetupCommand() checksCmd := cli.NewChecksCommand() validateCmd := cli.NewValidateCommand(validateEngine) lintCmd := cli.NewLintCommand() @@ -837,12 +843,14 @@ Use "` + string(constants.CLIExtensionPrefix) + ` help all" to show help for all newCmd.GroupID = "setup" addCmd.GroupID = "setup" addWizardCmd.GroupID = "setup" + bootstrapCmd.GroupID = "setup" removeCmd.GroupID = "setup" updateCmd.GroupID = "setup" deployCmd.GroupID = "setup" upgradeCmd.GroupID = "setup" secretsCmd.GroupID = "setup" envCmd.GroupID = "setup" + setupCmd.GroupID = "setup" // Development Commands compileCmd.GroupID = "development" @@ -882,6 +890,7 @@ Use "` + string(constants.CLIExtensionPrefix) + ` help all" to show help for all // Add all commands to root rootCmd.AddCommand(addCmd) rootCmd.AddCommand(addWizardCmd) + rootCmd.AddCommand(bootstrapCmd) rootCmd.AddCommand(updateCmd) rootCmd.AddCommand(deployCmd) rootCmd.AddCommand(upgradeCmd) @@ -912,6 +921,7 @@ Use "` + string(constants.CLIExtensionPrefix) + ` help all" to show help for all rootCmd.AddCommand(completionCmd) rootCmd.AddCommand(hashCmd) rootCmd.AddCommand(projectCmd) + rootCmd.AddCommand(setupCmd) rootCmd.AddCommand(domainsCmd) rootCmd.AddCommand(experimentsCmd) rootCmd.AddCommand(forecastCmd) diff --git a/pkg/cli/bootstrap.go b/pkg/cli/bootstrap.go new file mode 100644 index 00000000000..186dc36acc4 --- /dev/null +++ b/pkg/cli/bootstrap.go @@ -0,0 +1,406 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/github/gh-aw/pkg/console" + "github.com/github/gh-aw/pkg/workflow" +) + +type BootstrapOptions struct { + Ctx context.Context + Repo string + Dir string + CreateRepo bool + Visibility string + RequireOwnerType string + Yes bool + PlanOnly bool + EngineOverride string + Sources []string + Force bool + NoCompile bool + Verbose bool +} + +type bootstrapPlan struct { + Repo string + Dir string + RepoExists bool + CreateRepo bool + CloneRepo bool + AttachedCheckout bool + InitNeeded bool + InitMissingMarkers []string + ResolvedSources []string + SkippedSources []string + CompileAfterAdd bool + OwnerType string + NeedsMutation bool + PlanLines []string +} + +type bootstrapRuntime struct { + setupRepositoryRuntime + confirmAction func(string, string, string) (bool, error) + initRepo func(InitOptions) error + addWorkflows func(context.Context, []string, AddOptions) (*AddWorkflowsResult, error) + compileWorkflows func(context.Context, CompileConfig) ([]*workflow.WorkflowData, error) +} + +func defaultBootstrapRuntime() bootstrapRuntime { + setupRuntime := defaultSetupRepositoryRuntime() + return bootstrapRuntime{ + setupRepositoryRuntime: setupRuntime, + confirmAction: console.ConfirmAction, + initRepo: InitRepository, + addWorkflows: AddWorkflows, + compileWorkflows: CompileWorkflows, + } +} + +func RunBootstrap(opts BootstrapOptions) error { + originalDir, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to determine current directory: %w", err) + } + return runBootstrapWithRuntime(normalizeBootstrapOptions(opts), defaultBootstrapRuntime(), originalDir) +} + +func runBootstrapWithRuntime(opts BootstrapOptions, runtime bootstrapRuntime, originalDir string) error { + if err := validateBootstrapOptions(opts); err != nil { + return err + } + + ctx := opts.Ctx + if ctx == nil { + ctx = context.Background() + } + + plan, err := buildBootstrapPlan(ctx, opts, runtime, originalDir) + if err != nil { + return err + } + + printBootstrapPlan(plan) + + if opts.PlanOnly { + return nil + } + + if !plan.NeedsMutation { + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Bootstrap already satisfied for %s", opts.Repo))) + return nil + } + + if !opts.Yes { + if IsRunningInCI() { + return errors.New("--yes is required in CI when bootstrap would make changes") + } + confirmed, err := runtime.confirmAction( + fmt.Sprintf("Apply bootstrap changes to %s?", plan.Repo), + "Apply changes", + "Cancel", + ) + if err != nil { + return fmt.Errorf("failed to confirm bootstrap plan: %w", err) + } + if !confirmed { + return errors.New("bootstrap cancelled") + } + } + + if plan.CreateRepo { + if err := runtime.createRepo(ctx, plan.Repo, opts.Visibility); err != nil { + return err + } + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Created %s", plan.Repo))) + } + + if plan.CloneRepo { + if err := runtime.cloneRepo(ctx, plan.Repo, plan.Dir); err != nil { + return err + } + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Cloned %s into %s", plan.Repo, plan.Dir))) + } + + resolvedSources := resolveDeployWorkflowSpecs(opts.Sources, originalDir) + + if err := withWorkingDir(plan.Dir, func() error { + missingMarkers, err := missingBootstrapInitMarkers(".", opts.EngineOverride) + if err != nil { + return err + } + if len(missingMarkers) > 0 { + if err := runtime.initRepo(InitOptions{ + Ctx: ctx, + Verbose: opts.Verbose, + Engine: opts.EngineOverride, + Skill: true, + Agent: true, + MCP: true, + CodespaceRepos: []string{}, + CodespaceEnabled: false, + Completions: false, + CreatePR: false, + }); err != nil { + return fmt.Errorf("failed to initialize repository: %w", err) + } + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("Initialized repository for agentic workflows")) + } + + addedWorkflows := false + if len(resolvedSources) > 0 { + addOpts := AddOptions{ + Verbose: opts.Verbose, + EngineOverride: opts.EngineOverride, + Force: opts.Force, + } + workflowsToAdd, skippedWorkflows, err := excludeExistingSourcedWorkflows(resolvedSources, addOpts) + if err != nil { + return fmt.Errorf("failed to inspect existing workflows: %w", err) + } + if len(skippedWorkflows) > 0 { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Skipping already sourced workflows: %s", strings.Join(skippedWorkflows, ", ")))) + } + if len(workflowsToAdd) > 0 { + if _, err := runtime.addWorkflows(ctx, workflowsToAdd, addOpts); err != nil { + return fmt.Errorf("failed to add workflows: %w", err) + } + addedWorkflows = true + } + } + + if addedWorkflows && !opts.NoCompile { + if _, err := runtime.compileWorkflows(ctx, CompileConfig{ + Verbose: opts.Verbose, + EngineOverride: opts.EngineOverride, + }); err != nil { + return fmt.Errorf("failed to compile workflows: %w", err) + } + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("Compiled workflows")) + } + + return nil + }); err != nil { + return err + } + + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Bootstrap completed for %s", plan.Repo))) + return nil +} + +func normalizeBootstrapOptions(opts BootstrapOptions) BootstrapOptions { + if opts.Visibility == "" { + opts.Visibility = "private" + } + if opts.RequireOwnerType == "" { + opts.RequireOwnerType = "any" + } + return opts +} + +func validateBootstrapOptions(opts BootstrapOptions) error { + if strings.Count(opts.Repo, "/") != 1 { + return errors.New("--repo must use the OWNER/REPO format") + } + + switch opts.Visibility { + case "private", "public", "internal": + default: + return errors.New("--visibility must be one of: private, public, internal") + } + + switch opts.RequireOwnerType { + case "any", "org", "user": + default: + return errors.New("--require-owner-type must be one of: any, org, user") + } + + return nil +} + +func buildBootstrapPlan(ctx context.Context, opts BootstrapOptions, runtime bootstrapRuntime, originalDir string) (*bootstrapPlan, error) { + if err := runtime.checkAuth(ctx); err != nil { + return nil, fmt.Errorf("failed to verify GitHub CLI authentication: %w", err) + } + + plan := &bootstrapPlan{ + Repo: opts.Repo, + Dir: resolveSetupCheckoutDir(opts.Repo, opts.Dir), + ResolvedSources: resolveDeployWorkflowSpecs(opts.Sources, originalDir), + CompileAfterAdd: len(opts.Sources) > 0 && !opts.NoCompile, + } + + owner := strings.Split(opts.Repo, "/")[0] + if opts.RequireOwnerType != "any" { + ownerType, err := runtime.ownerType(ctx, owner) + if err != nil { + return nil, err + } + plan.OwnerType = ownerType + if normalizeSetupOwnerType(ownerType) != opts.RequireOwnerType { + return nil, fmt.Errorf("owner %s is %s, but --require-owner-type=%s was requested", owner, normalizeSetupOwnerType(ownerType), opts.RequireOwnerType) + } + } + + repoExists, err := runtime.repoExists(ctx, opts.Repo) + if err != nil { + return nil, err + } + plan.RepoExists = repoExists + if !repoExists { + if !opts.CreateRepo { + return nil, fmt.Errorf("repository %s does not exist; rerun with --create-repo to create it", opts.Repo) + } + plan.CreateRepo = true + } + + inspection, err := inspectSetupCheckout(plan.Dir, plan.Repo, runtime.dirOriginRepo) + if err != nil { + return nil, err + } + plan.CloneRepo = inspection.cloneNeeded + plan.AttachedCheckout = inspection.attached + + if inspection.attached { + missingMarkers, err := missingBootstrapInitMarkers(plan.Dir, opts.EngineOverride) + if err != nil { + return nil, err + } + plan.InitMissingMarkers = missingMarkers + plan.InitNeeded = len(missingMarkers) > 0 + + if len(plan.ResolvedSources) > 0 { + addOpts := AddOptions{EngineOverride: opts.EngineOverride} + var workflowsToAdd []string + var skippedWorkflows []string + if err := withWorkingDir(plan.Dir, func() error { + var excludeErr error + workflowsToAdd, skippedWorkflows, excludeErr = excludeExistingSourcedWorkflows(plan.ResolvedSources, addOpts) + return excludeErr + }); err != nil { + return nil, err + } + plan.ResolvedSources = workflowsToAdd + plan.SkippedSources = skippedWorkflows + plan.CompileAfterAdd = len(workflowsToAdd) > 0 && !opts.NoCompile + } + } + + plan.PlanLines = buildBootstrapPlanLines(plan, opts) + plan.NeedsMutation = plan.CreateRepo || plan.CloneRepo || plan.InitNeeded || len(plan.ResolvedSources) > 0 + + if plan.AttachedCheckout && plan.NeedsMutation { + if err := withWorkingDir(plan.Dir, func() error { + return runtime.checkCleanWorktree(opts.Verbose) + }); err != nil { + return nil, err + } + } + + return plan, nil +} + +func missingBootstrapInitMarkers(baseDir string, engineOverride string) ([]string, error) { + markers := expectedBootstrapInitMarkers(engineOverride) + missing := make([]string, 0) + for _, marker := range markers { + markerPath := filepath.Join(baseDir, filepath.FromSlash(marker)) + if _, err := os.Stat(markerPath); err != nil { + if errors.Is(err, os.ErrNotExist) { + missing = append(missing, marker) + continue + } + return nil, fmt.Errorf("failed to inspect %s: %w", marker, err) + } + } + return missing, nil +} + +func expectedBootstrapInitMarkers(engineOverride string) []string { + markers := []string{ + ".gitattributes", + ".vscode/settings.json", + } + if engineOverride == "" || engineOverride == "copilot" { + markers = append(markers, + ".github/skills/agentic-workflows/SKILL.md", + ".github/agents/agentic-workflows.md", + ".github/mcp.json", + ".github/workflows/copilot-setup-steps.yml", + ) + } + return markers +} + +func buildBootstrapPlanLines(plan *bootstrapPlan, opts BootstrapOptions) []string { + lines := []string{fmt.Sprintf("Bootstrap plan for %s", plan.Repo)} + + if plan.CreateRepo { + lines = append(lines, fmt.Sprintf("- create remote repository (%s)", opts.Visibility)) + if plan.CloneRepo { + lines = append(lines, fmt.Sprintf("- clone into %s", plan.Dir)) + } + } else if plan.CloneRepo { + lines = append(lines, fmt.Sprintf("- clone existing repository into %s", plan.Dir)) + } else if plan.AttachedCheckout { + lines = append(lines, fmt.Sprintf("- attach existing checkout at %s", plan.Dir)) + } + + if plan.AttachedCheckout { + if plan.InitNeeded { + lines = append(lines, fmt.Sprintf("- initialize repository artifacts (missing: %s)", strings.Join(plan.InitMissingMarkers, ", "))) + } else { + lines = append(lines, "- initialization markers already present") + } + } else { + lines = append(lines, "- inspect init markers after clone") + } + + if len(plan.ResolvedSources) > 0 { + lines = append(lines, fmt.Sprintf("- add %d workflow/package source(s)", len(plan.ResolvedSources))) + if plan.CompileAfterAdd { + lines = append(lines, "- compile workflows after adding sources") + } + } + if len(plan.SkippedSources) > 0 { + lines = append(lines, fmt.Sprintf("- skip already sourced workflows: %s", strings.Join(plan.SkippedSources, ", "))) + } + + if plan.OwnerType != "" { + lines = append(lines, fmt.Sprintf("- verified owner type: %s", normalizeSetupOwnerType(plan.OwnerType))) + } + + if !plan.CreateRepo && !plan.CloneRepo && !plan.InitNeeded && len(plan.ResolvedSources) == 0 { + lines = append(lines, "- no changes required") + } + + return lines +} + +func printBootstrapPlan(plan *bootstrapPlan) { + for _, line := range plan.PlanLines { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(line)) + } + fmt.Fprintln(os.Stderr, "") +} + +func withWorkingDir(dir string, fn func() error) error { + originalDir, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to read current directory: %w", err) + } + if err := os.Chdir(dir); err != nil { + return fmt.Errorf("failed to change directory to %s: %w", dir, err) + } + defer func() { + _ = os.Chdir(originalDir) + }() + return fn() +} diff --git a/pkg/cli/bootstrap_command.go b/pkg/cli/bootstrap_command.go new file mode 100644 index 00000000000..626f0154b1f --- /dev/null +++ b/pkg/cli/bootstrap_command.go @@ -0,0 +1,81 @@ +package cli + +import ( + "fmt" + + "github.com/github/gh-aw/pkg/constants" + "github.com/spf13/cobra" +) + +func NewBootstrapCommand(validateEngine func(string) error) *cobra.Command { + cmd := &cobra.Command{ + Use: "bootstrap [source]...", + Short: "Bootstrap a repository for agentic workflows", + Long: `Bootstrap a repository for agentic workflows by combining repository setup, +checkout attachment or cloning, initialization, and optional workflow or package installation. + +This command is intentionally generic. It handles repository creation and initialization +without assuming any product-specific auth, app registration, or secret layout. + +When you pass one or more sources, bootstrap will add them after initialization and then +compile workflows unless you disable compilation with --no-compile. + +Sources use the same syntax as '` + string(constants.CLIExtensionPrefix) + ` add'.`, + Example: ` ` + string(constants.CLIExtensionPrefix) + ` bootstrap --repo octo-org/platform-ops --create-repo --visibility private + ` + string(constants.CLIExtensionPrefix) + ` bootstrap --repo octo-org/platform-ops github/central-agentic-ops/readiness + ` + string(constants.CLIExtensionPrefix) + ` bootstrap --repo octo-org/platform-ops ./local-workflow.md --yes + ` + string(constants.CLIExtensionPrefix) + ` bootstrap --repo octo-org/platform-ops --plan + ` + string(constants.CLIExtensionPrefix) + ` bootstrap --repo octo-org/platform-ops --engine claude --create-repo`, + RunE: func(cmd *cobra.Command, args []string) error { + engineOverride, _ := cmd.Flags().GetString("engine") + if err := validateEngine(engineOverride); err != nil { + return err + } + + repo, _ := cmd.Flags().GetString("repo") + dir, _ := cmd.Flags().GetString("dir") + createRepo, _ := cmd.Flags().GetBool("create-repo") + visibility, _ := cmd.Flags().GetString("visibility") + requireOwnerType, _ := cmd.Flags().GetString("require-owner-type") + yes, _ := cmd.Flags().GetBool("yes") + planOnly, _ := cmd.Flags().GetBool("plan") + force, _ := cmd.Flags().GetBool("force") + noCompile, _ := cmd.Flags().GetBool("no-compile") + verbose, _ := cmd.Flags().GetBool("verbose") + + if repo == "" { + return fmt.Errorf("--repo is required\n\nRun '%s --help' for usage information", cmd.CommandPath()) + } + + return RunBootstrap(BootstrapOptions{ + Ctx: cmd.Context(), + Repo: repo, + Dir: dir, + CreateRepo: createRepo, + Visibility: visibility, + RequireOwnerType: requireOwnerType, + Yes: yes, + PlanOnly: planOnly, + EngineOverride: engineOverride, + Sources: args, + Force: force, + NoCompile: noCompile, + Verbose: verbose, + }) + }, + } + + cmd.Flags().String("repo", "", "Target repository (OWNER/REPO format)") + cmd.Flags().String("dir", "", "Local checkout directory (defaults to the repository name)") + cmd.Flags().Bool("create-repo", false, "Create the target repository when it does not exist") + cmd.Flags().String("visibility", "private", "Repository visibility for --create-repo: private, public, or internal") + cmd.Flags().String("require-owner-type", "any", "Require the repository owner to be org, user, or any") + cmd.Flags().BoolP("yes", "y", false, "Apply the bootstrap plan without confirmation") + cmd.Flags().Bool("plan", false, "Print the bootstrap plan without making changes") + cmd.Flags().BoolP("force", "f", false, "Allow added workflows to overwrite existing files when the add step runs") + cmd.Flags().Bool("no-compile", false, "Skip workflow compilation after adding sources") + addEngineFlag(cmd) + RegisterEngineFlagCompletion(cmd) + + return cmd +} diff --git a/pkg/cli/bootstrap_integration_test.go b/pkg/cli/bootstrap_integration_test.go new file mode 100644 index 00000000000..f7695ea23e6 --- /dev/null +++ b/pkg/cli/bootstrap_integration_test.go @@ -0,0 +1,136 @@ +//go:build integration + +package cli + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type bootstrapIntegrationSetup struct { + base *integrationTestSetup + repoDir string + repoArg string + fakeBinDir string + argsLog string + pathEnv string +} + +func setupBootstrapIntegrationTest(t *testing.T) *bootstrapIntegrationSetup { + t.Helper() + + base := setupIntegrationTest(t) + repoDir := filepath.Join(base.tempDir, "repo") + require.NoError(t, os.MkdirAll(repoDir, 0o755)) + + gitInitCmd := exec.Command("git", "init") + gitInitCmd.Dir = repoDir + output, err := gitInitCmd.CombinedOutput() + require.NoError(t, err, "Failed to run git init: %s", string(output)) + + gitNameCmd := exec.Command("git", "config", "user.name", "Bootstrap Test") + gitNameCmd.Dir = repoDir + output, err = gitNameCmd.CombinedOutput() + require.NoError(t, err, "Failed to set git user.name: %s", string(output)) + + gitEmailCmd := exec.Command("git", "config", "user.email", "bootstrap@example.com") + gitEmailCmd.Dir = repoDir + output, err = gitEmailCmd.CombinedOutput() + require.NoError(t, err, "Failed to set git user.email: %s", string(output)) + + gitRemoteCmd := exec.Command("git", "remote", "add", "origin", "https://github.com/octo/platform-ops.git") + gitRemoteCmd.Dir = repoDir + output, err = gitRemoteCmd.CombinedOutput() + require.NoError(t, err, "Failed to add git remote: %s", string(output)) + + fakeBinDir := filepath.Join(base.tempDir, "fake-bin") + require.NoError(t, os.MkdirAll(fakeBinDir, 0o755)) + argsLog := filepath.Join(base.tempDir, "gh-args.log") + fakeGH := filepath.Join(fakeBinDir, "gh") + fakeGHScript := "#!/bin/sh\n" + + "printf '%s\\n' \"$*\" >> \"" + argsLog + "\"\n" + + "if [ \"$1\" = \"auth\" ] && [ \"$2\" = \"status\" ]; then\n" + + " exit 0\n" + + "fi\n" + + "if [ \"$1\" = \"repo\" ] && [ \"$2\" = \"view\" ]; then\n" + + " printf '%s\\n' 'octo/platform-ops'\n" + + " exit 0\n" + + "fi\n" + + "printf 'unexpected gh invocation: %s\\n' \"$*\" >&2\n" + + "exit 1\n" + require.NoError(t, os.WriteFile(fakeGH, []byte(fakeGHScript), 0o755)) + + return &bootstrapIntegrationSetup{ + base: base, + repoDir: repoDir, + repoArg: "repo", + fakeBinDir: fakeBinDir, + argsLog: argsLog, + pathEnv: fakeBinDir + string(os.PathListSeparator) + os.Getenv("PATH"), + } +} + +func TestBootstrapCommandPlanIntegration(t *testing.T) { + setup := setupBootstrapIntegrationTest(t) + defer setup.base.cleanup() + + cmd := exec.Command(setup.base.binaryPath, "bootstrap", "--repo", "octo/platform-ops", "--dir", setup.repoArg, "--plan") + cmd.Dir = setup.base.tempDir + cmd.Env = append(os.Environ(), "PATH="+setup.pathEnv) + output, err := cmd.CombinedOutput() + outputStr := string(output) + + require.NoError(t, err, "bootstrap --plan should succeed: %s", outputStr) + assert.Contains(t, outputStr, "Bootstrap plan for octo/platform-ops") + assert.Contains(t, outputStr, "attach existing checkout at repo") + assert.Contains(t, outputStr, "initialize repository artifacts") + assert.NoFileExists(t, filepath.Join(setup.repoDir, ".gitattributes")) + + argsLog, err := os.ReadFile(setup.argsLog) + require.NoError(t, err) + assert.Contains(t, string(argsLog), "auth status") + assert.Contains(t, string(argsLog), "repo view octo/platform-ops --json nameWithOwner --jq .nameWithOwner") + assert.NotContains(t, string(argsLog), "repo clone") +} + +func TestBootstrapCommandInitAndRerunIntegration(t *testing.T) { + setup := setupBootstrapIntegrationTest(t) + defer setup.base.cleanup() + + runBootstrap := func(label string) string { + cmd := exec.Command(setup.base.binaryPath, "bootstrap", "--repo", "octo/platform-ops", "--dir", setup.repoArg, "--yes") + cmd.Dir = setup.base.tempDir + cmd.Env = append(os.Environ(), "PATH="+setup.pathEnv) + output, err := cmd.CombinedOutput() + outputStr := string(output) + require.NoError(t, err, "%s should succeed: %s", label, outputStr) + return outputStr + } + + firstOutput := runBootstrap("first bootstrap run") + assert.Contains(t, firstOutput, "Initialized repository for agentic workflows") + assert.Contains(t, firstOutput, "Bootstrap completed for octo/platform-ops") + + assert.FileExists(t, filepath.Join(setup.repoDir, ".gitattributes")) + assert.FileExists(t, filepath.Join(setup.repoDir, ".vscode", "settings.json")) + assert.FileExists(t, filepath.Join(setup.repoDir, ".github", "skills", "agentic-workflows", "SKILL.md")) + assert.FileExists(t, filepath.Join(setup.repoDir, ".github", "agents", "agentic-workflows.md")) + assert.FileExists(t, filepath.Join(setup.repoDir, ".github", "mcp.json")) + assert.FileExists(t, filepath.Join(setup.repoDir, ".github", "workflows", "copilot-setup-steps.yml")) + + secondOutput := runBootstrap("second bootstrap run") + assert.Contains(t, secondOutput, "Bootstrap already satisfied for octo/platform-ops") + assert.NotContains(t, secondOutput, "Initialized repository for agentic workflows") + + argsLog, err := os.ReadFile(setup.argsLog) + require.NoError(t, err) + assert.NotContains(t, string(argsLog), "repo clone") + assert.GreaterOrEqual(t, strings.Count(string(argsLog), "auth status"), 2) + assert.GreaterOrEqual(t, strings.Count(string(argsLog), "repo view octo/platform-ops --json nameWithOwner --jq .nameWithOwner"), 2) +} diff --git a/pkg/cli/bootstrap_test.go b/pkg/cli/bootstrap_test.go new file mode 100644 index 00000000000..d973b134b8e --- /dev/null +++ b/pkg/cli/bootstrap_test.go @@ -0,0 +1,337 @@ +//go:build !integration + +package cli + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/github/gh-aw/pkg/testutil" + "github.com/github/gh-aw/pkg/workflow" +) + +func TestNewBootstrapCommand(t *testing.T) { + cmd := NewBootstrapCommand(func(string) error { return nil }) + if cmd == nil { + t.Fatal("NewBootstrapCommand returned nil") + } + if cmd.Use != "bootstrap [source]..." { + t.Fatalf("unexpected use: %s", cmd.Use) + } + if cmd.Flags().Lookup("repo") == nil { + t.Fatal("expected --repo flag") + } + if cmd.Flags().Lookup("create-repo") == nil { + t.Fatal("expected --create-repo flag") + } + if cmd.Flags().Lookup("visibility") == nil { + t.Fatal("expected --visibility flag") + } + if cmd.Flags().Lookup("plan") == nil { + t.Fatal("expected --plan flag") + } + if cmd.Flags().Lookup("no-compile") == nil { + t.Fatal("expected --no-compile flag") + } + if cmd.Flags().Lookup("engine") == nil { + t.Fatal("expected --engine flag") + } + if cmd.Flags().Lookup("visibility").DefValue != "private" { + t.Fatalf("unexpected visibility default: %s", cmd.Flags().Lookup("visibility").DefValue) + } + if cmd.Flags().Lookup("require-owner-type").DefValue != "any" { + t.Fatalf("unexpected require-owner-type default: %s", cmd.Flags().Lookup("require-owner-type").DefValue) + } + if cmd.GroupID != "" { + t.Fatalf("group should be assigned by main, got %q", cmd.GroupID) + } +} + +func TestNewBootstrapCommand_RequiresRepoFlagOnExecute(t *testing.T) { + cmd := NewBootstrapCommand(func(string) error { return nil }) + cmd.SetArgs([]string{}) + err := cmd.Execute() + if err == nil { + t.Fatal("expected missing --repo error") + } + if err.Error() != "--repo is required\n\nRun 'bootstrap --help' for usage information" { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestBuildBootstrapPlan_AttachedCheckoutNeedsInit(t *testing.T) { + repoDir := initBootstrapGitRepo(t) + plan, err := buildBootstrapPlan(context.Background(), normalizeBootstrapOptions(BootstrapOptions{ + Repo: "octo/platform-ops", + Dir: repoDir, + Visibility: "private", + RequireOwnerType: "any", + }), bootstrapRuntime{ + setupRepositoryRuntime: setupRepositoryRuntime{ + checkAuth: func(context.Context) error { return nil }, + repoExists: func(context.Context, string) (bool, error) { return true, nil }, + dirOriginRepo: func(string) (string, error) { return "octo/platform-ops", nil }, + checkCleanWorktree: func(bool) error { return nil }, + }, + }, repoDir) + if err != nil { + t.Fatalf("buildBootstrapPlan returned error: %v", err) + } + if !plan.AttachedCheckout { + t.Fatal("expected attached checkout") + } + if plan.CloneRepo { + t.Fatal("did not expect clone plan") + } + if !plan.InitNeeded { + t.Fatal("expected init to be needed") + } + if len(plan.InitMissingMarkers) == 0 { + t.Fatal("expected missing init markers") + } +} + +func TestBuildBootstrapPlan_EnforcesOwnerTypeRequirement(t *testing.T) { + repoDir := initBootstrapGitRepo(t) + _, err := buildBootstrapPlan(context.Background(), normalizeBootstrapOptions(BootstrapOptions{ + Repo: "octo/platform-ops", + Dir: repoDir, + RequireOwnerType: "user", + }), bootstrapRuntime{ + setupRepositoryRuntime: setupRepositoryRuntime{ + checkAuth: func(context.Context) error { return nil }, + repoExists: func(context.Context, string) (bool, error) { return true, nil }, + ownerType: func(context.Context, string) (string, error) { return "Organization", nil }, + dirOriginRepo: func(string) (string, error) { return "octo/platform-ops", nil }, + checkCleanWorktree: func(bool) error { return nil }, + }, + }, repoDir) + if err == nil { + t.Fatal("expected owner type mismatch error") + } + if err.Error() != "owner octo is org, but --require-owner-type=user was requested" { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestRunBootstrapWithRuntime_CreateCloneInitAddCompile(t *testing.T) { + tempDir := testutil.TempDir(t, "bootstrap-*") + checkoutDir := filepath.Join(tempDir, "platform-ops") + + createCalls := 0 + cloneCalls := 0 + initCalls := 0 + addCalls := 0 + compileCalls := 0 + + err := runBootstrapWithRuntime(normalizeBootstrapOptions(BootstrapOptions{ + Ctx: context.Background(), + Repo: "octo/platform-ops", + Dir: checkoutDir, + CreateRepo: true, + Yes: true, + Sources: []string{"github/central-agentic-ops/readiness"}, + }), bootstrapRuntime{ + setupRepositoryRuntime: setupRepositoryRuntime{ + checkAuth: func(context.Context) error { return nil }, + repoExists: func(context.Context, string) (bool, error) { return false, nil }, + createRepo: func(context.Context, string, string) error { + createCalls++ + return nil + }, + cloneRepo: func(_ context.Context, _ string, dir string) error { + cloneCalls++ + return os.MkdirAll(dir, 0o755) + }, + checkCleanWorktree: func(bool) error { return nil }, + }, + confirmAction: func(string, string, string) (bool, error) { return false, nil }, + initRepo: func(InitOptions) error { initCalls++; return nil }, + addWorkflows: func(context.Context, []string, AddOptions) (*AddWorkflowsResult, error) { + addCalls++ + return &AddWorkflowsResult{}, nil + }, + compileWorkflows: func(context.Context, CompileConfig) ([]*workflow.WorkflowData, error) { + compileCalls++ + return nil, nil + }, + }, tempDir) + if err != nil { + t.Fatalf("runBootstrapWithRuntime returned error: %v", err) + } + if createCalls != 1 { + t.Fatalf("expected 1 create call, got %d", createCalls) + } + if cloneCalls != 1 { + t.Fatalf("expected 1 clone call, got %d", cloneCalls) + } + if initCalls != 1 { + t.Fatalf("expected 1 init call, got %d", initCalls) + } + if addCalls != 1 { + t.Fatalf("expected 1 add call, got %d", addCalls) + } + if compileCalls != 1 { + t.Fatalf("expected 1 compile call, got %d", compileCalls) + } +} + +func TestRunBootstrapWithRuntime_RequiresYesInCIWhenMutationPending(t *testing.T) { + repoDir := initBootstrapGitRepo(t) + t.Setenv("CI", "true") + + confirmCalls := 0 + err := runBootstrapWithRuntime(normalizeBootstrapOptions(BootstrapOptions{ + Ctx: context.Background(), + Repo: "octo/platform-ops", + Dir: repoDir, + }), bootstrapRuntime{ + setupRepositoryRuntime: setupRepositoryRuntime{ + checkAuth: func(context.Context) error { return nil }, + repoExists: func(context.Context, string) (bool, error) { return true, nil }, + dirOriginRepo: func(string) (string, error) { return "octo/platform-ops", nil }, + checkCleanWorktree: func(bool) error { return nil }, + }, + confirmAction: func(string, string, string) (bool, error) { + confirmCalls++ + return true, nil + }, + }, repoDir) + if err == nil { + t.Fatal("expected CI confirmation error") + } + if err.Error() != "--yes is required in CI when bootstrap would make changes" { + t.Fatalf("unexpected error: %v", err) + } + if confirmCalls != 0 { + t.Fatalf("confirmAction should not be called in CI, got %d calls", confirmCalls) + } +} + +func TestRunBootstrapWithRuntime_PropagatesCleanWorktreeError(t *testing.T) { + repoDir := initBootstrapGitRepo(t) + wantErr := errors.New("working directory has uncommitted changes, please commit or stash them first") + + err := runBootstrapWithRuntime(normalizeBootstrapOptions(BootstrapOptions{ + Ctx: context.Background(), + Repo: "octo/platform-ops", + Dir: repoDir, + }), bootstrapRuntime{ + setupRepositoryRuntime: setupRepositoryRuntime{ + checkAuth: func(context.Context) error { return nil }, + repoExists: func(context.Context, string) (bool, error) { return true, nil }, + dirOriginRepo: func(string) (string, error) { return "octo/platform-ops", nil }, + checkCleanWorktree: func(bool) error { + return wantErr + }, + }, + }, repoDir) + if !errors.Is(err, wantErr) { + t.Fatalf("expected clean worktree error, got %v", err) + } +} + +func TestRunBootstrapWithRuntime_SkipsExistingSourcedWorkflow(t *testing.T) { + repoDir := initBootstrapGitRepo(t) + writeBootstrapMarkers(t, repoDir, "") + workflowPath := filepath.Join(repoDir, ".github", "workflows", "readiness.md") + if err := os.MkdirAll(filepath.Dir(workflowPath), 0o755); err != nil { + t.Fatalf("failed to create workflow dir: %v", err) + } + content := "---\nsource: github/central-agentic-ops/readiness@main\n---\n\n# Readiness\n" + if err := os.WriteFile(workflowPath, []byte(content), 0o644); err != nil { + t.Fatalf("failed to write workflow: %v", err) + } + + plan, err := buildBootstrapPlan(context.Background(), normalizeBootstrapOptions(BootstrapOptions{ + Repo: "octo/platform-ops", + Dir: repoDir, + Sources: []string{"github/central-agentic-ops/readiness"}, + }), bootstrapRuntime{ + setupRepositoryRuntime: setupRepositoryRuntime{ + checkAuth: func(context.Context) error { return nil }, + repoExists: func(context.Context, string) (bool, error) { return true, nil }, + dirOriginRepo: func(string) (string, error) { return "octo/platform-ops", nil }, + checkCleanWorktree: func(bool) error { return nil }, + }, + }, repoDir) + if err != nil { + t.Fatalf("buildBootstrapPlan returned error: %v", err) + } + if plan.NeedsMutation { + t.Fatal("expected no-op bootstrap plan when sourced workflow is already present") + } + if len(plan.ResolvedSources) != 0 { + t.Fatalf("expected no pending sources, got %d", len(plan.ResolvedSources)) + } + if len(plan.SkippedSources) != 1 || plan.SkippedSources[0] != "readiness" { + t.Fatalf("expected skipped readiness workflow, got %#v", plan.SkippedSources) + } + + initCalls := 0 + addCalls := 0 + compileCalls := 0 + + err = runBootstrapWithRuntime(normalizeBootstrapOptions(BootstrapOptions{ + Ctx: context.Background(), + Repo: "octo/platform-ops", + Dir: repoDir, + Yes: true, + Sources: []string{"github/central-agentic-ops/readiness"}, + }), bootstrapRuntime{ + setupRepositoryRuntime: setupRepositoryRuntime{ + checkAuth: func(context.Context) error { return nil }, + repoExists: func(context.Context, string) (bool, error) { return true, nil }, + dirOriginRepo: func(string) (string, error) { return "octo/platform-ops", nil }, + checkCleanWorktree: func(bool) error { return nil }, + }, + initRepo: func(InitOptions) error { initCalls++; return nil }, + addWorkflows: func(context.Context, []string, AddOptions) (*AddWorkflowsResult, error) { + addCalls++ + return &AddWorkflowsResult{}, nil + }, + compileWorkflows: func(context.Context, CompileConfig) ([]*workflow.WorkflowData, error) { + compileCalls++ + return nil, nil + }, + }, repoDir) + if err != nil { + t.Fatalf("runBootstrapWithRuntime returned error: %v", err) + } + if initCalls != 0 { + t.Fatalf("expected init to be skipped, got %d calls", initCalls) + } + if addCalls != 0 { + t.Fatalf("expected add to be skipped, got %d calls", addCalls) + } + if compileCalls != 0 { + t.Fatalf("expected compile to be skipped, got %d calls", compileCalls) + } +} + +func initBootstrapGitRepo(t *testing.T) string { + t.Helper() + repoDir := testutil.TempDir(t, "bootstrap-repo-*") + cmd := exec.Command("git", "init", repoDir) + if output, err := cmd.CombinedOutput(); err != nil { + t.Skipf("git not available: %v (%s)", err, output) + } + return repoDir +} + +func writeBootstrapMarkers(t *testing.T, repoDir string, engineOverride string) { + t.Helper() + for _, marker := range expectedBootstrapInitMarkers(engineOverride) { + path := filepath.Join(repoDir, filepath.FromSlash(marker)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("failed to create marker dir for %s: %v", marker, err) + } + if err := os.WriteFile(path, []byte("ok\n"), 0o644); err != nil { + t.Fatalf("failed to create marker %s: %v", marker, err) + } + } +} diff --git a/pkg/cli/setup_command.go b/pkg/cli/setup_command.go new file mode 100644 index 00000000000..89195b328e5 --- /dev/null +++ b/pkg/cli/setup_command.go @@ -0,0 +1,92 @@ +package cli + +import ( + "github.com/spf13/cobra" +) + +func NewSetupCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "setup", + Short: "Run reusable auth and repository setup checks", + Long: `Run reusable auth and repository setup checks. + +This command exposes the shared setup primitives that bootstrap and future setup +flows can reuse, without forcing a full bootstrap run. + +Available subcommands: + - auth - Verify GitHub CLI authentication + - repo - Check repository existence, owner type, and checkout state`, + Example: ` gh aw setup auth + gh aw setup repo --repo github/gh-aw + gh aw setup repo --repo github/gh-aw --json + gh aw setup repo --repo github/gh-aw --dir ../gh-aw --require-owner-type org`, + RunE: func(cmd *cobra.Command, args []string) error { + return cmd.Help() + }, + } + + cmd.AddCommand(newSetupAuthSubcommand()) + cmd.AddCommand(newSetupRepoSubcommand()) + return cmd +} + +func newSetupAuthSubcommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "auth", + Short: "Verify GitHub CLI authentication", + Long: `Verify that GitHub CLI authentication is available for follow-on setup tasks. + +This reuses the same authentication preflight used by bootstrap and other +setup-oriented commands.`, + Example: ` gh aw setup auth + gh aw setup auth --json`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + jsonOutput, _ := cmd.Flags().GetBool("json") + return RunSetupAuth(SetupAuthOptions{Ctx: cmd.Context(), JSON: jsonOutput}) + }, + } + addJSONFlag(cmd) + return cmd +} + +func newSetupRepoSubcommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "repo", + Short: "Check repository and checkout setup state", + Long: `Check repository and checkout setup state. + +This command verifies GitHub CLI authentication, confirms that the target +repository exists, resolves the owner type, and inspects whether the target +directory is already attached to the expected checkout or is ready for clone.`, + Example: ` gh aw setup repo --repo github/gh-aw + gh aw setup repo --repo github/gh-aw --json + gh aw setup repo --repo github/gh-aw --dir ../gh-aw + gh aw setup repo --repo github/gh-aw --require-owner-type org`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + repo, _ := cmd.Flags().GetString("repo") + dir, _ := cmd.Flags().GetString("dir") + requireOwnerType, _ := cmd.Flags().GetString("require-owner-type") + verbose, _ := cmd.Flags().GetBool("verbose") + jsonOutput, _ := cmd.Flags().GetBool("json") + + return RunSetupRepositoryCheck(SetupRepositoryCheckOptions{ + Ctx: cmd.Context(), + Repo: repo, + Dir: dir, + RequireOwnerType: requireOwnerType, + Verbose: verbose, + JSON: jsonOutput, + }) + }, + } + + cmd.Flags().StringP("repo", "r", "", "Target repository in owner/repo format") + cmd.Flags().StringP("dir", "d", "", "Checkout directory to inspect (defaults to the repo name)") + cmd.Flags().String("require-owner-type", "any", "Require a specific owner type: any, org, or user") + addJSONFlag(cmd) + _ = cmd.MarkFlagRequired("repo") + + return cmd +} diff --git a/pkg/cli/setup_command_test.go b/pkg/cli/setup_command_test.go new file mode 100644 index 00000000000..ae203d7bf00 --- /dev/null +++ b/pkg/cli/setup_command_test.go @@ -0,0 +1,283 @@ +//go:build !integration + +package cli + +import ( + "context" + "encoding/json" + "errors" + "io" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewSetupCommand(t *testing.T) { + cmd := NewSetupCommand() + + require.NotNil(t, cmd) + assert.Equal(t, "setup", cmd.Use) + assert.Equal(t, "Run reusable auth and repository setup checks", cmd.Short) + assert.Contains(t, cmd.Long, "Available subcommands:") + assert.Contains(t, cmd.Example, "gh aw setup repo --repo github/gh-aw --json") + assert.Contains(t, cmd.Long, "- auth") + assert.Contains(t, cmd.Long, "- repo") + assert.True(t, cmd.HasSubCommands()) + + var hasAuth, hasRepo bool + for _, subcmd := range cmd.Commands() { + if subcmd.Name() == "auth" { + hasAuth = true + assert.NotNil(t, subcmd.Flags().Lookup("json"), "auth subcommand should expose --json") + } + if subcmd.Name() == "repo" { + hasRepo = true + assert.NotNil(t, subcmd.Flags().Lookup("json"), "repo subcommand should expose --json") + } + } + assert.True(t, hasAuth, "should have auth subcommand") + assert.True(t, hasRepo, "should have repo subcommand") +} + +func TestSetupSubcommandsAdvertiseJSONExamples(t *testing.T) { + authCmd := newSetupAuthSubcommand() + repoCmd := newSetupRepoSubcommand() + + assert.Contains(t, authCmd.Example, "gh aw setup auth --json") + assert.Contains(t, authCmd.Long, "setup-oriented commands.") + assert.Contains(t, repoCmd.Example, "gh aw setup repo --repo github/gh-aw --json") + assert.NotContains(t, repoCmd.Example, "\t") +} + +func TestNewSetupCommandHelp(t *testing.T) { + cmd := NewSetupCommand() + err := cmd.RunE(cmd, []string{}) + assert.NoError(t, err) +} + +func TestSetupCommandUnknownSubcommandReturnsError(t *testing.T) { + cmd := NewSetupCommand() + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SilenceUsage = true + cmd.SilenceErrors = true + cmd.SetArgs([]string{"unknown-cmd"}) + + err := cmd.Execute() + require.Error(t, err) + assert.Equal(t, `unknown command "unknown-cmd" for "setup"`, err.Error()) +} + +func TestNewSetupRepoSubcommandRequiresRepoFlagOnExecute(t *testing.T) { + cmd := newSetupRepoSubcommand() + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SilenceUsage = true + cmd.SilenceErrors = true + + err := cmd.Execute() + require.Error(t, err) + assert.Equal(t, "required flag(s) \"repo\" not set", err.Error()) +} + +func TestRunSetupAuthWithRuntime(t *testing.T) { + called := 0 + err := runSetupAuthWithRuntime(SetupAuthOptions{Ctx: context.Background()}, setupRepositoryRuntime{ + checkAuth: func(context.Context) error { + called++ + return nil + }, + }) + require.NoError(t, err) + assert.Equal(t, 1, called) +} + +func TestRunSetupAuthWithRuntime_JSONOutput(t *testing.T) { + output := captureSetupStdout(t, func() error { + return runSetupAuthWithRuntime(SetupAuthOptions{Ctx: context.Background(), JSON: true}, setupRepositoryRuntime{ + checkAuth: func(context.Context) error { return nil }, + }) + }) + + var result SetupAuthResult + require.NoError(t, json.Unmarshal([]byte(output), &result)) + assert.True(t, result.Authenticated) +} + +func TestRunSetupRepositoryCheck_AttachedCheckout(t *testing.T) { + repoDir := initBootstrapGitRepo(t) + err := runSetupRepositoryCheckWithRuntime(normalizeSetupRepositoryCheckOptions(SetupRepositoryCheckOptions{ + Ctx: context.Background(), + Repo: "octo/platform-ops", + Dir: repoDir, + }), setupRepositoryRuntime{ + checkAuth: func(context.Context) error { return nil }, + ownerType: func(context.Context, string) (string, error) { return "Organization", nil }, + repoExists: func(context.Context, string) (bool, error) { return true, nil }, + dirOriginRepo: func(string) (string, error) { return "octo/platform-ops", nil }, + checkCleanWorktree: func(bool) error { return nil }, + }) + require.NoError(t, err) +} + +func TestRunSetupRepositoryCheck_JSONOutput(t *testing.T) { + repoDir := initBootstrapGitRepo(t) + output := captureSetupStdout(t, func() error { + return runSetupRepositoryCheckWithRuntime(normalizeSetupRepositoryCheckOptions(SetupRepositoryCheckOptions{ + Ctx: context.Background(), + Repo: "octo/platform-ops", + Dir: repoDir, + RequireOwnerType: "org", + JSON: true, + }), setupRepositoryRuntime{ + checkAuth: func(context.Context) error { return nil }, + ownerType: func(context.Context, string) (string, error) { return "Organization", nil }, + repoExists: func(context.Context, string) (bool, error) { return true, nil }, + dirOriginRepo: func(string) (string, error) { return "octo/platform-ops", nil }, + checkCleanWorktree: func(bool) error { return nil }, + }) + }) + + var result SetupRepositoryCheckResult + require.NoError(t, json.Unmarshal([]byte(output), &result)) + assert.Equal(t, "octo/platform-ops", result.Repository) + assert.Equal(t, repoDir, result.Directory) + assert.True(t, result.Authenticated) + assert.True(t, result.RepositoryExists) + assert.Equal(t, "org", result.OwnerType) + assert.Equal(t, "org", result.RequiredOwnerType) + assert.True(t, result.CheckoutAttached) + assert.False(t, result.CloneNeeded) + require.NotNil(t, result.CleanWorktree) + assert.True(t, *result.CleanWorktree) +} + +func TestRunSetupRepositoryCheck_EnforcesOwnerTypeRequirement(t *testing.T) { + repoDir := initBootstrapGitRepo(t) + err := runSetupRepositoryCheckWithRuntime(normalizeSetupRepositoryCheckOptions(SetupRepositoryCheckOptions{ + Ctx: context.Background(), + Repo: "octo/platform-ops", + Dir: repoDir, + RequireOwnerType: "user", + }), setupRepositoryRuntime{ + checkAuth: func(context.Context) error { return nil }, + ownerType: func(context.Context, string) (string, error) { return "Organization", nil }, + repoExists: func(context.Context, string) (bool, error) { return true, nil }, + dirOriginRepo: func(string) (string, error) { return "octo/platform-ops", nil }, + checkCleanWorktree: func(bool) error { return nil }, + }) + require.Error(t, err) + assert.Equal(t, "owner octo is org, but --require-owner-type=user was requested", err.Error()) +} + +func TestRunSetupRepositoryCheck_RequiresExistingRepository(t *testing.T) { + err := runSetupRepositoryCheckWithRuntime(normalizeSetupRepositoryCheckOptions(SetupRepositoryCheckOptions{ + Ctx: context.Background(), + Repo: "octo/platform-ops", + }), setupRepositoryRuntime{ + checkAuth: func(context.Context) error { return nil }, + ownerType: func(context.Context, string) (string, error) { return "Organization", nil }, + repoExists: func(context.Context, string) (bool, error) { return false, nil }, + }) + require.Error(t, err) + assert.Equal(t, "repository octo/platform-ops does not exist", err.Error()) +} + +func TestRunSetupRepositoryCheck_PropagatesCleanWorktreeError(t *testing.T) { + repoDir := initBootstrapGitRepo(t) + wantErr := errors.New("working directory has uncommitted changes, please commit or stash them first") + + err := runSetupRepositoryCheckWithRuntime(normalizeSetupRepositoryCheckOptions(SetupRepositoryCheckOptions{ + Ctx: context.Background(), + Repo: "octo/platform-ops", + Dir: repoDir, + }), setupRepositoryRuntime{ + checkAuth: func(context.Context) error { return nil }, + ownerType: func(context.Context, string) (string, error) { + return "Organization", nil + }, + repoExists: func(context.Context, string) (bool, error) { return true, nil }, + dirOriginRepo: func(string) (string, error) { return "octo/platform-ops", nil }, + checkCleanWorktree: func(bool) error { + return wantErr + }, + }) + require.Error(t, err) + assert.ErrorIs(t, err, wantErr) +} + +func TestSetupCommandSubcommandListingsUseHyphenBullets(t *testing.T) { + tests := []struct { + name string + longDoc string + }{ + {name: "setup", longDoc: NewSetupCommand().Long}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Contains(t, tt.longDoc, "Available subcommands:") + assert.NotContains(t, tt.longDoc, " • ") + }) + } +} + +func TestSetupRepoSubcommandUsesNoArgs(t *testing.T) { + cmd := newSetupRepoSubcommand() + require.NotNil(t, cmd.Args) + assert.NoError(t, cmd.Args(cmd, []string{})) + assert.Error(t, cmd.Args(cmd, []string{"extra"})) +} + +func TestSetupAuthSubcommandUsesNoArgs(t *testing.T) { + cmd := newSetupAuthSubcommand() + require.NotNil(t, cmd.Args) + assert.NoError(t, cmd.Args(cmd, []string{})) + assert.Error(t, cmd.Args(cmd, []string{"extra"})) +} + +func TestSetupCommandStructure(t *testing.T) { + tests := []struct { + name string + expectedUse string + commandCreator func() any + }{ + { + name: "setup command exists", + expectedUse: "setup", + commandCreator: func() any { + return NewSetupCommand() + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := tt.commandCreator() + require.NotNil(t, cmd) + }) + } +} + +func captureSetupStdout(t *testing.T, fn func() error) string { + t.Helper() + oldStdout := os.Stdout + r, w, err := os.Pipe() + require.NoError(t, err) + os.Stdout = w + + runErr := fn() + require.NoError(t, runErr) + + require.NoError(t, w.Close()) + os.Stdout = oldStdout + t.Cleanup(func() { + os.Stdout = oldStdout + }) + + data, err := io.ReadAll(r) + require.NoError(t, err) + return string(data) +} diff --git a/pkg/cli/setup_repository.go b/pkg/cli/setup_repository.go new file mode 100644 index 00000000000..f88bd796e5e --- /dev/null +++ b/pkg/cli/setup_repository.go @@ -0,0 +1,337 @@ +package cli + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/github/gh-aw/pkg/console" + "github.com/github/gh-aw/pkg/gitutil" + "github.com/github/gh-aw/pkg/workflow" +) + +type SetupAuthOptions struct { + Ctx context.Context + JSON bool +} + +type SetupRepositoryCheckOptions struct { + Ctx context.Context + Repo string + Dir string + RequireOwnerType string + Verbose bool + JSON bool +} + +type SetupAuthResult struct { + Authenticated bool `json:"authenticated"` +} + +type SetupRepositoryCheckResult struct { + Repository string `json:"repository"` + Directory string `json:"directory"` + Authenticated bool `json:"authenticated"` + RepositoryExists bool `json:"repository_exists"` + OwnerType string `json:"owner_type"` + RequiredOwnerType string `json:"required_owner_type"` + CheckoutAttached bool `json:"checkout_attached"` + CloneNeeded bool `json:"clone_needed"` + CleanWorktree *bool `json:"clean_worktree,omitempty"` +} + +// setupRepositoryRuntime holds the reusable repository/auth setup primitives that +// higher-level setup flows can compose. bootstrap is the first consumer, but the +// helpers are intentionally generic so future auth/setup commands can reuse them. +type setupRepositoryRuntime struct { + checkAuth func(context.Context) error + repoExists func(context.Context, string) (bool, error) + ownerType func(context.Context, string) (string, error) + createRepo func(context.Context, string, string) error + cloneRepo func(context.Context, string, string) error + dirOriginRepo func(string) (string, error) + checkCleanWorktree func(bool) error +} + +func defaultSetupRepositoryRuntime() setupRepositoryRuntime { + return setupRepositoryRuntime{ + checkAuth: func(context.Context) error { + return checkGHAuthStatusShared(false) + }, + repoExists: checkSetupRepositoryExists, + ownerType: checkSetupRepositoryOwnerType, + createRepo: createSetupRepository, + cloneRepo: cloneSetupRepository, + dirOriginRepo: func(dir string) (string, error) { + remoteURL, _, err := resolveRemoteURL(dir) + if err != nil { + return "", err + } + repo := parseGitHubRepoSlugFromURL(remoteURL) + if repo == "" { + return "", fmt.Errorf("remote URL for %s does not point to a GitHub repository", dir) + } + return repo, nil + }, + checkCleanWorktree: checkCleanWorkingDirectory, + } +} + +func checkSetupRepositoryExists(ctx context.Context, repo string) (bool, error) { + output, err := workflow.RunGHCombinedContext(ctx, "Checking repository...", "repo", "view", repo, "--json", "nameWithOwner", "--jq", ".nameWithOwner") + if err == nil { + return strings.TrimSpace(string(output)) != "", nil + } + + message := strings.ToLower(string(output)) + if strings.Contains(message, "could not resolve to a repository") || strings.Contains(message, "http 404") || strings.Contains(message, "not found") { + return false, nil + } + return false, fmt.Errorf("failed to check repository %s: %w", repo, err) +} + +func checkSetupRepositoryOwnerType(ctx context.Context, owner string) (string, error) { + output, err := workflow.RunGHContext(ctx, "Checking owner type...", "api", "users/"+owner, "--jq", ".type") + if err != nil { + return "", fmt.Errorf("failed to check owner type for %s: %w", owner, err) + } + return strings.TrimSpace(string(output)), nil +} + +func createSetupRepository(ctx context.Context, repo string, visibility string) error { + output, err := workflow.RunGHCombinedContext(ctx, "Creating repository...", "repo", "create", repo, "--"+visibility, "--clone=false", "--confirm") + if err != nil { + trimmed := strings.TrimSpace(string(output)) + if trimmed == "" { + return fmt.Errorf("failed to create repository %s: %w", repo, err) + } + return fmt.Errorf("failed to create repository %s: %w: %s", repo, err, trimmed) + } + return nil +} + +func cloneSetupRepository(ctx context.Context, repo string, dir string) error { + cmd := workflow.ExecGHContext(ctx, "repo", "clone", repo, dir) + output, err := cmd.CombinedOutput() + if err != nil { + trimmed := strings.TrimSpace(string(output)) + if trimmed == "" { + return fmt.Errorf("failed to clone repository %s into %s: %w", repo, dir, err) + } + return fmt.Errorf("failed to clone repository %s into %s: %w: %s", repo, dir, err, trimmed) + } + return nil +} + +type setupCheckoutInspection struct { + attached bool + cloneNeeded bool +} + +func inspectSetupCheckout(dir string, repo string, originRepoLookup func(string) (string, error)) (*setupCheckoutInspection, error) { + info, err := os.Stat(dir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return &setupCheckoutInspection{cloneNeeded: true}, nil + } + return nil, fmt.Errorf("failed to inspect %s: %w", dir, err) + } + if !info.IsDir() { + return nil, fmt.Errorf("target path %s exists but is not a directory", dir) + } + + gitRoot, err := gitutil.FindGitRootFrom(dir) + if err != nil { + empty, emptyErr := isDirectoryEmpty(dir) + if emptyErr != nil { + return nil, emptyErr + } + if empty { + return &setupCheckoutInspection{cloneNeeded: true}, nil + } + return nil, fmt.Errorf("target directory %s exists but is not a git checkout for %s", dir, repo) + } + + absDir, err := filepath.Abs(dir) + if err != nil { + return nil, fmt.Errorf("failed to resolve %s: %w", dir, err) + } + if gitRoot != absDir { + return nil, fmt.Errorf("target directory %s is inside a different git checkout rooted at %s", dir, gitRoot) + } + + originRepo, err := originRepoLookup(dir) + if err != nil { + return nil, fmt.Errorf("failed to resolve git remote for %s: %w", dir, err) + } + if originRepo != repo { + return nil, fmt.Errorf("target directory %s points to %s, not %s", dir, originRepo, repo) + } + + return &setupCheckoutInspection{attached: true}, nil +} + +func resolveSetupCheckoutDir(repo string, dir string) string { + if strings.TrimSpace(dir) != "" { + return dir + } + return filepath.Base(repo) +} + +func normalizeSetupOwnerType(ownerType string) string { + switch strings.ToLower(strings.TrimSpace(ownerType)) { + case "organization": + return "org" + case "user": + return "user" + default: + return strings.ToLower(strings.TrimSpace(ownerType)) + } +} + +func isDirectoryEmpty(dir string) (bool, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return false, fmt.Errorf("failed to read %s: %w", dir, err) + } + return len(entries) == 0, nil +} + +func RunSetupAuth(opts SetupAuthOptions) error { + return runSetupAuthWithRuntime(opts, defaultSetupRepositoryRuntime()) +} + +func runSetupAuthWithRuntime(opts SetupAuthOptions, runtime setupRepositoryRuntime) error { + ctx := opts.Ctx + if ctx == nil { + ctx = context.Background() + } + + if err := runtime.checkAuth(ctx); err != nil { + return fmt.Errorf("failed to verify GitHub CLI authentication: %w", err) + } + + if opts.JSON { + return renderSetupJSON(SetupAuthResult{Authenticated: true}) + } + + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("GitHub CLI authentication verified")) + return nil +} + +func RunSetupRepositoryCheck(opts SetupRepositoryCheckOptions) error { + return runSetupRepositoryCheckWithRuntime(normalizeSetupRepositoryCheckOptions(opts), defaultSetupRepositoryRuntime()) +} + +func normalizeSetupRepositoryCheckOptions(opts SetupRepositoryCheckOptions) SetupRepositoryCheckOptions { + if opts.RequireOwnerType == "" { + opts.RequireOwnerType = "any" + } + return opts +} + +func validateSetupRepositoryCheckOptions(opts SetupRepositoryCheckOptions) error { + if strings.Count(opts.Repo, "/") != 1 { + return errors.New("--repo must use the OWNER/REPO format") + } + + switch opts.RequireOwnerType { + case "any", "org", "user": + default: + return errors.New("--require-owner-type must be one of: any, org, user") + } + + return nil +} + +func runSetupRepositoryCheckWithRuntime(opts SetupRepositoryCheckOptions, runtime setupRepositoryRuntime) error { + if err := validateSetupRepositoryCheckOptions(opts); err != nil { + return err + } + + ctx := opts.Ctx + if ctx == nil { + ctx = context.Background() + } + + if err := runtime.checkAuth(ctx); err != nil { + return fmt.Errorf("failed to verify GitHub CLI authentication: %w", err) + } + + owner := strings.Split(opts.Repo, "/")[0] + ownerType, err := runtime.ownerType(ctx, owner) + if err != nil { + return err + } + ownerType = normalizeSetupOwnerType(ownerType) + if opts.RequireOwnerType != "any" && ownerType != opts.RequireOwnerType { + return fmt.Errorf("owner %s is %s, but --require-owner-type=%s was requested", owner, ownerType, opts.RequireOwnerType) + } + + repoExists, err := runtime.repoExists(ctx, opts.Repo) + if err != nil { + return err + } + if !repoExists { + return fmt.Errorf("repository %s does not exist", opts.Repo) + } + + dir := resolveSetupCheckoutDir(opts.Repo, opts.Dir) + inspection, err := inspectSetupCheckout(dir, opts.Repo, runtime.dirOriginRepo) + if err != nil { + return err + } + + if inspection.attached { + if err := withWorkingDir(dir, func() error { + return runtime.checkCleanWorktree(opts.Verbose) + }); err != nil { + return err + } + } + + result := SetupRepositoryCheckResult{ + Repository: opts.Repo, + Directory: dir, + Authenticated: true, + RepositoryExists: true, + OwnerType: ownerType, + RequiredOwnerType: opts.RequireOwnerType, + CheckoutAttached: inspection.attached, + CloneNeeded: inspection.cloneNeeded, + } + if inspection.attached { + clean := true + result.CleanWorktree = &clean + } + + if opts.JSON { + return renderSetupJSON(result) + } + + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Setup repository check for %s", opts.Repo))) + fmt.Fprintln(os.Stderr, console.FormatInfoMessage("- GitHub CLI authenticated")) + fmt.Fprintln(os.Stderr, console.FormatInfoMessage("- repository exists")) + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("- owner type: %s", ownerType))) + if inspection.attached { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("- attached checkout at %s", dir))) + fmt.Fprintln(os.Stderr, console.FormatInfoMessage("- working tree is clean")) + } else if inspection.cloneNeeded { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("- no checkout at %s; directory is ready for clone", dir))) + } + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("Setup repository checks passed")) + return nil +} + +func renderSetupJSON(output any) error { + b, err := json.MarshalIndent(output, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal setup JSON: %w", err) + } + fmt.Fprintln(os.Stdout, string(b)) + return nil +} From f98d20bd2a3ef19e0e123c6dbdd3065f1c7292ff Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:32:14 +0000 Subject: [PATCH 2/8] docs(adr): add draft ADR-45524 for bootstrap command Documents the decision to introduce `bootstrap` and `setup` commands with a shared `setupRepositoryRuntime` for idempotent agentic workflow repository setup. --- ...trap-command-for-agentic-workflow-setup.md | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 docs/adr/45524-bootstrap-command-for-agentic-workflow-setup.md diff --git a/docs/adr/45524-bootstrap-command-for-agentic-workflow-setup.md b/docs/adr/45524-bootstrap-command-for-agentic-workflow-setup.md new file mode 100644 index 00000000000..6c25ffab4c0 --- /dev/null +++ b/docs/adr/45524-bootstrap-command-for-agentic-workflow-setup.md @@ -0,0 +1,47 @@ +# ADR-45524: Introduce `bootstrap` Command for Idempotent Agentic Workflow Repository Setup + +**Date**: 2026-07-14 +**Status**: Draft +**Deciders**: mnkiefer + +--- + +### Context + +Setting up a repository for agentic workflows required users to run multiple CLI commands in sequence: create or clone the repo, run `gh aw init` for marker files, add workflow sources with `gh aw add`, and compile with `gh aw compile`. This multi-step process was not idempotent, not CI-safe, and required external scripting glue. Agentic CI pipelines especially need a single command that can run unattended (with `--yes`) and safely skip already-completed steps without side effects. + +A companion need also emerged: other setup-oriented commands (and future tooling) need access to auth verification and repository state checks as reusable primitives, without being forced to run a full bootstrap. Exposing these as a `setup` subcommand tree allows both scripted inspection and composition. + +### Decision + +We will add two new CLI commands — `bootstrap` and `setup` — backed by a shared `setupRepositoryRuntime` struct. `bootstrap` orchestrates the full repository lifecycle (auth check → optional repo create → clone or attach → init markers → add workflows → compile) as a single idempotent, plan-then-apply operation. `setup` exposes the auth check (`setup auth`) and repository state inspection (`setup repo`) as lightweight standalone subcommands. Both commands reuse the same runtime primitives, injected via struct fields to keep them fully testable. + +### Alternatives Considered + +#### Alternative 1: Shell script / external tooling + +Users could compose `gh repo create`, `gh repo clone`, `gh aw init`, `gh aw add`, and `gh aw compile` in a shell script. This was considered because it requires no new code. It was rejected because shell scripts are brittle across platforms (Windows, CI images), are not idempotent by default, lack the plan-and-confirm UX, and require every consumer to reimplement the same error-handling and skip logic — defeating the goal of a single authoritative setup path. + +#### Alternative 2: Extend `init` with repo-lifecycle flags + +Adding `--create-repo`, `--clone`, and `--source` flags to the existing `gh aw init` command would avoid a new top-level command. It was rejected because `init` has a well-defined scope (writing repository marker files), and mixing repository creation/cloning into it would create a single-responsibility violation. It would also make the existing `init` command's interface more confusing for users who only want to reinitialize marker files on an already-cloned repository. + +### Consequences + +#### Positive +- Single idempotent entry point for bootstrapping agentic workflow repositories from scratch or attaching to existing checkouts. +- CI-safe via `--yes` flag; `--plan` provides a dry-run mode that prints the exact steps without executing them. +- Shared `setupRepositoryRuntime` struct is reusable by future setup-oriented commands without code duplication. +- Full unit and integration test coverage via injected runtime, with a fake `gh` binary for integration tests. + +#### Negative +- Adds two new commands (`bootstrap`, `setup`) to an already large CLI surface, increasing the maintenance and documentation burden. +- The plan-then-apply pattern makes two passes over auth and repository state, adding latency in the common case where no changes are needed. + +#### Neutral +- The `setup` command intentionally does not perform mutations; it is read-only by design. This means users who want a combined check-and-act workflow must use `bootstrap`. +- Engine-specific init marker detection (Copilot vs. other engines) is baked into `expectedBootstrapInitMarkers`, so adding a new engine requires updating that function. + +--- + +*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.* From 9c0372dfa22248e58db56ecd13686bf8af1af0c3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Jul 2026 17:22:23 +0000 Subject: [PATCH 3/8] fix: address open bootstrap/setup review feedback Co-authored-by: mnkiefer <8320933+mnkiefer@users.noreply.github.com> --- pkg/cli/bootstrap.go | 141 +++++++++++++++++++++++++++------- pkg/cli/bootstrap_test.go | 129 ++++++++++++++++++++++++++++++- pkg/cli/setup_command.go | 4 +- pkg/cli/setup_command_test.go | 33 +++++++- pkg/cli/setup_repository.go | 17 ++-- 5 files changed, 286 insertions(+), 38 deletions(-) diff --git a/pkg/cli/bootstrap.go b/pkg/cli/bootstrap.go index 186dc36acc4..0697dee11f5 100644 --- a/pkg/cli/bootstrap.go +++ b/pkg/cli/bootstrap.go @@ -2,6 +2,7 @@ package cli import ( "context" + "encoding/json" "errors" "fmt" "os" @@ -9,6 +10,7 @@ import ( "strings" "github.com/github/gh-aw/pkg/console" + "github.com/github/gh-aw/pkg/constants" "github.com/github/gh-aw/pkg/workflow" ) @@ -94,7 +96,7 @@ func runBootstrapWithRuntime(opts BootstrapOptions, runtime bootstrapRuntime, or } if !plan.NeedsMutation { - fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Bootstrap already satisfied for %s", opts.Repo))) + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("Bootstrap already satisfied for "+opts.Repo)) return nil } @@ -119,7 +121,7 @@ func runBootstrapWithRuntime(opts BootstrapOptions, runtime bootstrapRuntime, or if err := runtime.createRepo(ctx, plan.Repo, opts.Visibility); err != nil { return err } - fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Created %s", plan.Repo))) + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("Created "+plan.Repo)) } if plan.CloneRepo { @@ -161,12 +163,16 @@ func runBootstrapWithRuntime(opts BootstrapOptions, runtime bootstrapRuntime, or EngineOverride: opts.EngineOverride, Force: opts.Force, } - workflowsToAdd, skippedWorkflows, err := excludeExistingSourcedWorkflows(resolvedSources, addOpts) - if err != nil { - return fmt.Errorf("failed to inspect existing workflows: %w", err) + workflowsToAdd := resolvedSources + var skippedWorkflows []string + if !opts.Force { + workflowsToAdd, skippedWorkflows, err = excludeExistingSourcedWorkflows(resolvedSources, addOpts) + if err != nil { + return fmt.Errorf("failed to inspect existing workflows: %w", err) + } } if len(skippedWorkflows) > 0 { - fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Skipping already sourced workflows: %s", strings.Join(skippedWorkflows, ", ")))) + fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Skipping already sourced workflows: "+strings.Join(skippedWorkflows, ", "))) } if len(workflowsToAdd) > 0 { if _, err := runtime.addWorkflows(ctx, workflowsToAdd, addOpts); err != nil { @@ -191,7 +197,7 @@ func runBootstrapWithRuntime(opts BootstrapOptions, runtime bootstrapRuntime, or return err } - fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Bootstrap completed for %s", plan.Repo))) + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("Bootstrap completed for "+plan.Repo)) return nil } @@ -206,7 +212,7 @@ func normalizeBootstrapOptions(opts BootstrapOptions) BootstrapOptions { } func validateBootstrapOptions(opts BootstrapOptions) error { - if strings.Count(opts.Repo, "/") != 1 { + if !isValidOwnerRepoSlug(opts.Repo) { return errors.New("--repo must use the OWNER/REPO format") } @@ -280,12 +286,16 @@ func buildBootstrapPlan(ctx context.Context, opts BootstrapOptions, runtime boot addOpts := AddOptions{EngineOverride: opts.EngineOverride} var workflowsToAdd []string var skippedWorkflows []string - if err := withWorkingDir(plan.Dir, func() error { - var excludeErr error - workflowsToAdd, skippedWorkflows, excludeErr = excludeExistingSourcedWorkflows(plan.ResolvedSources, addOpts) - return excludeErr - }); err != nil { - return nil, err + if opts.Force { + workflowsToAdd = plan.ResolvedSources + } else { + if err := withWorkingDir(plan.Dir, func() error { + var excludeErr error + workflowsToAdd, skippedWorkflows, excludeErr = excludeExistingSourcedWorkflows(plan.ResolvedSources, addOpts) + return excludeErr + }); err != nil { + return nil, err + } } plan.ResolvedSources = workflowsToAdd plan.SkippedSources = skippedWorkflows @@ -296,7 +306,7 @@ func buildBootstrapPlan(ctx context.Context, opts BootstrapOptions, runtime boot plan.PlanLines = buildBootstrapPlanLines(plan, opts) plan.NeedsMutation = plan.CreateRepo || plan.CloneRepo || plan.InitNeeded || len(plan.ResolvedSources) > 0 - if plan.AttachedCheckout && plan.NeedsMutation { + if !opts.PlanOnly && plan.AttachedCheckout && plan.NeedsMutation { if err := withWorkingDir(plan.Dir, func() error { return runtime.checkCleanWorktree(opts.Verbose) }); err != nil { @@ -311,18 +321,93 @@ func missingBootstrapInitMarkers(baseDir string, engineOverride string) ([]strin markers := expectedBootstrapInitMarkers(engineOverride) missing := make([]string, 0) for _, marker := range markers { - markerPath := filepath.Join(baseDir, filepath.FromSlash(marker)) - if _, err := os.Stat(markerPath); err != nil { - if errors.Is(err, os.ErrNotExist) { - missing = append(missing, marker) - continue - } - return nil, fmt.Errorf("failed to inspect %s: %w", marker, err) + ok, err := isBootstrapInitMarkerSatisfied(baseDir, marker) + if err != nil { + return nil, err + } + if !ok { + missing = append(missing, marker) } } return missing, nil } +func isBootstrapInitMarkerSatisfied(baseDir string, marker string) (bool, error) { + markerPath := filepath.Join(baseDir, filepath.FromSlash(marker)) + info, err := os.Stat(markerPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + return false, fmt.Errorf("failed to inspect %s: %w", marker, err) + } + if !info.Mode().IsRegular() { + return false, nil + } + + switch marker { + case ".gitattributes": + content, err := os.ReadFile(markerPath) + if err != nil { + return false, fmt.Errorf("failed to inspect %s: %w", marker, err) + } + return strings.Contains(string(content), constants.WorkflowsLockYmlGitAttributesEntry), nil + case ".github/mcp.json": + content, err := os.ReadFile(markerPath) + if err != nil { + return false, fmt.Errorf("failed to inspect %s: %w", marker, err) + } + var config MCPConfig + if err := json.Unmarshal(content, &config); err != nil { + return false, nil + } + servers := config.MCPServers + if len(servers) == 0 { + servers = config.Servers + } + server, ok := servers["github-agentic-workflows"] + if !ok { + return false, nil + } + if strings.TrimSpace(server.Command) != "gh" { + return false, nil + } + return len(server.Args) >= 2 && server.Args[0] == "aw" && server.Args[1] == "mcp-server", nil + case ".github/workflows/copilot-setup-steps.yml": + content, err := os.ReadFile(markerPath) + if err != nil { + return false, fmt.Errorf("failed to inspect %s: %w", marker, err) + } + steps := string(content) + hasLegacyInstall := strings.Contains(steps, "install-gh-aw.sh") || + (strings.Contains(steps, "Install gh-aw extension") && strings.Contains(steps, "curl -fsSL")) + hasActionInstall := strings.Contains(steps, "actions/setup-cli") + return hasLegacyInstall || hasActionInstall, nil + case ".github/skills/agentic-workflows/SKILL.md": + expected, err := buildAgenticWorkflowsSkillContent() + if err != nil { + return false, fmt.Errorf("failed to inspect %s: %w", marker, err) + } + content, err := os.ReadFile(markerPath) + if err != nil { + return false, fmt.Errorf("failed to inspect %s: %w", marker, err) + } + return strings.TrimSpace(string(content)) == strings.TrimSpace(expected), nil + case ".github/agents/agentic-workflows.md": + expected, err := buildAgenticWorkflowsAgentContent(baseDir) + if err != nil { + return false, fmt.Errorf("failed to inspect %s: %w", marker, err) + } + content, err := os.ReadFile(markerPath) + if err != nil { + return false, fmt.Errorf("failed to inspect %s: %w", marker, err) + } + return strings.TrimSpace(string(content)) == strings.TrimSpace(expected), nil + default: + return info.Size() > 0, nil + } +} + func expectedBootstrapInitMarkers(engineOverride string) []string { markers := []string{ ".gitattributes", @@ -340,17 +425,17 @@ func expectedBootstrapInitMarkers(engineOverride string) []string { } func buildBootstrapPlanLines(plan *bootstrapPlan, opts BootstrapOptions) []string { - lines := []string{fmt.Sprintf("Bootstrap plan for %s", plan.Repo)} + lines := []string{"Bootstrap plan for " + plan.Repo} if plan.CreateRepo { lines = append(lines, fmt.Sprintf("- create remote repository (%s)", opts.Visibility)) if plan.CloneRepo { - lines = append(lines, fmt.Sprintf("- clone into %s", plan.Dir)) + lines = append(lines, "- clone into "+plan.Dir) } } else if plan.CloneRepo { - lines = append(lines, fmt.Sprintf("- clone existing repository into %s", plan.Dir)) + lines = append(lines, "- clone existing repository into "+plan.Dir) } else if plan.AttachedCheckout { - lines = append(lines, fmt.Sprintf("- attach existing checkout at %s", plan.Dir)) + lines = append(lines, "- attach existing checkout at "+plan.Dir) } if plan.AttachedCheckout { @@ -370,11 +455,11 @@ func buildBootstrapPlanLines(plan *bootstrapPlan, opts BootstrapOptions) []strin } } if len(plan.SkippedSources) > 0 { - lines = append(lines, fmt.Sprintf("- skip already sourced workflows: %s", strings.Join(plan.SkippedSources, ", "))) + lines = append(lines, "- skip already sourced workflows: "+strings.Join(plan.SkippedSources, ", ")) } if plan.OwnerType != "" { - lines = append(lines, fmt.Sprintf("- verified owner type: %s", normalizeSetupOwnerType(plan.OwnerType))) + lines = append(lines, "- verified owner type: "+normalizeSetupOwnerType(plan.OwnerType)) } if !plan.CreateRepo && !plan.CloneRepo && !plan.InitNeeded && len(plan.ResolvedSources) == 0 { diff --git a/pkg/cli/bootstrap_test.go b/pkg/cli/bootstrap_test.go index d973b134b8e..2f910baf5a7 100644 --- a/pkg/cli/bootstrap_test.go +++ b/pkg/cli/bootstrap_test.go @@ -8,8 +8,10 @@ import ( "os" "os/exec" "path/filepath" + "slices" "testing" + "github.com/github/gh-aw/pkg/constants" "github.com/github/gh-aw/pkg/testutil" "github.com/github/gh-aw/pkg/workflow" ) @@ -235,6 +237,29 @@ func TestRunBootstrapWithRuntime_PropagatesCleanWorktreeError(t *testing.T) { } } +func TestBuildBootstrapPlan_PlanOnlySkipsCleanWorktreeCheck(t *testing.T) { + repoDir := initBootstrapGitRepo(t) + wantErr := errors.New("working directory has uncommitted changes, please commit or stash them first") + + _, err := buildBootstrapPlan(context.Background(), normalizeBootstrapOptions(BootstrapOptions{ + Repo: "octo/platform-ops", + Dir: repoDir, + PlanOnly: true, + }), bootstrapRuntime{ + setupRepositoryRuntime: setupRepositoryRuntime{ + checkAuth: func(context.Context) error { return nil }, + repoExists: func(context.Context, string) (bool, error) { return true, nil }, + dirOriginRepo: func(string) (string, error) { return "octo/platform-ops", nil }, + checkCleanWorktree: func(bool) error { + return wantErr + }, + }, + }, repoDir) + if err != nil { + t.Fatalf("expected plan-only run to ignore clean worktree checks, got %v", err) + } +} + func TestRunBootstrapWithRuntime_SkipsExistingSourcedWorkflow(t *testing.T) { repoDir := initBootstrapGitRepo(t) writeBootstrapMarkers(t, repoDir, "") @@ -313,6 +338,81 @@ func TestRunBootstrapWithRuntime_SkipsExistingSourcedWorkflow(t *testing.T) { } } +func TestBuildBootstrapPlan_ForceKeepsExistingSourcedWorkflow(t *testing.T) { + repoDir := initBootstrapGitRepo(t) + writeBootstrapMarkers(t, repoDir, "") + workflowPath := filepath.Join(repoDir, ".github", "workflows", "readiness.md") + if err := os.MkdirAll(filepath.Dir(workflowPath), 0o755); err != nil { + t.Fatalf("failed to create workflow dir: %v", err) + } + content := "---\nsource: github/central-agentic-ops/readiness@main\n---\n\n# Readiness\n" + if err := os.WriteFile(workflowPath, []byte(content), 0o644); err != nil { + t.Fatalf("failed to write workflow: %v", err) + } + + plan, err := buildBootstrapPlan(context.Background(), normalizeBootstrapOptions(BootstrapOptions{ + Repo: "octo/platform-ops", + Dir: repoDir, + Sources: []string{"github/central-agentic-ops/readiness"}, + Force: true, + }), bootstrapRuntime{ + setupRepositoryRuntime: setupRepositoryRuntime{ + checkAuth: func(context.Context) error { return nil }, + repoExists: func(context.Context, string) (bool, error) { return true, nil }, + dirOriginRepo: func(string) (string, error) { return "octo/platform-ops", nil }, + checkCleanWorktree: func(bool) error { return nil }, + }, + }, repoDir) + if err != nil { + t.Fatalf("buildBootstrapPlan returned error: %v", err) + } + if !plan.NeedsMutation { + t.Fatal("expected force plan to keep sourced workflow in mutation set") + } + if len(plan.ResolvedSources) != 1 || plan.ResolvedSources[0] != "github/central-agentic-ops/readiness" { + t.Fatalf("expected readiness source to be preserved with --force, got %#v", plan.ResolvedSources) + } + if len(plan.SkippedSources) != 0 { + t.Fatalf("did not expect skipped sources with --force, got %#v", plan.SkippedSources) + } +} + +func TestValidateBootstrapOptions_RejectsEmptyRepoComponents(t *testing.T) { + tests := []BootstrapOptions{ + {Repo: "/repo"}, + {Repo: "owner/"}, + } + + for _, tt := range tests { + if err := validateBootstrapOptions(tt); err == nil { + t.Fatalf("expected invalid repo slug error for %q", tt.Repo) + } + } +} + +func TestMissingBootstrapInitMarkers_DetectsInvalidArtifacts(t *testing.T) { + repoDir := initBootstrapGitRepo(t) + writeBootstrapMarkers(t, repoDir, "") + + if err := os.WriteFile(filepath.Join(repoDir, ".gitattributes"), []byte(""), 0o644); err != nil { + t.Fatalf("failed to write .gitattributes: %v", err) + } + if err := os.WriteFile(filepath.Join(repoDir, ".github", "mcp.json"), []byte("{invalid"), 0o644); err != nil { + t.Fatalf("failed to write .github/mcp.json: %v", err) + } + + missing, err := missingBootstrapInitMarkers(repoDir, "") + if err != nil { + t.Fatalf("missingBootstrapInitMarkers returned error: %v", err) + } + if !containsString(missing, ".gitattributes") { + t.Fatalf("expected .gitattributes to be marked missing, got %#v", missing) + } + if !containsString(missing, ".github/mcp.json") { + t.Fatalf("expected .github/mcp.json to be marked missing, got %#v", missing) + } +} + func initBootstrapGitRepo(t *testing.T) string { t.Helper() repoDir := testutil.TempDir(t, "bootstrap-repo-*") @@ -330,8 +430,35 @@ func writeBootstrapMarkers(t *testing.T, repoDir string, engineOverride string) if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatalf("failed to create marker dir for %s: %v", marker, err) } - if err := os.WriteFile(path, []byte("ok\n"), 0o644); err != nil { + content, err := bootstrapMarkerContent(marker, repoDir) + if err != nil { + t.Fatalf("failed to render marker content for %s: %v", marker, err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { t.Fatalf("failed to create marker %s: %v", marker, err) } } } + +func bootstrapMarkerContent(marker string, repoDir string) (string, error) { + switch marker { + case ".gitattributes": + return constants.WorkflowsLockYmlGitAttributesEntry + "\n", nil + case ".vscode/settings.json": + return "{\n \"github.copilot.chat.agent.thinkingTool\": true\n}\n", nil + case ".github/skills/agentic-workflows/SKILL.md": + return buildAgenticWorkflowsSkillContent() + case ".github/agents/agentic-workflows.md": + return buildAgenticWorkflowsAgentContent(repoDir) + case ".github/mcp.json": + return "{\n \"mcpServers\": {\n \"github-agentic-workflows\": {\n \"type\": \"local\",\n \"command\": \"gh\",\n \"args\": [\"aw\", \"mcp-server\"],\n \"tools\": [\"compile\"]\n }\n }\n}\n", nil + case ".github/workflows/copilot-setup-steps.yml": + return "name: Copilot Setup Steps\njobs:\n copilot-setup-steps:\n steps:\n - uses: actions/setup-cli@v1\n", nil + default: + return "ok\n", nil + } +} + +func containsString(values []string, want string) bool { + return slices.Contains(values, want) +} diff --git a/pkg/cli/setup_command.go b/pkg/cli/setup_command.go index 89195b328e5..3a6197f4aa1 100644 --- a/pkg/cli/setup_command.go +++ b/pkg/cli/setup_command.go @@ -19,7 +19,7 @@ Available subcommands: Example: ` gh aw setup auth gh aw setup repo --repo github/gh-aw gh aw setup repo --repo github/gh-aw --json - gh aw setup repo --repo github/gh-aw --dir ../gh-aw --require-owner-type org`, + gh aw setup repo --repo github/gh-aw --dir ./gh-aw --require-owner-type org`, RunE: func(cmd *cobra.Command, args []string) error { return cmd.Help() }, @@ -61,7 +61,7 @@ repository exists, resolves the owner type, and inspects whether the target directory is already attached to the expected checkout or is ready for clone.`, Example: ` gh aw setup repo --repo github/gh-aw gh aw setup repo --repo github/gh-aw --json - gh aw setup repo --repo github/gh-aw --dir ../gh-aw + gh aw setup repo --repo github/gh-aw --dir ./gh-aw gh aw setup repo --repo github/gh-aw --require-owner-type org`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { diff --git a/pkg/cli/setup_command_test.go b/pkg/cli/setup_command_test.go index ae203d7bf00..df217bc5226 100644 --- a/pkg/cli/setup_command_test.go +++ b/pkg/cli/setup_command_test.go @@ -208,6 +208,35 @@ func TestRunSetupRepositoryCheck_PropagatesCleanWorktreeError(t *testing.T) { assert.ErrorIs(t, err, wantErr) } +func TestRunSetupRepositoryCheck_AcceptsCaseInsensitiveSlugMatch(t *testing.T) { + repoDir := initBootstrapGitRepo(t) + err := runSetupRepositoryCheckWithRuntime(normalizeSetupRepositoryCheckOptions(SetupRepositoryCheckOptions{ + Ctx: context.Background(), + Repo: "octo/platform-ops", + Dir: repoDir, + }), setupRepositoryRuntime{ + checkAuth: func(context.Context) error { return nil }, + ownerType: func(context.Context, string) (string, error) { return "Organization", nil }, + repoExists: func(context.Context, string) (bool, error) { return true, nil }, + dirOriginRepo: func(string) (string, error) { return "Octo/Platform-Ops", nil }, + checkCleanWorktree: func(bool) error { return nil }, + }) + require.NoError(t, err) +} + +func TestValidateSetupRepositoryCheckOptions_RejectsEmptyRepoComponents(t *testing.T) { + tests := []SetupRepositoryCheckOptions{ + {Repo: "/repo"}, + {Repo: "owner/"}, + } + + for _, tt := range tests { + if err := validateSetupRepositoryCheckOptions(tt); err == nil { + t.Fatalf("expected invalid repo slug error for %q", tt.Repo) + } + } +} + func TestSetupCommandSubcommandListingsUseHyphenBullets(t *testing.T) { tests := []struct { name string @@ -227,14 +256,14 @@ func TestSetupCommandSubcommandListingsUseHyphenBullets(t *testing.T) { func TestSetupRepoSubcommandUsesNoArgs(t *testing.T) { cmd := newSetupRepoSubcommand() require.NotNil(t, cmd.Args) - assert.NoError(t, cmd.Args(cmd, []string{})) + require.NoError(t, cmd.Args(cmd, []string{})) assert.Error(t, cmd.Args(cmd, []string{"extra"})) } func TestSetupAuthSubcommandUsesNoArgs(t *testing.T) { cmd := newSetupAuthSubcommand() require.NotNil(t, cmd.Args) - assert.NoError(t, cmd.Args(cmd, []string{})) + require.NoError(t, cmd.Args(cmd, []string{})) assert.Error(t, cmd.Args(cmd, []string{"extra"})) } diff --git a/pkg/cli/setup_repository.go b/pkg/cli/setup_repository.go index f88bd796e5e..8b30a45fd0b 100644 --- a/pkg/cli/setup_repository.go +++ b/pkg/cli/setup_repository.go @@ -168,7 +168,7 @@ func inspectSetupCheckout(dir string, repo string, originRepoLookup func(string) if err != nil { return nil, fmt.Errorf("failed to resolve git remote for %s: %w", dir, err) } - if originRepo != repo { + if !strings.EqualFold(strings.TrimSpace(originRepo), strings.TrimSpace(repo)) { return nil, fmt.Errorf("target directory %s points to %s, not %s", dir, originRepo, repo) } @@ -235,7 +235,7 @@ func normalizeSetupRepositoryCheckOptions(opts SetupRepositoryCheckOptions) Setu } func validateSetupRepositoryCheckOptions(opts SetupRepositoryCheckOptions) error { - if strings.Count(opts.Repo, "/") != 1 { + if !isValidOwnerRepoSlug(opts.Repo) { return errors.New("--repo must use the OWNER/REPO format") } @@ -248,6 +248,13 @@ func validateSetupRepositoryCheckOptions(opts SetupRepositoryCheckOptions) error return nil } +func isValidOwnerRepoSlug(repo string) bool { + parts := strings.Split(repo, "/") + return len(parts) == 2 && + strings.TrimSpace(parts[0]) != "" && + strings.TrimSpace(parts[1]) != "" +} + func runSetupRepositoryCheckWithRuntime(opts SetupRepositoryCheckOptions, runtime setupRepositoryRuntime) error { if err := validateSetupRepositoryCheckOptions(opts); err != nil { return err @@ -313,12 +320,12 @@ func runSetupRepositoryCheckWithRuntime(opts SetupRepositoryCheckOptions, runtim return renderSetupJSON(result) } - fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Setup repository check for %s", opts.Repo))) + fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Setup repository check for "+opts.Repo)) fmt.Fprintln(os.Stderr, console.FormatInfoMessage("- GitHub CLI authenticated")) fmt.Fprintln(os.Stderr, console.FormatInfoMessage("- repository exists")) - fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("- owner type: %s", ownerType))) + fmt.Fprintln(os.Stderr, console.FormatInfoMessage("- owner type: "+ownerType)) if inspection.attached { - fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("- attached checkout at %s", dir))) + fmt.Fprintln(os.Stderr, console.FormatInfoMessage("- attached checkout at "+dir)) fmt.Fprintln(os.Stderr, console.FormatInfoMessage("- working tree is clean")) } else if inspection.cloneNeeded { fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("- no checkout at %s; directory is ready for clone", dir))) From 600c37d124ae54d0de40fad8c1ffb935b3a3b092 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Jul 2026 17:24:27 +0000 Subject: [PATCH 4/8] test: simplify bootstrap marker assertion helper usage Co-authored-by: mnkiefer <8320933+mnkiefer@users.noreply.github.com> --- pkg/cli/bootstrap_test.go | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/pkg/cli/bootstrap_test.go b/pkg/cli/bootstrap_test.go index 2f910baf5a7..35cdce8f575 100644 --- a/pkg/cli/bootstrap_test.go +++ b/pkg/cli/bootstrap_test.go @@ -405,10 +405,10 @@ func TestMissingBootstrapInitMarkers_DetectsInvalidArtifacts(t *testing.T) { if err != nil { t.Fatalf("missingBootstrapInitMarkers returned error: %v", err) } - if !containsString(missing, ".gitattributes") { + if !slices.Contains(missing, ".gitattributes") { t.Fatalf("expected .gitattributes to be marked missing, got %#v", missing) } - if !containsString(missing, ".github/mcp.json") { + if !slices.Contains(missing, ".github/mcp.json") { t.Fatalf("expected .github/mcp.json to be marked missing, got %#v", missing) } } @@ -458,7 +458,3 @@ func bootstrapMarkerContent(marker string, repoDir string) (string, error) { return "ok\n", nil } } - -func containsString(values []string, want string) bool { - return slices.Contains(values, want) -} From 07641e6eaa3c9238503037b1358f4f7bcc7455fe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Jul 2026 17:49:04 +0000 Subject: [PATCH 5/8] fix: address remaining bootstrap/setup review feedback Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- pkg/cli/bootstrap.go | 15 ++++++----- pkg/cli/bootstrap_test.go | 34 +++++++++++++++++++++++++ pkg/cli/setup_command_test.go | 47 +++++++++++++++++++++++++++++++++++ pkg/cli/setup_repository.go | 7 +++++- 4 files changed, 96 insertions(+), 7 deletions(-) diff --git a/pkg/cli/bootstrap.go b/pkg/cli/bootstrap.go index 0697dee11f5..45f3cb0c058 100644 --- a/pkg/cli/bootstrap.go +++ b/pkg/cli/bootstrap.go @@ -131,9 +131,8 @@ func runBootstrapWithRuntime(opts BootstrapOptions, runtime bootstrapRuntime, or fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Cloned %s into %s", plan.Repo, plan.Dir))) } - resolvedSources := resolveDeployWorkflowSpecs(opts.Sources, originalDir) - if err := withWorkingDir(plan.Dir, func() error { + initCompleted := false missingMarkers, err := missingBootstrapInitMarkers(".", opts.EngineOverride) if err != nil { return err @@ -153,20 +152,21 @@ func runBootstrapWithRuntime(opts BootstrapOptions, runtime bootstrapRuntime, or }); err != nil { return fmt.Errorf("failed to initialize repository: %w", err) } + initCompleted = true fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("Initialized repository for agentic workflows")) } addedWorkflows := false - if len(resolvedSources) > 0 { + if len(plan.ResolvedSources) > 0 { addOpts := AddOptions{ Verbose: opts.Verbose, EngineOverride: opts.EngineOverride, Force: opts.Force, } - workflowsToAdd := resolvedSources + workflowsToAdd := plan.ResolvedSources var skippedWorkflows []string - if !opts.Force { - workflowsToAdd, skippedWorkflows, err = excludeExistingSourcedWorkflows(resolvedSources, addOpts) + if !opts.Force && !plan.AttachedCheckout { + workflowsToAdd, skippedWorkflows, err = excludeExistingSourcedWorkflows(plan.ResolvedSources, addOpts) if err != nil { return fmt.Errorf("failed to inspect existing workflows: %w", err) } @@ -176,6 +176,9 @@ func runBootstrapWithRuntime(opts BootstrapOptions, runtime bootstrapRuntime, or } if len(workflowsToAdd) > 0 { if _, err := runtime.addWorkflows(ctx, workflowsToAdd, addOpts); err != nil { + if initCompleted { + return fmt.Errorf("failed to add workflows (repository initialization completed; re-run bootstrap to retry workflow addition): %w", err) + } return fmt.Errorf("failed to add workflows: %w", err) } addedWorkflows = true diff --git a/pkg/cli/bootstrap_test.go b/pkg/cli/bootstrap_test.go index 35cdce8f575..d7e2b1ecbed 100644 --- a/pkg/cli/bootstrap_test.go +++ b/pkg/cli/bootstrap_test.go @@ -9,6 +9,7 @@ import ( "os/exec" "path/filepath" "slices" + "strings" "testing" "github.com/github/gh-aw/pkg/constants" @@ -237,6 +238,39 @@ func TestRunBootstrapWithRuntime_PropagatesCleanWorktreeError(t *testing.T) { } } +func TestRunBootstrapWithRuntime_AddWorkflowFailureAfterInitIncludesRecoveryHint(t *testing.T) { + repoDir := initBootstrapGitRepo(t) + addErr := errors.New("add failed") + + err := runBootstrapWithRuntime(normalizeBootstrapOptions(BootstrapOptions{ + Ctx: context.Background(), + Repo: "octo/platform-ops", + Dir: repoDir, + Yes: true, + Sources: []string{"github/central-agentic-ops/readiness"}, + }), bootstrapRuntime{ + setupRepositoryRuntime: setupRepositoryRuntime{ + checkAuth: func(context.Context) error { return nil }, + repoExists: func(context.Context, string) (bool, error) { return true, nil }, + dirOriginRepo: func(string) (string, error) { return "octo/platform-ops", nil }, + checkCleanWorktree: func(bool) error { return nil }, + }, + initRepo: func(InitOptions) error { return nil }, + addWorkflows: func(context.Context, []string, AddOptions) (*AddWorkflowsResult, error) { + return nil, addErr + }, + }, repoDir) + if err == nil { + t.Fatal("expected add workflow error") + } + if !strings.Contains(err.Error(), "repository initialization completed; re-run bootstrap to retry workflow addition") { + t.Fatalf("expected recovery hint in error, got %v", err) + } + if !errors.Is(err, addErr) { + t.Fatalf("expected wrapped add error, got %v", err) + } +} + func TestBuildBootstrapPlan_PlanOnlySkipsCleanWorktreeCheck(t *testing.T) { repoDir := initBootstrapGitRepo(t) wantErr := errors.New("working directory has uncommitted changes, please commit or stash them first") diff --git a/pkg/cli/setup_command_test.go b/pkg/cli/setup_command_test.go index df217bc5226..e15c98ea75c 100644 --- a/pkg/cli/setup_command_test.go +++ b/pkg/cli/setup_command_test.go @@ -8,6 +8,8 @@ import ( "errors" "io" "os" + "path/filepath" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -208,6 +210,51 @@ func TestRunSetupRepositoryCheck_PropagatesCleanWorktreeError(t *testing.T) { assert.ErrorIs(t, err, wantErr) } +func TestCreateSetupRepository_UsesSupportedFlags(t *testing.T) { + fakeBin := t.TempDir() + argsLog := filepath.Join(fakeBin, "gh-args.log") + fakeGH := filepath.Join(fakeBin, "gh") + script := "#!/bin/sh\n" + + "printf '%s\\n' \"$*\" >> \"" + argsLog + "\"\n" + + "if [ \"$1\" = \"repo\" ] && [ \"$2\" = \"create\" ]; then\n" + + " exit 0\n" + + "fi\n" + + "exit 1\n" + require.NoError(t, os.WriteFile(fakeGH, []byte(script), 0o755)) + t.Setenv("PATH", fakeBin+string(os.PathListSeparator)+os.Getenv("PATH")) + + require.NoError(t, createSetupRepository(context.Background(), "octo/platform-ops", "private")) + + logData, err := os.ReadFile(argsLog) + require.NoError(t, err) + logText := string(logData) + assert.Contains(t, logText, "repo create octo/platform-ops --private") + assert.NotContains(t, logText, "--confirm") + assert.NotContains(t, logText, "--clone=false") +} + +func TestCheckSetupRepositoryOwnerType_FallsBackToOrgsEndpoint(t *testing.T) { + fakeBin := t.TempDir() + fakeGH := filepath.Join(fakeBin, "gh") + script := `#!/bin/sh +if [ "$1" = "api" ] && [ "$2" = "users/octo" ]; then + echo "Not Found" >&2 + exit 1 +fi +if [ "$1" = "api" ] && [ "$2" = "orgs/octo" ]; then + echo "Organization" + exit 0 +fi +exit 1 +` + require.NoError(t, os.WriteFile(fakeGH, []byte(script), 0o755)) + t.Setenv("PATH", fakeBin+string(os.PathListSeparator)+os.Getenv("PATH")) + + ownerType, err := checkSetupRepositoryOwnerType(context.Background(), "octo") + require.NoError(t, err) + assert.Equal(t, "Organization", strings.TrimSpace(ownerType)) +} + func TestRunSetupRepositoryCheck_AcceptsCaseInsensitiveSlugMatch(t *testing.T) { repoDir := initBootstrapGitRepo(t) err := runSetupRepositoryCheckWithRuntime(normalizeSetupRepositoryCheckOptions(SetupRepositoryCheckOptions{ diff --git a/pkg/cli/setup_repository.go b/pkg/cli/setup_repository.go index 8b30a45fd0b..df7ed214e0d 100644 --- a/pkg/cli/setup_repository.go +++ b/pkg/cli/setup_repository.go @@ -96,6 +96,11 @@ func checkSetupRepositoryExists(ctx context.Context, repo string) (bool, error) func checkSetupRepositoryOwnerType(ctx context.Context, owner string) (string, error) { output, err := workflow.RunGHContext(ctx, "Checking owner type...", "api", "users/"+owner, "--jq", ".type") + if err == nil { + return strings.TrimSpace(string(output)), nil + } + + output, err = workflow.RunGHContext(ctx, "Checking owner type...", "api", "orgs/"+owner, "--jq", ".type") if err != nil { return "", fmt.Errorf("failed to check owner type for %s: %w", owner, err) } @@ -103,7 +108,7 @@ func checkSetupRepositoryOwnerType(ctx context.Context, owner string) (string, e } func createSetupRepository(ctx context.Context, repo string, visibility string) error { - output, err := workflow.RunGHCombinedContext(ctx, "Creating repository...", "repo", "create", repo, "--"+visibility, "--clone=false", "--confirm") + output, err := workflow.RunGHCombinedContext(ctx, "Creating repository...", "repo", "create", repo, "--"+visibility) if err != nil { trimmed := strings.TrimSpace(string(output)) if trimmed == "" { From a4f465169c892dd2d1db64adcb6a5a1c48cff7d5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Jul 2026 17:59:36 +0000 Subject: [PATCH 6/8] test: refine bootstrap/setup follow-up feedback fixes Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- pkg/cli/bootstrap.go | 11 +++++++---- pkg/cli/bootstrap_test.go | 11 +++++++++-- pkg/cli/setup_command_test.go | 3 +-- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/pkg/cli/bootstrap.go b/pkg/cli/bootstrap.go index 45f3cb0c058..bd1cce38785 100644 --- a/pkg/cli/bootstrap.go +++ b/pkg/cli/bootstrap.go @@ -55,6 +55,8 @@ type bootstrapRuntime struct { compileWorkflows func(context.Context, CompileConfig) ([]*workflow.WorkflowData, error) } +const bootstrapAddWorkflowsRetryHint = "repository initialization completed; re-run bootstrap to retry workflow addition" + func defaultBootstrapRuntime() bootstrapRuntime { setupRuntime := defaultSetupRepositoryRuntime() return bootstrapRuntime{ @@ -132,7 +134,7 @@ func runBootstrapWithRuntime(opts BootstrapOptions, runtime bootstrapRuntime, or } if err := withWorkingDir(plan.Dir, func() error { - initCompleted := false + repositoryInitialized := false missingMarkers, err := missingBootstrapInitMarkers(".", opts.EngineOverride) if err != nil { return err @@ -152,7 +154,7 @@ func runBootstrapWithRuntime(opts BootstrapOptions, runtime bootstrapRuntime, or }); err != nil { return fmt.Errorf("failed to initialize repository: %w", err) } - initCompleted = true + repositoryInitialized = true fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("Initialized repository for agentic workflows")) } @@ -165,6 +167,7 @@ func runBootstrapWithRuntime(opts BootstrapOptions, runtime bootstrapRuntime, or } workflowsToAdd := plan.ResolvedSources var skippedWorkflows []string + // Attached checkouts were already filtered during plan construction. if !opts.Force && !plan.AttachedCheckout { workflowsToAdd, skippedWorkflows, err = excludeExistingSourcedWorkflows(plan.ResolvedSources, addOpts) if err != nil { @@ -176,8 +179,8 @@ func runBootstrapWithRuntime(opts BootstrapOptions, runtime bootstrapRuntime, or } if len(workflowsToAdd) > 0 { if _, err := runtime.addWorkflows(ctx, workflowsToAdd, addOpts); err != nil { - if initCompleted { - return fmt.Errorf("failed to add workflows (repository initialization completed; re-run bootstrap to retry workflow addition): %w", err) + if repositoryInitialized { + return fmt.Errorf("failed to add workflows (%s): %w", bootstrapAddWorkflowsRetryHint, err) } return fmt.Errorf("failed to add workflows: %w", err) } diff --git a/pkg/cli/bootstrap_test.go b/pkg/cli/bootstrap_test.go index d7e2b1ecbed..494a407b2e6 100644 --- a/pkg/cli/bootstrap_test.go +++ b/pkg/cli/bootstrap_test.go @@ -241,6 +241,7 @@ func TestRunBootstrapWithRuntime_PropagatesCleanWorktreeError(t *testing.T) { func TestRunBootstrapWithRuntime_AddWorkflowFailureAfterInitIncludesRecoveryHint(t *testing.T) { repoDir := initBootstrapGitRepo(t) addErr := errors.New("add failed") + initCalled := false err := runBootstrapWithRuntime(normalizeBootstrapOptions(BootstrapOptions{ Ctx: context.Background(), @@ -255,7 +256,10 @@ func TestRunBootstrapWithRuntime_AddWorkflowFailureAfterInitIncludesRecoveryHint dirOriginRepo: func(string) (string, error) { return "octo/platform-ops", nil }, checkCleanWorktree: func(bool) error { return nil }, }, - initRepo: func(InitOptions) error { return nil }, + initRepo: func(InitOptions) error { + initCalled = true + return nil + }, addWorkflows: func(context.Context, []string, AddOptions) (*AddWorkflowsResult, error) { return nil, addErr }, @@ -263,12 +267,15 @@ func TestRunBootstrapWithRuntime_AddWorkflowFailureAfterInitIncludesRecoveryHint if err == nil { t.Fatal("expected add workflow error") } - if !strings.Contains(err.Error(), "repository initialization completed; re-run bootstrap to retry workflow addition") { + if !strings.Contains(err.Error(), bootstrapAddWorkflowsRetryHint) { t.Fatalf("expected recovery hint in error, got %v", err) } if !errors.Is(err, addErr) { t.Fatalf("expected wrapped add error, got %v", err) } + if !initCalled { + t.Fatal("expected repository initialization to run before add failure") + } } func TestBuildBootstrapPlan_PlanOnlySkipsCleanWorktreeCheck(t *testing.T) { diff --git a/pkg/cli/setup_command_test.go b/pkg/cli/setup_command_test.go index e15c98ea75c..9e5bc205658 100644 --- a/pkg/cli/setup_command_test.go +++ b/pkg/cli/setup_command_test.go @@ -9,7 +9,6 @@ import ( "io" "os" "path/filepath" - "strings" "testing" "github.com/stretchr/testify/assert" @@ -252,7 +251,7 @@ exit 1 ownerType, err := checkSetupRepositoryOwnerType(context.Background(), "octo") require.NoError(t, err) - assert.Equal(t, "Organization", strings.TrimSpace(ownerType)) + assert.Equal(t, "Organization", ownerType) } func TestRunSetupRepositoryCheck_AcceptsCaseInsensitiveSlugMatch(t *testing.T) { From 94e49992c122e1be482bae567608a5d20cbccb2d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:33:03 +0000 Subject: [PATCH 7/8] fix: reject nested non-existent checkout dirs for setup/bootstrap Co-authored-by: mnkiefer <8320933+mnkiefer@users.noreply.github.com> --- pkg/cli/bootstrap_test.go | 24 +++++++++++++++++ pkg/cli/setup_command_test.go | 18 +++++++++++++ pkg/cli/setup_repository.go | 51 +++++++++++++++++++++++++++++++++++ 3 files changed, 93 insertions(+) diff --git a/pkg/cli/bootstrap_test.go b/pkg/cli/bootstrap_test.go index 494a407b2e6..6d190d4cb68 100644 --- a/pkg/cli/bootstrap_test.go +++ b/pkg/cli/bootstrap_test.go @@ -301,6 +301,30 @@ func TestBuildBootstrapPlan_PlanOnlySkipsCleanWorktreeCheck(t *testing.T) { } } +func TestBuildBootstrapPlan_RejectsNonExistentNestedCheckoutPath(t *testing.T) { + parentRepoDir := initBootstrapGitRepo(t) + nestedDir := filepath.Join(parentRepoDir, "new-checkout") + + _, err := buildBootstrapPlan(context.Background(), normalizeBootstrapOptions(BootstrapOptions{ + Repo: "octo/platform-ops", + Dir: nestedDir, + }), bootstrapRuntime{ + setupRepositoryRuntime: setupRepositoryRuntime{ + checkAuth: func(context.Context) error { return nil }, + repoExists: func(context.Context, string) (bool, error) { return true, nil }, + }, + }, parentRepoDir) + if err == nil { + t.Fatal("expected nested checkout path validation error") + } + if !strings.Contains(err.Error(), "is inside a different git checkout rooted at") { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(err.Error(), parentRepoDir) { + t.Fatalf("expected error to include checkout root %s, got %v", parentRepoDir, err) + } +} + func TestRunBootstrapWithRuntime_SkipsExistingSourcedWorkflow(t *testing.T) { repoDir := initBootstrapGitRepo(t) writeBootstrapMarkers(t, repoDir, "") diff --git a/pkg/cli/setup_command_test.go b/pkg/cli/setup_command_test.go index 9e5bc205658..9ba84b9c944 100644 --- a/pkg/cli/setup_command_test.go +++ b/pkg/cli/setup_command_test.go @@ -209,6 +209,24 @@ func TestRunSetupRepositoryCheck_PropagatesCleanWorktreeError(t *testing.T) { assert.ErrorIs(t, err, wantErr) } +func TestRunSetupRepositoryCheck_RejectsNonExistentNestedCheckoutPath(t *testing.T) { + parentRepoDir := initBootstrapGitRepo(t) + nestedDir := filepath.Join(parentRepoDir, "new-checkout") + + err := runSetupRepositoryCheckWithRuntime(normalizeSetupRepositoryCheckOptions(SetupRepositoryCheckOptions{ + Ctx: context.Background(), + Repo: "octo/platform-ops", + Dir: nestedDir, + }), setupRepositoryRuntime{ + checkAuth: func(context.Context) error { return nil }, + ownerType: func(context.Context, string) (string, error) { return "Organization", nil }, + repoExists: func(context.Context, string) (bool, error) { return true, nil }, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "is inside a different git checkout rooted at") + assert.Contains(t, err.Error(), parentRepoDir) +} + func TestCreateSetupRepository_UsesSupportedFlags(t *testing.T) { fakeBin := t.TempDir() argsLog := filepath.Join(fakeBin, "gh-args.log") diff --git a/pkg/cli/setup_repository.go b/pkg/cli/setup_repository.go index df7ed214e0d..4ea3a5ce51b 100644 --- a/pkg/cli/setup_repository.go +++ b/pkg/cli/setup_repository.go @@ -141,6 +141,9 @@ func inspectSetupCheckout(dir string, repo string, originRepoLookup func(string) info, err := os.Stat(dir) if err != nil { if errors.Is(err, os.ErrNotExist) { + if nestedErr := rejectNestedNonExistentCheckoutPath(dir); nestedErr != nil { + return nil, nestedErr + } return &setupCheckoutInspection{cloneNeeded: true}, nil } return nil, fmt.Errorf("failed to inspect %s: %w", dir, err) @@ -180,6 +183,54 @@ func inspectSetupCheckout(dir string, repo string, originRepoLookup func(string) return &setupCheckoutInspection{attached: true}, nil } +func rejectNestedNonExistentCheckoutPath(dir string) error { + existingPath, err := firstExistingParent(dir) + if err != nil { + return err + } + if existingPath == "" { + return nil + } + + gitRoot, err := gitutil.FindGitRootFrom(existingPath) + if err != nil { + return nil + } + + absDir, err := filepath.Abs(dir) + if err != nil { + return fmt.Errorf("failed to resolve %s: %w", dir, err) + } + if absDir != gitRoot { + return fmt.Errorf("target directory %s is inside a different git checkout rooted at %s", dir, gitRoot) + } + return nil +} + +func firstExistingParent(path string) (string, error) { + absPath, err := filepath.Abs(path) + if err != nil { + return "", fmt.Errorf("failed to resolve %s: %w", path, err) + } + + current := absPath + for { + _, statErr := os.Stat(current) + if statErr == nil { + return current, nil + } + if !errors.Is(statErr, os.ErrNotExist) { + return "", fmt.Errorf("failed to inspect %s: %w", current, statErr) + } + + parent := filepath.Dir(current) + if parent == current { + return "", nil + } + current = parent + } +} + func resolveSetupCheckoutDir(repo string, dir string) string { if strings.TrimSpace(dir) != "" { return dir From de04e2835d7ceaf9750edc10b2e98d24bff22e9a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:39:53 +0000 Subject: [PATCH 8/8] fix: narrow nested checkout detection for non-existent dirs Co-authored-by: mnkiefer <8320933+mnkiefer@users.noreply.github.com> --- pkg/cli/setup_repository.go | 34 ++++++++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/pkg/cli/setup_repository.go b/pkg/cli/setup_repository.go index 4ea3a5ce51b..a7d431c6b79 100644 --- a/pkg/cli/setup_repository.go +++ b/pkg/cli/setup_repository.go @@ -194,19 +194,45 @@ func rejectNestedNonExistentCheckoutPath(dir string) error { gitRoot, err := gitutil.FindGitRootFrom(existingPath) if err != nil { - return nil + if errors.Is(err, gitutil.ErrNotGitRepository) { + return nil + } + return fmt.Errorf("failed to inspect git checkout for %s: %w", existingPath, err) } - absDir, err := filepath.Abs(dir) + insideGitRoot, err := isNestedPathUnder(dir, gitRoot) if err != nil { - return fmt.Errorf("failed to resolve %s: %w", dir, err) + return err } - if absDir != gitRoot { + if insideGitRoot { return fmt.Errorf("target directory %s is inside a different git checkout rooted at %s", dir, gitRoot) } return nil } +func isNestedPathUnder(path string, root string) (bool, error) { + absPath, err := filepath.Abs(path) + if err != nil { + return false, fmt.Errorf("failed to resolve %s: %w", path, err) + } + absRoot, err := filepath.Abs(root) + if err != nil { + return false, fmt.Errorf("failed to resolve %s: %w", root, err) + } + + rel, err := filepath.Rel(absRoot, absPath) + if err != nil { + return false, fmt.Errorf("failed to compare %s with %s: %w", absPath, absRoot, err) + } + if rel == "." { + return false, nil + } + if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return false, nil + } + return true, nil +} + func firstExistingParent(path string) (string, error) { absPath, err := filepath.Abs(path) if err != nil {