diff --git a/docs/guides/admin/installation.md b/docs/guides/admin/installation.md index 45583096c4..885fdcfb5d 100644 --- a/docs/guides/admin/installation.md +++ b/docs/guides/admin/installation.md @@ -187,7 +187,56 @@ After install completes, the installer dispatches a workflow that creates an enr Review and merge each enrollment PR to complete enrollment. -## 4. Test the pipeline +## 4. Managing repository enrollment + +After installation, you can enroll or unenroll repositories at any time using the `repos` subcommands. + +### Enable repositories + +To enroll specific repositories: + +```bash +fullsend admin enable repos "$ORG_NAME" [repo-name...] +``` + +To enroll all repositories: + +```bash +fullsend admin enable repos "$ORG_NAME" --all +``` + +The enable command: +- Updates `config.yaml` in the `.fullsend` repository +- Triggers the `repo-maintenance` workflow to create enrollment PRs +- Validates that repositories exist in the organization before making changes + +### Disable repositories + +To unenroll specific repositories: + +```bash +fullsend admin disable repos "$ORG_NAME" [repo-name...] +``` + +To unenroll all repositories: + +```bash +fullsend admin disable repos "$ORG_NAME" --all +``` + +The `--all` flag prompts for confirmation — you must type the exact organization name when prompted. To skip the confirmation prompt (e.g., in automated scripts): + +```bash +fullsend admin disable repos "$ORG_NAME" --all --yolo +``` + +The disable command: +- Updates `config.yaml` to mark repositories as disabled +- Triggers the `repo-maintenance` workflow to create unenrollment PRs +- Warns (but does not reject) repository names not found in the config, allowing safe cleanup of deleted repos +- Does not delete existing shim workflows (merge the unenrollment PR to remove them) + +## 5. Test the pipeline Once a repo is enrolled (enrollment PR merged): diff --git a/internal/cli/admin.go b/internal/cli/admin.go index 490fd33a28..5811dfa5f9 100644 --- a/internal/cli/admin.go +++ b/internal/cli/admin.go @@ -8,9 +8,11 @@ import ( "net/url" "os" "os/exec" + "sort" "strings" "github.com/spf13/cobra" + "golang.org/x/term" "github.com/fullsend-ai/fullsend/internal/appsetup" "github.com/fullsend-ai/fullsend/internal/config" @@ -31,6 +33,8 @@ func newAdminCmd() *cobra.Command { cmd.AddCommand(newInstallCmd()) cmd.AddCommand(newUninstallCmd()) cmd.AddCommand(newAnalyzeCmd()) + cmd.AddCommand(newEnableCmd()) + cmd.AddCommand(newDisableCmd()) return cmd } @@ -1006,6 +1010,394 @@ func promptDispatchToken(ctx context.Context, client forge.Client, printer *ui.P return token, nil } +func newEnableCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "enable", + Short: "Enable fullsend features", + Long: "Commands for enabling fullsend features such as repository enrollment.", + } + cmd.AddCommand(newEnableReposCmd()) + return cmd +} + +func newDisableCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "disable", + Short: "Disable fullsend features", + Long: "Commands for disabling fullsend features such as repository enrollment.", + } + cmd.AddCommand(newDisableReposCmd()) + return cmd +} + +// reposRunFunc is the signature for repo enable/disable operations. +type reposRunFunc func(ctx context.Context, client forge.Client, printer *ui.Printer, org string, repos []string, all bool, yolo bool) error + +// newReposSubcommand creates a repos enable or disable subcommand with shared setup logic. +// If withYolo is true, the --yolo flag is added to skip confirmation prompts. +func newReposSubcommand(use, short, long, allFlagHelp string, runFn reposRunFunc, withYolo bool) *cobra.Command { + var all bool + var yolo bool + + cmd := &cobra.Command{ + Use: use, + Short: short, + Long: long, + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + org := args[0] + if err := validateOrgName(org); err != nil { + return err + } + + // When --all is set, ignore positional repo arguments. + // Otherwise, require at least one repo name. + var repos []string + if all { + // Ignore positional args; repos will be discovered from org + repos = nil + } else { + hasRepos := len(args) > 1 + if !hasRepos { + return fmt.Errorf("must specify repository names or use --all flag") + } + repos = args[1:] + } + + token, err := resolveToken() + if err != nil { + return err + } + + client := gh.New(token) + printer := ui.New(os.Stdout) + ctx := cmd.Context() + + return runFn(ctx, client, printer, org, repos, all, yolo) + }, + } + + cmd.Flags().BoolVar(&all, "all", false, allFlagHelp) + if withYolo { + cmd.Flags().BoolVar(&yolo, "yolo", false, "skip confirmation prompt") + } + + return cmd +} + +func newEnableReposCmd() *cobra.Command { + return newReposSubcommand( + "repos [repo...]", + "Enable repositories for fullsend enrollment", + "Enables the specified repositories for fullsend enrollment by updating config.yaml in the .fullsend repository. Use --all to enable all repositories (excluding .fullsend).", + "enable all repositories (excluding .fullsend)", + runEnableRepos, + false, // no confirmation prompt, so no --yolo flag + ) +} + +func newDisableReposCmd() *cobra.Command { + return newReposSubcommand( + "repos [repo...]", + "Disable repositories from fullsend enrollment", + "Disables the specified repositories from fullsend enrollment by updating config.yaml in the .fullsend repository. Use --all to disable all repositories.", + "disable all repositories", + runDisableRepos, + true, // has confirmation prompt for --all, so include --yolo flag + ) +} + +// runEnableRepos enables the specified repositories for fullsend enrollment. +// The yolo parameter is accepted for signature compatibility with reposRunFunc but is unused +// since enable has no destructive operations that require confirmation. +func runEnableRepos(ctx context.Context, client forge.Client, printer *ui.Printer, org string, repos []string, all bool, yolo bool) error { + printer.Banner() + printer.Blank() + printer.Header("Enabling repositories for " + org) + printer.Blank() + + // Load current config. + cfg, err := loadRepoConfig(ctx, client, printer, org) + if err != nil { + return err + } + + // Determine which repos to enable. + var reposToEnable []string + if all { + // Get all org repos by calling ListOrgRepos. + // Note: disable --all iterates cfg.Repos instead of calling ListOrgRepos. + // This asymmetry is intentional: enable --all discovers all current org repos, + // while disable --all operates on previously configured repos (which may have + // been deleted from the org but still need unenrollment PRs for cleanup). + printer.StepStart("Discovering all organization repositories") + allRepos, err := client.ListOrgRepos(ctx, org) + if err != nil { + printer.StepFail("Failed to list organization repositories") + printer.StepInfo("Hint: verify your token has 'repo' scope with: gh auth refresh -s repo") + return fmt.Errorf("listing org repos: %w", err) + } + for _, r := range allRepos { + if r.Name != forge.ConfigRepoName { + reposToEnable = append(reposToEnable, r.Name) + } + } + sort.Strings(reposToEnable) + printer.StepDone(fmt.Sprintf("Found %d repositories to enable", len(reposToEnable))) + } else { + // Validate provided repo names against org repos. + // Fetch org repos once and validate against the list instead of making + // one API call per repo (O(n) → O(1) API calls). + printer.StepStart("Validating repository names") + + allOrgRepos, err := client.ListOrgRepos(ctx, org) + if err != nil { + printer.StepFail("Failed to list organization repositories") + printer.StepInfo("Hint: verify your token has 'repo' scope with: gh auth refresh -s repo") + return fmt.Errorf("listing org repos: %w", err) + } + + // Build a set of valid repo names for O(1) lookup. + validRepos := make(map[string]bool, len(allOrgRepos)) + for _, r := range allOrgRepos { + validRepos[r.Name] = true + } + + // Validate each requested repo. + for _, repo := range repos { + if repo == forge.ConfigRepoName { + printer.StepFail("Cannot enable .fullsend repository") + return fmt.Errorf("cannot enable .fullsend repository itself") + } + if !validRepos[repo] { + printer.StepFail(fmt.Sprintf("Repository %s not found", repo)) + return fmt.Errorf("repository %s not found in %s", repo, org) + } + } + reposToEnable = repos + printer.StepDone("Repository names validated") + } + + if len(reposToEnable) == 0 { + printer.StepInfo("No repositories to enable") + return nil + } + + // Update config. + printer.StepStart("Updating config.yaml") + changed := 0 + for _, repo := range reposToEnable { + rc, exists := cfg.Repos[repo] + if !exists { + // Add new repo entry. + cfg.Repos[repo] = config.RepoConfig{Enabled: true} + changed++ + } else if !rc.Enabled { + // Update existing entry. + rc.Enabled = true + cfg.Repos[repo] = rc + changed++ + } + } + + if changed == 0 { + printer.StepInfo("All specified repositories are already enabled") + return nil + } + printer.StepDone(fmt.Sprintf("Updated %d repositories in config.yaml", changed)) + + // Save updated config. + commitMsg := fmt.Sprintf("chore: enable %d repositories for fullsend enrollment", changed) + if err := saveRepoConfig(ctx, client, printer, org, cfg, commitMsg); err != nil { + return err + } + + printer.Blank() + printer.Summary("Repositories enabled", []string{ + fmt.Sprintf("Organization: %s", org), + fmt.Sprintf("Enabled: %d repositories", changed), + "The repo-maintenance workflow will create enrollment PRs", + }) + + return nil +} + +// runDisableRepos disables the specified repositories from fullsend enrollment. +func runDisableRepos(ctx context.Context, client forge.Client, printer *ui.Printer, org string, repos []string, all bool, yolo bool) error { + printer.Banner() + printer.Blank() + printer.Header("Disabling repositories for " + org) + printer.Blank() + + // Load current config. + cfg, err := loadRepoConfig(ctx, client, printer, org) + if err != nil { + return err + } + + // Determine which repos to disable. + var reposToDisable []string + if all { + // Disable all repos currently in config. + printer.StepStart("Collecting all configured repositories") + for repo := range cfg.Repos { + reposToDisable = append(reposToDisable, repo) + } + sort.Strings(reposToDisable) + printer.StepDone(fmt.Sprintf("Found %d repositories to disable", len(reposToDisable))) + + // Prompt for confirmation when disabling all repos. + if !yolo && len(reposToDisable) > 0 { + printer.Blank() + printer.StepWarn(fmt.Sprintf("This will disable all %d repositories in %s.", len(reposToDisable), org)) + printer.StepInfo(fmt.Sprintf("Type the organization name (%s) to confirm:", org)) + + // Check if stdin is a terminal before prompting for input. + if !term.IsTerminal(int(os.Stdin.Fd())) { + return fmt.Errorf("stdin is not a terminal; use --yolo to skip confirmation in non-interactive environments") + } + + var confirmation string + if _, err := fmt.Scanln(&confirmation); err != nil { + return fmt.Errorf("reading confirmation: %w", err) + } + if confirmation != org { + return fmt.Errorf("confirmation did not match; aborting disable") + } + printer.Blank() + } + } else { + // Validate provided repo names against config (not GitHub). + // Unlike enable, disable is cleanup and must handle repos deleted from GitHub. + printer.StepStart("Validating repository names") + for _, repo := range repos { + if repo == forge.ConfigRepoName { + printer.StepFail("Cannot disable .fullsend repository") + return fmt.Errorf("cannot disable .fullsend repository itself") + } + // Check if repo exists in config (don't require GitHub existence for cleanup). + if _, exists := cfg.Repos[repo]; !exists { + printer.StepWarn(fmt.Sprintf("Repository %s not in config (skipping)", repo)) + continue + } + reposToDisable = append(reposToDisable, repo) + } + printer.StepDone("Repository names validated") + } + + if len(reposToDisable) == 0 { + printer.StepInfo("No repositories to disable") + return nil + } + + // Update config. + printer.StepStart("Updating config.yaml") + changed := 0 + for _, repo := range reposToDisable { + rc, exists := cfg.Repos[repo] + if exists && rc.Enabled { + // Update existing entry to disabled. + rc.Enabled = false + cfg.Repos[repo] = rc + changed++ + } + } + + if changed == 0 { + printer.StepInfo("All specified repositories are already disabled") + return nil + } + printer.StepDone(fmt.Sprintf("Updated %d repositories in config.yaml", changed)) + + // Save updated config. + commitMsg := fmt.Sprintf("chore: disable %d repositories from fullsend enrollment", changed) + if err := saveRepoConfig(ctx, client, printer, org, cfg, commitMsg); err != nil { + return err + } + + printer.Blank() + printer.Summary("Repositories disabled", []string{ + fmt.Sprintf("Organization: %s", org), + fmt.Sprintf("Disabled: %d repositories", changed), + "The repo-maintenance workflow will create unenrollment PRs", + }) + + return nil +} + +// loadRepoConfig verifies the .fullsend repository exists and loads config.yaml. +// +// Note: The read-modify-write pattern used by enable/disable (loadRepoConfig → +// modify → saveRepoConfig) has no optimistic concurrency control. Concurrent +// admin CLI invocations could race, with the last write winning. This is +// acceptable for an admin CLI where concurrent usage is rare, and the state +// is recoverable (just re-run the command). Production systems would use +// conditional writes (e.g., if-match headers with ETags). +func loadRepoConfig(ctx context.Context, client forge.Client, printer *ui.Printer, org string) (*config.OrgConfig, error) { + // Verify .fullsend repository exists. + printer.StepStart("Checking .fullsend repository") + _, err := client.GetRepo(ctx, org, forge.ConfigRepoName) + if err != nil { + if forge.IsNotFound(err) { + printer.StepFail(".fullsend repository not found") + return nil, fmt.Errorf(".fullsend repository not found: run 'fullsend admin install %s' first", org) + } + printer.StepFail("Failed to check .fullsend repository") + printer.StepInfo("Hint: verify your token has 'repo' scope with: gh auth refresh -s repo") + return nil, fmt.Errorf("checking .fullsend repository: %w", err) + } + printer.StepDone(".fullsend repository exists") + + // Get current config.yaml. + printer.StepStart("Reading config.yaml") + configData, err := client.GetFileContent(ctx, org, forge.ConfigRepoName, "config.yaml") + if err != nil { + printer.StepFail("Failed to read config.yaml") + printer.StepInfo("Hint: verify your token has 'repo' scope with: gh auth refresh -s repo") + return nil, fmt.Errorf("reading config.yaml: %w", err) + } + + cfg, err := config.ParseOrgConfig(configData) + if err != nil { + printer.StepFail("Failed to parse config.yaml") + return nil, fmt.Errorf("parsing config.yaml: %w", err) + } + printer.StepDone("Read config.yaml") + + return cfg, nil +} + +// saveRepoConfig marshals and commits the updated config, then triggers the repo-maintenance workflow. +func saveRepoConfig(ctx context.Context, client forge.Client, printer *ui.Printer, org string, cfg *config.OrgConfig, commitMsg string) error { + // Marshal updated config. + updatedConfigData, err := cfg.Marshal() + if err != nil { + printer.StepFail("Failed to marshal config.yaml") + return fmt.Errorf("marshaling config.yaml: %w", err) + } + + // Commit and push changes. + printer.StepStart("Committing changes to .fullsend") + if err := client.CreateOrUpdateFile(ctx, org, forge.ConfigRepoName, "config.yaml", commitMsg, updatedConfigData); err != nil { + printer.StepFail("Failed to commit changes") + printer.StepInfo("Hint: verify your token has 'repo' scope with: gh auth refresh -s repo") + return fmt.Errorf("committing config.yaml: %w", err) + } + printer.StepDone("Changes committed to .fullsend") + + // Trigger repo-maintenance workflow. + printer.StepStart("Triggering repo-maintenance workflow") + if err := client.DispatchWorkflow(ctx, org, forge.ConfigRepoName, "repo-maintenance.yml", "main", nil); err != nil { + printer.StepWarn(fmt.Sprintf("Failed to trigger repo-maintenance: %v", err)) + printer.StepInfo("Hint: verify your token has 'workflow' scope with: gh auth refresh -s workflow") + printer.StepInfo("Changes committed successfully, but you may need to manually trigger the workflow") + } else { + printer.StepDone("Triggered repo-maintenance workflow") + } + + return nil +} + // Helper functions. func repoNameList(repos []forge.Repository) []string { diff --git a/internal/cli/admin_test.go b/internal/cli/admin_test.go index d5f43cb2e2..73683e1189 100644 --- a/internal/cli/admin_test.go +++ b/internal/cli/admin_test.go @@ -3,11 +3,13 @@ package cli import ( "context" "fmt" + "sort" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/fullsend-ai/fullsend/internal/config" "github.com/fullsend-ai/fullsend/internal/forge" "github.com/fullsend-ai/fullsend/internal/ui" ) @@ -21,6 +23,8 @@ func TestAdminCommand_HasSubcommands(t *testing.T) { assert.True(t, names["install "], "expected install subcommand") assert.True(t, names["uninstall "], "expected uninstall subcommand") assert.True(t, names["analyze "], "expected analyze subcommand") + assert.True(t, names["enable"], "expected enable subcommand") + assert.True(t, names["disable"], "expected disable subcommand") } func TestInstallCmd_RequiresOrg(t *testing.T) { @@ -208,3 +212,418 @@ func TestEnsureConfigRepoExists_ReturnsError(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "checking for config repo") } + +func TestEnableCommand_HasReposSubcommand(t *testing.T) { + cmd := newEnableCmd() + names := make(map[string]bool) + for _, sub := range cmd.Commands() { + names[sub.Name()] = true + } + assert.True(t, names["repos"], "expected repos subcommand") +} + +func TestDisableCommand_HasReposSubcommand(t *testing.T) { + cmd := newDisableCmd() + names := make(map[string]bool) + for _, sub := range cmd.Commands() { + names[sub.Name()] = true + } + assert.True(t, names["repos"], "expected repos subcommand") +} + +func TestReposEnableCmd_RequiresOrg(t *testing.T) { + cmd := newRootCmd() + cmd.SetArgs([]string{"admin", "enable", "repos"}) + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "requires at least 1 arg") +} + +func TestReposEnableCmd_RequiresReposOrAllFlag(t *testing.T) { + cmd := newRootCmd() + // Set GH_TOKEN to avoid token resolution error. + t.Setenv("GH_TOKEN", "test-token") + cmd.SetArgs([]string{"admin", "enable", "repos", "testorg"}) + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "must specify repository names or use --all flag") +} + +func TestReposEnableCmd_HasAllFlag(t *testing.T) { + cmd := newEnableReposCmd() + allFlag := cmd.Flags().Lookup("all") + require.NotNil(t, allFlag, "expected --all flag") + assert.Equal(t, "false", allFlag.DefValue) +} + +func TestReposDisableCmd_RequiresOrg(t *testing.T) { + cmd := newRootCmd() + cmd.SetArgs([]string{"admin", "disable", "repos"}) + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "requires at least 1 arg") +} + +func TestReposDisableCmd_RequiresReposOrAllFlag(t *testing.T) { + cmd := newRootCmd() + // Set GH_TOKEN to avoid token resolution error. + t.Setenv("GH_TOKEN", "test-token") + cmd.SetArgs([]string{"admin", "disable", "repos", "testorg"}) + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "must specify repository names or use --all flag") +} + +func TestReposDisableCmd_HasAllFlag(t *testing.T) { + cmd := newDisableReposCmd() + allFlag := cmd.Flags().Lookup("all") + require.NotNil(t, allFlag, "expected --all flag") + assert.Equal(t, "false", allFlag.DefValue) +} + +func TestReposEnableCmd_AllIgnoresPositionalArgs(t *testing.T) { + // When --all is set, positional repo arguments are ignored + cfg := setupTestConfig(map[string]bool{ + "web-app": false, + "api": false, + }) + client := setupTestClient("testorg", cfg, []string{"web-app", "api"}) + printer := ui.New(&discardWriter{}) + + // Pass "web-app" as a positional arg, but --all should ignore it and enable both repos + err := runEnableRepos(context.Background(), client, printer, "testorg", []string{"web-app"}, true, true) + require.NoError(t, err) + + // Verify both repos were enabled (--all behavior), not just web-app + require.Len(t, client.CreatedFiles, 1) + updatedCfg, err := config.ParseOrgConfig(client.CreatedFiles[0].Content) + require.NoError(t, err) + assert.True(t, updatedCfg.Repos["web-app"].Enabled) + assert.True(t, updatedCfg.Repos["api"].Enabled) +} + +func TestReposDisableCmd_AllIgnoresPositionalArgs(t *testing.T) { + // When --all is set, positional repo arguments are ignored + cfg := setupTestConfig(map[string]bool{ + "web-app": true, + "api": true, + }) + client := setupTestClient("testorg", cfg, []string{"web-app", "api"}) + printer := ui.New(&discardWriter{}) + + // Pass "web-app" as a positional arg, but --all should ignore it and disable both repos + err := runDisableRepos(context.Background(), client, printer, "testorg", []string{"web-app"}, true, true) + require.NoError(t, err) + + // Verify both repos were disabled (--all behavior), not just web-app + require.Len(t, client.CreatedFiles, 1) + updatedCfg, err := config.ParseOrgConfig(client.CreatedFiles[0].Content) + require.NoError(t, err) + assert.False(t, updatedCfg.Repos["web-app"].Enabled) + assert.False(t, updatedCfg.Repos["api"].Enabled) +} + +// Test helpers + +func setupTestConfig(repos map[string]bool) *config.OrgConfig { + repoNames := make([]string, 0, len(repos)) + enabledRepos := make([]string, 0) + for name, enabled := range repos { + repoNames = append(repoNames, name) + if enabled { + enabledRepos = append(enabledRepos, name) + } + } + // Sort to ensure deterministic order despite map iteration being non-deterministic. + sort.Strings(repoNames) + sort.Strings(enabledRepos) + return config.NewOrgConfig(repoNames, enabledRepos, []string{"triage"}, nil, "") +} + +func setupTestClient(org string, cfg *config.OrgConfig, orgRepos []string) *forge.FakeClient { + client := forge.NewFakeClient() + client.Repos = []forge.Repository{ + {Name: ".fullsend", FullName: org + "/.fullsend"}, + } + for _, name := range orgRepos { + client.Repos = append(client.Repos, forge.Repository{ + Name: name, + FullName: org + "/" + name, + }) + } + if cfg != nil { + cfgData, _ := cfg.Marshal() + client.FileContents[org+"/.fullsend/config.yaml"] = cfgData + } + return client +} + +// Business logic tests for runEnableRepos + +func TestRunEnableRepos_EnableSingleRepo(t *testing.T) { + cfg := setupTestConfig(map[string]bool{ + "web-app": false, + "api": false, + }) + client := setupTestClient("testorg", cfg, []string{"web-app", "api"}) + printer := ui.New(&discardWriter{}) + + err := runEnableRepos(context.Background(), client, printer, "testorg", []string{"web-app"}, false, true) + require.NoError(t, err) + + // Verify config was updated. + require.Len(t, client.CreatedFiles, 1) + updatedCfg, err := config.ParseOrgConfig(client.CreatedFiles[0].Content) + require.NoError(t, err) + assert.True(t, updatedCfg.Repos["web-app"].Enabled) + assert.False(t, updatedCfg.Repos["api"].Enabled) +} + +func TestRunEnableRepos_EnableMultipleRepos(t *testing.T) { + cfg := setupTestConfig(map[string]bool{ + "web-app": false, + "api": false, + "docs": false, + }) + client := setupTestClient("testorg", cfg, []string{"web-app", "api", "docs"}) + printer := ui.New(&discardWriter{}) + + err := runEnableRepos(context.Background(), client, printer, "testorg", []string{"web-app", "docs"}, false, true) + require.NoError(t, err) + + // Verify config was updated. + require.Len(t, client.CreatedFiles, 1) + updatedCfg, err := config.ParseOrgConfig(client.CreatedFiles[0].Content) + require.NoError(t, err) + assert.True(t, updatedCfg.Repos["web-app"].Enabled) + assert.True(t, updatedCfg.Repos["docs"].Enabled) + assert.False(t, updatedCfg.Repos["api"].Enabled) +} + +func TestRunEnableRepos_EnableAllRepos(t *testing.T) { + cfg := setupTestConfig(map[string]bool{ + "web-app": false, + "api": false, + }) + client := setupTestClient("testorg", cfg, []string{"web-app", "api", "new-repo"}) + printer := ui.New(&discardWriter{}) + + err := runEnableRepos(context.Background(), client, printer, "testorg", nil, true, true) + require.NoError(t, err) + + // Verify all repos were enabled (excluding .fullsend). + require.Len(t, client.CreatedFiles, 1) + updatedCfg, err := config.ParseOrgConfig(client.CreatedFiles[0].Content) + require.NoError(t, err) + assert.True(t, updatedCfg.Repos["web-app"].Enabled) + assert.True(t, updatedCfg.Repos["api"].Enabled) + assert.True(t, updatedCfg.Repos["new-repo"].Enabled) + // .fullsend should not be in repos map. + _, hasFullsend := updatedCfg.Repos[".fullsend"] + assert.False(t, hasFullsend) +} + +func TestRunEnableRepos_NoOpWhenAlreadyEnabled(t *testing.T) { + cfg := setupTestConfig(map[string]bool{ + "web-app": true, + }) + client := setupTestClient("testorg", cfg, []string{"web-app"}) + printer := ui.New(&discardWriter{}) + + err := runEnableRepos(context.Background(), client, printer, "testorg", []string{"web-app"}, false, true) + require.NoError(t, err) + + // Verify no file was created (no changes). + assert.Empty(t, client.CreatedFiles) +} + +func TestRunEnableRepos_ErrorWhenFullsendRepoMissing(t *testing.T) { + client := forge.NewFakeClient() + printer := ui.New(&discardWriter{}) + + err := runEnableRepos(context.Background(), client, printer, "testorg", []string{"web-app"}, false, true) + require.Error(t, err) + assert.Contains(t, err.Error(), ".fullsend repository not found") +} + +func TestRunEnableRepos_ErrorWhenConfigMissing(t *testing.T) { + client := setupTestClient("testorg", nil, []string{"web-app"}) + printer := ui.New(&discardWriter{}) + + err := runEnableRepos(context.Background(), client, printer, "testorg", []string{"web-app"}, false, true) + require.Error(t, err) + assert.Contains(t, err.Error(), "reading config.yaml") +} + +func TestRunEnableRepos_ErrorWhenEnablingFullsend(t *testing.T) { + cfg := setupTestConfig(map[string]bool{ + "web-app": false, + }) + client := setupTestClient("testorg", cfg, []string{"web-app"}) + printer := ui.New(&discardWriter{}) + + err := runEnableRepos(context.Background(), client, printer, "testorg", []string{".fullsend"}, false, true) + require.Error(t, err) + assert.Contains(t, err.Error(), "cannot enable .fullsend repository") +} + +func TestRunEnableRepos_ErrorWhenRepoNotFound(t *testing.T) { + cfg := setupTestConfig(map[string]bool{ + "web-app": false, + }) + client := setupTestClient("testorg", cfg, []string{"web-app"}) + printer := ui.New(&discardWriter{}) + + err := runEnableRepos(context.Background(), client, printer, "testorg", []string{"nonexistent"}, false, true) + require.Error(t, err) + assert.Contains(t, err.Error(), "repository nonexistent not found") +} + +func TestRunEnableRepos_CommitMessageFormat(t *testing.T) { + cfg := setupTestConfig(map[string]bool{ + "web-app": false, + "api": false, + }) + client := setupTestClient("testorg", cfg, []string{"web-app", "api"}) + printer := ui.New(&discardWriter{}) + + err := runEnableRepos(context.Background(), client, printer, "testorg", []string{"web-app", "api"}, false, true) + require.NoError(t, err) + + require.Len(t, client.CreatedFiles, 1) + assert.Contains(t, client.CreatedFiles[0].Message, "chore: enable 2 repositories") +} + +// Business logic tests for runDisableRepos + +func TestRunDisableRepos_DisableSingleRepo(t *testing.T) { + cfg := setupTestConfig(map[string]bool{ + "web-app": true, + "api": true, + }) + client := setupTestClient("testorg", cfg, []string{"web-app", "api"}) + printer := ui.New(&discardWriter{}) + + err := runDisableRepos(context.Background(), client, printer, "testorg", []string{"web-app"}, false, true) + require.NoError(t, err) + + // Verify config was updated. + require.Len(t, client.CreatedFiles, 1) + updatedCfg, err := config.ParseOrgConfig(client.CreatedFiles[0].Content) + require.NoError(t, err) + assert.False(t, updatedCfg.Repos["web-app"].Enabled) + assert.True(t, updatedCfg.Repos["api"].Enabled) +} + +func TestRunDisableRepos_DisableMultipleRepos(t *testing.T) { + cfg := setupTestConfig(map[string]bool{ + "web-app": true, + "api": true, + "docs": true, + }) + client := setupTestClient("testorg", cfg, []string{"web-app", "api", "docs"}) + printer := ui.New(&discardWriter{}) + + err := runDisableRepos(context.Background(), client, printer, "testorg", []string{"web-app", "docs"}, false, true) + require.NoError(t, err) + + // Verify config was updated. + require.Len(t, client.CreatedFiles, 1) + updatedCfg, err := config.ParseOrgConfig(client.CreatedFiles[0].Content) + require.NoError(t, err) + assert.False(t, updatedCfg.Repos["web-app"].Enabled) + assert.False(t, updatedCfg.Repos["docs"].Enabled) + assert.True(t, updatedCfg.Repos["api"].Enabled) +} + +func TestRunDisableRepos_DisableAllRepos(t *testing.T) { + cfg := setupTestConfig(map[string]bool{ + "web-app": true, + "api": true, + }) + client := setupTestClient("testorg", cfg, []string{"web-app", "api"}) + printer := ui.New(&discardWriter{}) + + err := runDisableRepos(context.Background(), client, printer, "testorg", nil, true, true) + require.NoError(t, err) + + // Verify all repos were disabled. + require.Len(t, client.CreatedFiles, 1) + updatedCfg, err := config.ParseOrgConfig(client.CreatedFiles[0].Content) + require.NoError(t, err) + assert.False(t, updatedCfg.Repos["web-app"].Enabled) + assert.False(t, updatedCfg.Repos["api"].Enabled) +} + +func TestRunDisableRepos_NoOpWhenAlreadyDisabled(t *testing.T) { + cfg := setupTestConfig(map[string]bool{ + "web-app": false, + }) + client := setupTestClient("testorg", cfg, []string{"web-app"}) + printer := ui.New(&discardWriter{}) + + err := runDisableRepos(context.Background(), client, printer, "testorg", []string{"web-app"}, false, true) + require.NoError(t, err) + + // Verify no file was created (no changes). + assert.Empty(t, client.CreatedFiles) +} + +func TestRunDisableRepos_ErrorWhenFullsendRepoMissing(t *testing.T) { + client := forge.NewFakeClient() + printer := ui.New(&discardWriter{}) + + err := runDisableRepos(context.Background(), client, printer, "testorg", []string{"web-app"}, false, true) + require.Error(t, err) + assert.Contains(t, err.Error(), ".fullsend repository not found") +} + +func TestRunDisableRepos_ErrorWhenConfigMissing(t *testing.T) { + client := setupTestClient("testorg", nil, []string{"web-app"}) + printer := ui.New(&discardWriter{}) + + err := runDisableRepos(context.Background(), client, printer, "testorg", []string{"web-app"}, false, true) + require.Error(t, err) + assert.Contains(t, err.Error(), "reading config.yaml") +} + +func TestRunDisableRepos_ErrorWhenDisablingFullsend(t *testing.T) { + cfg := setupTestConfig(map[string]bool{ + "web-app": true, + }) + client := setupTestClient("testorg", cfg, []string{"web-app"}) + printer := ui.New(&discardWriter{}) + + err := runDisableRepos(context.Background(), client, printer, "testorg", []string{".fullsend"}, false, true) + require.Error(t, err) + assert.Contains(t, err.Error(), "cannot disable .fullsend repository") +} + +func TestRunDisableRepos_AllowsRepoNotInConfig(t *testing.T) { + // Disable should allow repos not in config (for cleanup of deleted repos). + cfg := setupTestConfig(map[string]bool{ + "web-app": true, + }) + client := setupTestClient("testorg", cfg, []string{"web-app"}) + printer := ui.New(&discardWriter{}) + + err := runDisableRepos(context.Background(), client, printer, "testorg", []string{"nonexistent"}, false, true) + require.NoError(t, err) + // Should succeed but make no changes (repo not in config, nothing to disable) + assert.Len(t, client.CreatedFiles, 0) +} + +func TestRunDisableRepos_CommitMessageFormat(t *testing.T) { + cfg := setupTestConfig(map[string]bool{ + "web-app": true, + "api": true, + }) + client := setupTestClient("testorg", cfg, []string{"web-app", "api"}) + printer := ui.New(&discardWriter{}) + + err := runDisableRepos(context.Background(), client, printer, "testorg", []string{"web-app", "api"}, false, true) + require.NoError(t, err) + + require.Len(t, client.CreatedFiles, 1) + assert.Contains(t, client.CreatedFiles[0].Message, "chore: disable 2 repositories") +}