From dc8712560f9f45482965b4db254d321d931ab1b5 Mon Sep 17 00:00:00 2001 From: Greg Allen Date: Sun, 5 Jul 2026 22:17:29 -0400 Subject: [PATCH] feat(repos): add repos init command for manifest bootstrapping Signed-off-by: Claude Signed-off-by: Greg Allen Co-Authored-By: Claude Opus 4.6 Signed-off-by: Greg Allen Co-Authored-By: Claude Opus 4.6 Signed-off-by: Greg Allen Co-Authored-By: Claude Opus 4.6 Signed-off-by: Greg Allen --- docs/cli/repos.md | 47 + docs/guides/dev/cli-internals.md | 9 + docs/guides/getting-started/operations.md | 1 + internal/cli/repos.go | 142 ++- internal/cli/repos_test.go | 81 +- internal/cli/root.go | 1 + internal/repos/init.go | 525 +++++++++ internal/repos/init_test.go | 1181 +++++++++++++++++++++ 8 files changed, 1983 insertions(+), 4 deletions(-) create mode 100644 internal/repos/init.go create mode 100644 internal/repos/init_test.go diff --git a/docs/cli/repos.md b/docs/cli/repos.md index fe60ea875..2a83778f5 100644 --- a/docs/cli/repos.md +++ b/docs/cli/repos.md @@ -10,8 +10,55 @@ Manage per-repo installations across multiple orgs via a declarative `repos.yaml | Command | Description | |---------|-------------| +| `fullsend repos init ` | Generate a repos.yaml manifest by discovering existing installations | | `fullsend repos status` | Compare manifest against actual repo state | +## `repos init` + +Discovers existing fullsend installations (per-repo and per-org) and generates a `repos.yaml` manifest reflecting their current state. Supports greenfield onboarding and migration from existing installations. + +```bash +fullsend repos init --all --mint-project --inference-project +``` + +Single-repo mode: + +```bash +fullsend repos init --mint-project +``` + +### Flags + +| Flag | Default | Description | +|------|---------|-------------| +| `--output`, `-o` | `repos.yaml` | Output path (use `-` for stdout) | +| `--repos` | | Comma-separated list of repos to include | +| `--all` | `false` | Include all eligible repos without prompting | +| `--mint-project` | | GCP project for the mint | +| `--mint-region` | `us-central1` | GCP region for the mint | +| `--inference-project` | | Default GCP project for inference | +| `--concurrency` | `8` | Max parallel API calls (capped at 64) | +| `--force` | `false` | Overwrite output file if it already exists | + +### Discovery + +The command discovers repos by checking: + +1. **Per-repo guard variable** (`FULLSEND_PER_REPO_INSTALL`) — identifies per-repo installations +2. **Per-org config enrollment** (`config.yaml` in `.fullsend` repo) — identifies per-org installations +3. **Workflow ref** — extracts the `@ref` from scaffold shim workflow files + +### Defaults computation + +Default values for `fullsend_ref` and `inference_region` are computed using the mode (most common value) across discovered repos. Per-repo overrides are generated only for fields that differ from defaults. + +### Selection modes + +For org targets, one of `--all` or `--repos` is required: + +- `--all`: include all discovered repos +- `--repos`: include only the specified repos (comma-separated `owner/repo` names) + ## `repos status` Read-only comparison of the `repos.yaml` manifest against actual forge state. Reports installation status and configuration drift for each repo. diff --git a/docs/guides/dev/cli-internals.md b/docs/guides/dev/cli-internals.md index 67c34490d..11dafe582 100644 --- a/docs/guides/dev/cli-internals.md +++ b/docs/guides/dev/cli-internals.md @@ -39,6 +39,15 @@ fullsend │ ├── uninstall # Remove fullsend GitHub configuration │ └── sync-scaffold # Update workflow templates ├── repos # Manage per-repo installations via manifest +│ ├── init # Generate repos.yaml from discovered installs +│ │ ├── --output, -o # Output path (default: repos.yaml, - for stdout) +│ │ ├── --repos # Comma-separated repos to include +│ │ ├── --all # Include all eligible repos +│ │ ├── --mint-project # GCP project for the mint +│ │ ├── --mint-region # GCP region for the mint (default: us-central1) +│ │ ├── --inference-project # Default GCP project for inference +│ │ ├── --force # Overwrite output file if it exists +│ │ └── --concurrency # Max parallel API calls (default: 8) │ └── status # Compare manifest against actual repo state ├── agent # Manage agent registrations in config │ ├── add # Register an agent (URL auto-pinned) diff --git a/docs/guides/getting-started/operations.md b/docs/guides/getting-started/operations.md index 2975959ef..6e55322b3 100644 --- a/docs/guides/getting-started/operations.md +++ b/docs/guides/getting-started/operations.md @@ -71,6 +71,7 @@ For organizations that separate GCP and GitHub responsibilities across teams, fu | GCP Admin (Mint) | `fullsend mint unenroll ` | Remove an org or repo from the mint | | GCP Admin (Mint) | `fullsend mint status` | Inspect mint state and PEM health | +| Fleet Admin | `fullsend repos init ` | Generate a `repos.yaml` manifest by discovering existing installations | | Fleet Admin | `fullsend repos status` | Compare `repos.yaml` manifest against actual per-repo state (drift detection) | | Developer | `fullsend agent add ` | Register an agent in config (URL auto-pinned to commit SHA) | diff --git a/internal/cli/repos.go b/internal/cli/repos.go index 8acfd0825..638fb6cc5 100644 --- a/internal/cli/repos.go +++ b/internal/cli/repos.go @@ -2,11 +2,16 @@ package cli import ( "encoding/json" + "errors" "fmt" + "os" "strings" - "github.com/fullsend-ai/fullsend/internal/repos" "github.com/spf13/cobra" + + gh "github.com/fullsend-ai/fullsend/internal/forge/github" + "github.com/fullsend-ai/fullsend/internal/repos" + "github.com/fullsend-ai/fullsend/internal/ui" ) func newReposCmd() *cobra.Command { @@ -15,10 +20,145 @@ func newReposCmd() *cobra.Command { Short: "Manage per-repo installations across multiple orgs", Long: "Commands for managing fullsend per-repo installations at scale via a declarative repos.yaml manifest.", } + cmd.AddCommand(newReposInitCmd()) cmd.AddCommand(newReposStatusCmd()) return cmd } +type reposInitConfig struct { + output string + repoNames string + all bool + mintProject string + mintRegion string + inferenceProject string + concurrency int + force bool +} + +func newReposInitCmd() *cobra.Command { + var cfg reposInitConfig + + cmd := &cobra.Command{ + Use: "init ", + Short: "Generate a repos.yaml manifest by discovering existing installations", + Long: `Discovers existing fullsend installations (per-repo and per-org) and +generates a repos.yaml manifest reflecting their current state. + +For greenfield onboarding, select which repos to include and the command +generates a manifest with default config. For migration from existing +installations, the command discovers their state and generates a manifest +that reflects current reality.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + target := args[0] + + token, err := resolveToken() + if err != nil { + return err + } + client := gh.New(token) + printerOut := os.Stdout + if cfg.output == "-" { + printerOut = os.Stderr + } + printer := ui.New(printerOut) + printer.Banner(Version()) + ctx := cmd.Context() + + owner := target + if idx := strings.IndexByte(target, '/'); idx >= 0 { + owner = target[:idx] + } + if err := validateOrgName(owner); err != nil { + return err + } + + initCfg := repos.InitConfig{ + Target: target, + All: cfg.all, + MintProject: cfg.mintProject, + MintRegion: cfg.mintRegion, + InferenceProject: cfg.inferenceProject, + MaxConcurrency: cfg.concurrency, + CLIVersion: version, + } + + if cfg.repoNames != "" { + parts := strings.Split(cfg.repoNames, ",") + for i, p := range parts { + parts[i] = strings.TrimSpace(p) + } + initCfg.Repos = parts + } + + progress := func(repo, phase, message string) { + printer.StepInfo(fmt.Sprintf("[%s] %s: %s", phase, repo, message)) + } + + result, err := repos.Init(ctx, initCfg, client, nil, progress) + if err != nil { + return err + } + + data, err := repos.MarshalWithHeader(result.Manifest) + if err != nil { + return err + } + + if cfg.output == "-" { + fmt.Print(string(data)) + } else { + if !cfg.force { + if _, statErr := os.Stat(cfg.output); statErr == nil { + return fmt.Errorf("output file %s already exists (use --force to overwrite)", cfg.output) + } else if !errors.Is(statErr, os.ErrNotExist) { + return fmt.Errorf("checking output file: %w", statErr) + } + } + if writeErr := os.WriteFile(cfg.output, data, 0o644); writeErr != nil { + return fmt.Errorf("writing manifest: %w", writeErr) + } + printer.StepDone(fmt.Sprintf("Manifest written to %s", cfg.output)) + } + + printer.Blank() + printer.StepInfo(fmt.Sprintf("Discovered: %d per-repo, %d per-org, %d new", + result.PerRepoCount, result.PerOrgCount, result.NewCount)) + + if len(result.Errors) > 0 { + printer.Blank() + printer.StepWarn(fmt.Sprintf("Discovery failed for %d repos (excluded from manifest):", len(result.Errors))) + for _, e := range result.Errors { + printer.StepWarn("- " + e) + } + } + + if len(result.TODOs) > 0 { + printer.Blank() + printer.StepInfo("TODOs (fields requiring manual attention):") + for _, todo := range result.TODOs { + printer.StepInfo("- " + todo) + } + } + + return nil + }, + } + + cmd.Flags().StringVarP(&cfg.output, "output", "o", "repos.yaml", "output path (use - for stdout)") + cmd.Flags().StringVar(&cfg.repoNames, "repos", "", "comma-separated list of repos to include") + cmd.Flags().BoolVar(&cfg.all, "all", false, "include all eligible repos without prompting") + cmd.Flags().StringVar(&cfg.mintProject, "mint-project", "", "GCP project for the mint") + cmd.Flags().StringVar(&cfg.mintRegion, "mint-region", "us-central1", "GCP region for the mint") + cmd.Flags().StringVar(&cfg.inferenceProject, "inference-project", "", "default GCP project for inference") + cmd.Flags().IntVar(&cfg.concurrency, "concurrency", 8, "max parallel API calls (capped at 64)") + cmd.Flags().BoolVar(&cfg.force, "force", false, "overwrite output file if it already exists") + cmd.MarkFlagsMutuallyExclusive("repos", "all") + + return cmd +} + func newReposStatusCmd() *cobra.Command { var ( manifest string diff --git a/internal/cli/repos_test.go b/internal/cli/repos_test.go index dfc54e364..63bc6ccea 100644 --- a/internal/cli/repos_test.go +++ b/internal/cli/repos_test.go @@ -14,11 +14,86 @@ func TestReposCommand_HasSubcommands(t *testing.T) { cmd := newReposCmd() names := make(map[string]bool) for _, sub := range cmd.Commands() { - names[sub.Use] = true + names[sub.Name()] = true } + assert.True(t, names["init"], "expected init subcommand") assert.True(t, names["status"], "expected status subcommand") } +func TestReposCommand_RegisteredInRoot(t *testing.T) { + cmd := newRootCmd() + names := make(map[string]bool) + for _, sub := range cmd.Commands() { + names[sub.Name()] = true + } + assert.True(t, names["repos"], "expected repos subcommand on root") +} + +func TestReposInitCmd_RequiresArg(t *testing.T) { + cmd := newRootCmd() + cmd.SetArgs([]string{"repos", "init"}) + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "accepts 1 arg(s)") +} + +func TestReposInitCmd_Flags(t *testing.T) { + cmd := newReposInitCmd() + + outputFlag := cmd.Flags().Lookup("output") + require.NotNil(t, outputFlag, "expected --output flag") + assert.Equal(t, "repos.yaml", outputFlag.DefValue) + + reposFlag := cmd.Flags().Lookup("repos") + require.NotNil(t, reposFlag, "expected --repos flag") + + allFlag := cmd.Flags().Lookup("all") + require.NotNil(t, allFlag, "expected --all flag") + assert.Equal(t, "false", allFlag.DefValue) + + mintProjectFlag := cmd.Flags().Lookup("mint-project") + require.NotNil(t, mintProjectFlag, "expected --mint-project flag") + + mintRegionFlag := cmd.Flags().Lookup("mint-region") + require.NotNil(t, mintRegionFlag, "expected --mint-region flag") + assert.Equal(t, "us-central1", mintRegionFlag.DefValue) + + inferenceProjectFlag := cmd.Flags().Lookup("inference-project") + require.NotNil(t, inferenceProjectFlag, "expected --inference-project flag") + + concurrencyFlag := cmd.Flags().Lookup("concurrency") + require.NotNil(t, concurrencyFlag, "expected --concurrency flag") + assert.Equal(t, "8", concurrencyFlag.DefValue) + + forceFlag := cmd.Flags().Lookup("force") + require.NotNil(t, forceFlag, "expected --force flag") + assert.Equal(t, "false", forceFlag.DefValue) +} + +func TestReposInitCmd_OutputShorthand(t *testing.T) { + cmd := newReposInitCmd() + outputFlag := cmd.Flags().ShorthandLookup("o") + require.NotNil(t, outputFlag, "expected -o shorthand for --output") +} + +func TestReposInitCmd_ValidatesOrgName(t *testing.T) { + t.Setenv("GH_TOKEN", "test-token") + cmd := newRootCmd() + cmd.SetArgs([]string{"repos", "init", "--", "-invalid"}) + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "cannot start or end with a hyphen") +} + +func TestReposInitCmd_ReposAllMutuallyExclusive(t *testing.T) { + t.Setenv("GH_TOKEN", "test-token") + cmd := newRootCmd() + cmd.SetArgs([]string{"repos", "init", "test-org", "--all", "--repos", "foo/bar"}) + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "if any flags in the group [repos all] are set none of the others can be") +} + func TestReposStatusCmd_Flags(t *testing.T) { cmd := newReposStatusCmd() @@ -240,11 +315,11 @@ func TestReposStatusCmd_WiredToRoot(t *testing.T) { root := newRootCmd() found := false for _, cmd := range root.Commands() { - if cmd.Use == "repos" { + if cmd.Name() == "repos" { found = true statusFound := false for _, sub := range cmd.Commands() { - if sub.Use == "status" { + if sub.Name() == "status" { statusFound = true } } diff --git a/internal/cli/root.go b/internal/cli/root.go index 74173e177..15196e691 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -52,6 +52,7 @@ func newRootCmd() *cobra.Command { cmd.AddCommand(newPostReviewCmd()) cmd.AddCommand(newPostCommentCmd()) cmd.AddCommand(newReconcileStatusCmd()) + cmd.AddCommand(newReposCmd()) return cmd } diff --git a/internal/repos/init.go b/internal/repos/init.go new file mode 100644 index 000000000..d0b56450a --- /dev/null +++ b/internal/repos/init.go @@ -0,0 +1,525 @@ +package repos + +import ( + "context" + "fmt" + "sort" + "strings" + "sync" + "time" + + "github.com/fullsend-ai/fullsend/internal/config" + "github.com/fullsend-ai/fullsend/internal/forge" + "gopkg.in/yaml.v3" +) + +// InitConfig holds configuration for the repos init command. +type InitConfig struct { + Target string + Repos []string + All bool + MintProject string + MintRegion string + InferenceProject string + MaxConcurrency int + CLIVersion string +} + +// DiscoveredRepo holds the result of discovering a single repo's +// fullsend installation status. +type DiscoveredRepo struct { + Owner string + Repo string + Source string // "per-repo", "per-org", or "new" + MintURL string + InferenceRegion string + FullsendRef string +} + +// RepoCandidate is presented to the interactive selection callback. +type RepoCandidate struct { + Owner string + Repo string + Status string // "per-repo", "per-org", "new" + Ref string +} + +// RepoSelectFunc is a callback the CLI layer provides to handle +// interactive repo selection. It receives candidates and returns +// the full names (owner/repo) the operator selected. +type RepoSelectFunc func(candidates []RepoCandidate) ([]string, error) + +// InitResult holds the output of Init. +type InitResult struct { + Manifest *Manifest + PerRepoCount int + PerOrgCount int + NewCount int + TODOs []string + Errors []string +} + +// Init discovers existing fullsend installations and generates a +// repos.yaml manifest. It supports both greenfield onboarding and +// migration from existing per-repo or per-org installations. +func Init(ctx context.Context, cfg InitConfig, client forge.Client, + selectRepos RepoSelectFunc, progress ProgressFunc) (*InitResult, error) { + + if cfg.MaxConcurrency <= 0 { + cfg.MaxConcurrency = 8 + } + if cfg.MaxConcurrency > 64 { + cfg.MaxConcurrency = 64 + } + if progress == nil { + progress = func(_, _, _ string) {} + } + + owner, repo, isRepo, err := parseInitTarget(cfg.Target) + if err != nil { + return nil, err + } + + if isRepo { + if cfg.All { + return nil, fmt.Errorf("--all flag cannot be used with a single repo target") + } + if cfg.Repos != nil { + return nil, fmt.Errorf("--repos flag cannot be used with a single repo target") + } + return initSingleRepo(ctx, cfg, client, owner, repo, progress) + } + return initOrg(ctx, cfg, client, owner, selectRepos, progress) +} + +func parseInitTarget(target string) (owner, repo string, isRepo bool, err error) { + if target == "" { + return "", "", false, fmt.Errorf("target cannot be empty") + } + if strings.Contains(target, "/") { + if strings.Count(target, "/") > 1 { + return "", "", false, fmt.Errorf("invalid target %q: expected org or owner/repo format", target) + } + parts := strings.SplitN(target, "/", 2) + if parts[0] == "" || parts[1] == "" { + return "", "", false, fmt.Errorf("invalid target %q: both owner and repo must be non-empty", target) + } + return parts[0], parts[1], true, nil + } + return target, "", false, nil +} + +// initSingleRepo discovers a single repo and generates a one-entry manifest. +func initSingleRepo(ctx context.Context, cfg InitConfig, client forge.Client, + owner, repo string, progress ProgressFunc) (*InitResult, error) { + + progress(owner+"/"+repo, "discover", "checking installation status") + + // Check for per-org config if the repo isn't per-repo installed. + var orgCfg *config.OrgConfig + progress(owner, "discover", "checking for per-org config") + configData, err := client.GetFileContent(ctx, owner, forge.ConfigRepoName, "config.yaml") + if err == nil { + orgCfg, err = config.ParseOrgConfig(configData) + if err != nil { + progress(owner, "discover", fmt.Sprintf("warning: could not parse per-org config: %v", err)) + orgCfg = nil + } + } else if !forge.IsNotFound(err) { + return nil, fmt.Errorf("fetching org config for %s: %w", owner, err) + } + + discovered, err := discoverRepo(ctx, client, owner, repo, orgCfg, progress) + if err != nil { + return nil, fmt.Errorf("discovering %s/%s: %w", owner, repo, err) + } + + manifest, todos := buildManifest([]DiscoveredRepo{discovered}, cfg) + result := &InitResult{ + Manifest: manifest, + TODOs: todos, + } + countSources([]DiscoveredRepo{discovered}, result) + return result, nil +} + +// initOrg discovers all repos in an org and generates a manifest. +func initOrg(ctx context.Context, cfg InitConfig, client forge.Client, + org string, selectRepos RepoSelectFunc, progress ProgressFunc) (*InitResult, error) { + + progress(org, "discover", "listing org repos") + allOrgRepos, err := client.ListOrgRepos(ctx, org) + if err != nil { + return nil, fmt.Errorf("listing repos for org %s: %w", org, err) + } + + // Exclude the org config repo from discovery. + orgRepos := make([]forge.Repository, 0, len(allOrgRepos)) + for _, r := range allOrgRepos { + if r.Name != forge.ConfigRepoName { + orgRepos = append(orgRepos, r) + } + } + + // Read per-org config if it exists. + var orgCfg *config.OrgConfig + progress(org, "discover", "checking for per-org config") + configData, err := client.GetFileContent(ctx, org, forge.ConfigRepoName, "config.yaml") + if err == nil { + orgCfg, err = config.ParseOrgConfig(configData) + if err != nil { + return nil, fmt.Errorf("parsing per-org config for %s: %w", org, err) + } + } else if !forge.IsNotFound(err) { + return nil, fmt.Errorf("fetching org config for %s: %w", org, err) + } + + // Discover repos in parallel. + discovery := discoverReposParallel(ctx, client, org, orgRepos, orgCfg, cfg.MaxConcurrency, progress) + + // Build candidates for selection. + candidates := make([]RepoCandidate, 0, len(discovery.repos)) + for _, d := range discovery.repos { + candidates = append(candidates, RepoCandidate{ + Owner: d.Owner, + Repo: d.Repo, + Status: d.Source, + Ref: d.FullsendRef, + }) + } + + // Select repos based on mode. + selected, err := selectInitRepos(cfg, candidates, discovery.errors, selectRepos) + if err != nil { + return nil, err + } + + // Filter discovered repos to selected set. + selectedSet := make(map[string]bool, len(selected)) + for _, name := range selected { + selectedSet[name] = true + } + var filtered []DiscoveredRepo + for _, d := range discovery.repos { + if selectedSet[d.Owner+"/"+d.Repo] { + filtered = append(filtered, d) + } + } + + manifest, todos := buildManifest(filtered, cfg) + result := &InitResult{ + Manifest: manifest, + TODOs: todos, + Errors: discovery.errors, + } + countSources(filtered, result) + return result, nil +} + +func selectInitRepos(cfg InitConfig, candidates []RepoCandidate, + discoveryErrors []string, selectRepos RepoSelectFunc) ([]string, error) { + + if cfg.Repos != nil { + if len(cfg.Repos) == 0 { + return nil, fmt.Errorf("--repos list is empty") + } + // Explicit mode: validate all names exist in candidates. + candidateSet := make(map[string]bool, len(candidates)) + for _, c := range candidates { + candidateSet[c.Owner+"/"+c.Repo] = true + } + for _, name := range cfg.Repos { + if !candidateSet[name] { + for _, e := range discoveryErrors { + if strings.HasPrefix(e, name+":") { + return nil, fmt.Errorf("repo %q failed discovery: %s", name, e) + } + } + return nil, fmt.Errorf("repo %q not found in org", name) + } + } + return cfg.Repos, nil + } + + if cfg.All { + names := make([]string, 0, len(candidates)) + for _, c := range candidates { + names = append(names, c.Owner+"/"+c.Repo) + } + return names, nil + } + + // Interactive mode. + if selectRepos == nil { + return nil, fmt.Errorf("org target requires --all or --repos flag") + } + return selectRepos(candidates) +} + +type discoveryResult struct { + repos []DiscoveredRepo + errors []string +} + +func discoverReposParallel(ctx context.Context, client forge.Client, + org string, repos []forge.Repository, orgCfg *config.OrgConfig, + maxConcurrency int, progress ProgressFunc) discoveryResult { + + type indexedRepo struct { + idx int + repo DiscoveredRepo + } + + sem := make(chan struct{}, maxConcurrency) + var wg sync.WaitGroup + var mu sync.Mutex + var discovered []indexedRepo + var errors []string + + for i, r := range repos { + wg.Add(1) + go func(idx int, repo forge.Repository) { + defer wg.Done() + select { + case sem <- struct{}{}: + case <-ctx.Done(): + mu.Lock() + errors = append(errors, fmt.Sprintf("%s/%s: %v", org, repo.Name, ctx.Err())) + mu.Unlock() + return + } + defer func() { <-sem }() + + d, err := discoverRepo(ctx, client, org, repo.Name, orgCfg, progress) + if err != nil { + progress(org+"/"+repo.Name, "discover", fmt.Sprintf("error: %v", err)) + mu.Lock() + errors = append(errors, fmt.Sprintf("%s/%s: %v", org, repo.Name, err)) + mu.Unlock() + return + } + + mu.Lock() + discovered = append(discovered, indexedRepo{idx: idx, repo: d}) + mu.Unlock() + }(i, r) + } + wg.Wait() + + sort.Slice(discovered, func(i, j int) bool { + return discovered[i].idx < discovered[j].idx + }) + results := make([]DiscoveredRepo, 0, len(discovered)) + for _, r := range discovered { + results = append(results, r.repo) + } + sort.Strings(errors) + + return discoveryResult{repos: results, errors: errors} +} + +// discoverRepo checks the installation status of a single repository. +func discoverRepo(ctx context.Context, client forge.Client, + owner, repo string, orgCfg *config.OrgConfig, progress ProgressFunc) (DiscoveredRepo, error) { + + fullName := owner + "/" + repo + progress(fullName, "discover", "reading variables") + + vars, err := client.ListRepoVariables(ctx, owner, repo) + if err != nil { + return DiscoveredRepo{}, fmt.Errorf("listing variables for %s: %w", fullName, err) + } + + // Check for per-repo installation. + if vars[forge.PerRepoGuardVar] == "true" { + progress(fullName, "discover", "per-repo installation detected") + ref, err := readWorkflowRef(ctx, client, owner, repo) + if err != nil { + return DiscoveredRepo{}, err + } + if ref != "" { + progress(fullName, "discover", fmt.Sprintf("ref: %s", ref)) + } + return DiscoveredRepo{ + Owner: owner, + Repo: repo, + Source: "per-repo", + MintURL: vars["FULLSEND_MINT_URL"], + InferenceRegion: vars["FULLSEND_GCP_REGION"], + FullsendRef: ref, + }, nil + } + + // Check for per-org enrollment. + if orgCfg != nil { + if repoConfig, exists := orgCfg.Repos[repo]; exists && repoConfig.Enabled { + progress(fullName, "discover", "per-org enrollment detected") + ref, err := readWorkflowRef(ctx, client, owner, repo) + if err != nil { + return DiscoveredRepo{}, err + } + if ref != "" { + progress(fullName, "discover", fmt.Sprintf("ref: %s", ref)) + } + d := DiscoveredRepo{ + Owner: owner, + Repo: repo, + Source: "per-org", + } + if orgCfg.Dispatch.MintURL != "" { + d.MintURL = orgCfg.Dispatch.MintURL + } + d.FullsendRef = ref + return d, nil + } + } + + // Not installed. + progress(fullName, "discover", "not installed") + return DiscoveredRepo{ + Owner: owner, + Repo: repo, + Source: "new", + }, nil +} + +// buildManifest generates a Manifest from discovered repos and config. +func buildManifest(repos []DiscoveredRepo, cfg InitConfig) (*Manifest, []string) { + var todos []string + + // Compute mint block. + mintURL := computeMode(repos, func(d DiscoveredRepo) string { return d.MintURL }) + if mintURL == "" { + mintURL = "# TODO: set mint URL" + todos = append(todos, "mint.url: set the Cloud Run endpoint URL") + } else if countDistinct(repos, func(d DiscoveredRepo) string { return d.MintURL }) > 1 { + todos = append(todos, "mint.url: multiple mint URLs discovered; using most common — verify correctness") + } + + mintProject := cfg.MintProject + if mintProject == "" { + mintProject = "# TODO: set GCP project" + todos = append(todos, "mint.project: provide via --mint-project flag") + } + + mintRegion := cfg.MintRegion + if mintRegion == "" { + mintRegion = "us-central1" + } + + // Compute defaults. + defaultRef := computeMode(repos, func(d DiscoveredRepo) string { return d.FullsendRef }) + if defaultRef == "" { + if cfg.CLIVersion != "" && cfg.CLIVersion != "dev" { + defaultRef = "v" + cfg.CLIVersion + } else { + defaultRef = config.DefaultUpstreamRef + } + } + + defaultRegion := computeMode(repos, func(d DiscoveredRepo) string { return d.InferenceRegion }) + if defaultRegion == "" { + defaultRegion = cfg.MintRegion + if defaultRegion == "" { + defaultRegion = "us-central1" + } + } + + inferenceProject := cfg.InferenceProject + if inferenceProject == "" { + inferenceProject = "# TODO: set inference GCP project" + todos = append(todos, "defaults.inference_project: provide via --inference-project flag") + } + + manifest := &Manifest{ + Version: 1, + Mint: MintConfig{ + URL: mintURL, + Project: mintProject, + Region: mintRegion, + }, + Defaults: DefaultsConfig{ + InferenceProject: inferenceProject, + InferenceRegion: defaultRegion, + FullsendRef: defaultRef, + }, + } + + // Build repo entries. + for _, d := range repos { + entry := RepoEntry{Repo: d.Owner + "/" + d.Repo} + + // Add per-repo overrides only for fields that differ from defaults. + if d.FullsendRef != "" && d.FullsendRef != defaultRef { + entry.FullsendRef = NullableString{Set: true, Value: d.FullsendRef} + } + if d.InferenceRegion != "" && d.InferenceRegion != defaultRegion { + entry.InferenceRegion = NullableString{Set: true, Value: d.InferenceRegion} + } + + manifest.Repos = append(manifest.Repos, entry) + } + + return manifest, todos +} + +// computeMode returns the most common non-empty value across repos. +func computeMode(repos []DiscoveredRepo, extract func(DiscoveredRepo) string) string { + counts := make(map[string]int) + for _, r := range repos { + v := extract(r) + if v != "" { + counts[v]++ + } + } + if len(counts) == 0 { + return "" + } + var best string + var bestCount int + for v, c := range counts { + if c > bestCount || (c == bestCount && v < best) { + best = v + bestCount = c + } + } + return best +} + +// countDistinct returns the number of distinct non-empty values. +func countDistinct(repos []DiscoveredRepo, extract func(DiscoveredRepo) string) int { + seen := make(map[string]bool) + for _, r := range repos { + v := extract(r) + if v != "" { + seen[v] = true + } + } + return len(seen) +} + +func countSources(repos []DiscoveredRepo, result *InitResult) { + for _, d := range repos { + switch d.Source { + case "per-repo": + result.PerRepoCount++ + case "per-org": + result.PerOrgCount++ + case "new": + result.NewCount++ + } + } +} + +// MarshalWithHeader serializes the manifest with a descriptive header comment. +func MarshalWithHeader(m *Manifest) ([]byte, error) { + data, err := yaml.Marshal(m) + if err != nil { + return nil, fmt.Errorf("marshalling manifest: %w", err) + } + + header := fmt.Sprintf("# Generated by fullsend repos init on %s.\n# Review and adjust before running fullsend repos install.\n", + time.Now().UTC().Format("2006-01-02")) + + return append([]byte(header), data...), nil +} diff --git a/internal/repos/init_test.go b/internal/repos/init_test.go new file mode 100644 index 000000000..d26364192 --- /dev/null +++ b/internal/repos/init_test.go @@ -0,0 +1,1181 @@ +package repos + +import ( + "context" + "fmt" + "testing" + + "github.com/fullsend-ai/fullsend/internal/config" + "github.com/fullsend-ai/fullsend/internal/forge" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +// newFakeWithOrgRepos returns a FakeClient with org repos and per-repo +// variables pre-populated. This is the standard setup helper for init tests. +func newFakeWithOrgRepos(org string, repos []forge.Repository) *forge.FakeClient { + fc := forge.NewFakeClient() + fc.OrgRepos = map[string][]forge.Repository{org: repos} + return fc +} + +func setRepoVars(fc *forge.FakeClient, owner, repo string, vars map[string]string) { + for k, v := range vars { + fc.VariableValues[owner+"/"+repo+"/"+k] = v + } +} + +func setWorkflowFile(fc *forge.FakeClient, owner, repo, content string) { + fc.FileContents[owner+"/"+repo+"/.github/workflows/fullsend.yml"] = []byte(content) +} + +func setOrgConfig(fc *forge.FakeClient, org, configYAML string) { + fc.FileContents[org+"/"+forge.ConfigRepoName+"/config.yaml"] = []byte(configYAML) +} + +var selectAll = func(candidates []RepoCandidate) ([]string, error) { + names := make([]string, 0, len(candidates)) + for _, c := range candidates { + names = append(names, c.Owner+"/"+c.Repo) + } + return names, nil +} + +// nopProgress is a no-op progress callback for tests. +func nopProgress(_, _, _ string) {} + +// --- Init: greenfield org tests --- + +func TestInit_GreenfieldOrg_AllFlag(t *testing.T) { + fc := newFakeWithOrgRepos("acme", []forge.Repository{ + {Name: "api", FullName: "acme/api"}, + {Name: "web", FullName: "acme/web"}, + {Name: "lib", FullName: "acme/lib"}, + }) + + result, err := Init(context.Background(), InitConfig{ + Target: "acme", + All: true, + MintProject: "my-project", + MintRegion: "us-central1", + InferenceProject: "my-inference", + CLIVersion: "2.3.0", + MaxConcurrency: 2, + }, fc, nil, nopProgress) + + require.NoError(t, err) + assert.Equal(t, 0, result.PerRepoCount) + assert.Equal(t, 0, result.PerOrgCount) + assert.Equal(t, 3, result.NewCount) + // Greenfield: no mint URL discovered → TODO generated. + assert.Contains(t, result.TODOs, "mint.url: set the Cloud Run endpoint URL") + + m := result.Manifest + assert.Equal(t, 1, m.Version) + assert.Equal(t, "my-project", m.Mint.Project) + assert.Equal(t, "us-central1", m.Mint.Region) + assert.Equal(t, "my-inference", m.Defaults.InferenceProject) + assert.Equal(t, "v2.3.0", m.Defaults.FullsendRef) + require.Len(t, m.Repos, 3) +} + +func TestInit_GreenfieldOrg_ExplicitRepos(t *testing.T) { + fc := newFakeWithOrgRepos("acme", []forge.Repository{ + {Name: "api", FullName: "acme/api"}, + {Name: "web", FullName: "acme/web"}, + {Name: "lib", FullName: "acme/lib"}, + }) + + result, err := Init(context.Background(), InitConfig{ + Target: "acme", + Repos: []string{"acme/api", "acme/web"}, + MintProject: "p", + MintRegion: "r", + InferenceProject: "inf", + CLIVersion: "1.0.0", + }, fc, nil, nopProgress) + + require.NoError(t, err) + assert.Equal(t, 2, result.NewCount) + require.Len(t, result.Manifest.Repos, 2) + assert.Equal(t, "acme/api", result.Manifest.Repos[0].Repo) + assert.Equal(t, "acme/web", result.Manifest.Repos[1].Repo) +} + +func TestInit_GreenfieldOrg_ExplicitRepos_NotFound(t *testing.T) { + fc := newFakeWithOrgRepos("acme", []forge.Repository{ + {Name: "api", FullName: "acme/api"}, + }) + + _, err := Init(context.Background(), InitConfig{ + Target: "acme", + Repos: []string{"acme/api", "acme/nonexistent"}, + }, fc, nil, nopProgress) + + assert.Error(t, err) + assert.ErrorContains(t, err, "not found in org") +} + +// --- Init: migration tests --- + +func TestInit_MixedPerRepoAndPerOrg(t *testing.T) { + fc := newFakeWithOrgRepos("acme", []forge.Repository{ + {Name: "api", FullName: "acme/api"}, + {Name: "web", FullName: "acme/web"}, + {Name: "lib", FullName: "acme/lib"}, + }) + + // api is per-repo installed. + setRepoVars(fc, "acme", "api", map[string]string{ + forge.PerRepoGuardVar: "true", + "FULLSEND_MINT_URL": "https://mint.example.com", + "FULLSEND_GCP_REGION": "us-east1", + }) + setWorkflowFile(fc, "acme", "api", + " uses: fullsend-ai/fullsend/.github/workflows/reusable-dispatch.yml@v2.3.0") + + // web is per-org enrolled. + setOrgConfig(fc, "acme", ` +version: "1" +dispatch: + platform: github-actions + mode: oidc-mint + mint_url: https://mint-org.example.com +repos: + web: + enabled: true + lib: + enabled: false +`) + setWorkflowFile(fc, "acme", "web", + " uses: fullsend-ai/fullsend/.github/workflows/reusable-dispatch.yml@v2.1.0") + + result, err := Init(context.Background(), InitConfig{ + Target: "acme", + All: true, + MintProject: "proj", + MintRegion: "us-central1", + InferenceProject: "inf", + MaxConcurrency: 4, + }, fc, nil, nopProgress) + + require.NoError(t, err) + assert.Equal(t, 1, result.PerRepoCount) + assert.Equal(t, 1, result.PerOrgCount) + assert.Equal(t, 1, result.NewCount) + require.Len(t, result.Manifest.Repos, 3) +} + +func TestInit_OnlyPerRepoInstallations(t *testing.T) { + fc := newFakeWithOrgRepos("acme", []forge.Repository{ + {Name: "api", FullName: "acme/api"}, + {Name: "web", FullName: "acme/web"}, + }) + + setRepoVars(fc, "acme", "api", map[string]string{ + forge.PerRepoGuardVar: "true", + "FULLSEND_MINT_URL": "https://mint.example.com", + }) + setWorkflowFile(fc, "acme", "api", + " uses: fullsend-ai/fullsend/.github/workflows/reusable-dispatch.yml@v2.0.0") + + setRepoVars(fc, "acme", "web", map[string]string{ + forge.PerRepoGuardVar: "true", + "FULLSEND_MINT_URL": "https://mint.example.com", + }) + setWorkflowFile(fc, "acme", "web", + " uses: fullsend-ai/fullsend/.github/workflows/reusable-dispatch.yml@v2.0.0") + + result, err := Init(context.Background(), InitConfig{ + Target: "acme", + All: true, + MintProject: "proj", + MintRegion: "us-central1", + MaxConcurrency: 2, + }, fc, nil, nopProgress) + + require.NoError(t, err) + assert.Equal(t, 2, result.PerRepoCount) + assert.Equal(t, 0, result.PerOrgCount) + assert.Equal(t, "https://mint.example.com", result.Manifest.Mint.URL) +} + +func TestInit_OnlyPerOrgEnrollments(t *testing.T) { + fc := newFakeWithOrgRepos("acme", []forge.Repository{ + {Name: "api", FullName: "acme/api"}, + }) + + setOrgConfig(fc, "acme", ` +version: "1" +dispatch: + platform: github-actions + mode: oidc-mint + mint_url: https://mint-org.example.com +repos: + api: + enabled: true +`) + setWorkflowFile(fc, "acme", "api", + " uses: fullsend-ai/fullsend/.github/workflows/reusable-dispatch.yml@v2.1.0") + + result, err := Init(context.Background(), InitConfig{ + Target: "acme", + All: true, + MintProject: "proj", + MintRegion: "us-central1", + InferenceProject: "inf", + }, fc, nil, nopProgress) + + require.NoError(t, err) + assert.Equal(t, 0, result.PerRepoCount) + assert.Equal(t, 1, result.PerOrgCount) + assert.Equal(t, "https://mint-org.example.com", result.Manifest.Mint.URL) +} + +// --- Init: single repo tests --- + +func TestInit_SingleRepo_PerRepoInstalled(t *testing.T) { + fc := forge.NewFakeClient() + setRepoVars(fc, "acme", "api", map[string]string{ + forge.PerRepoGuardVar: "true", + "FULLSEND_MINT_URL": "https://mint.example.com", + "FULLSEND_GCP_REGION": "us-west1", + }) + setWorkflowFile(fc, "acme", "api", + " uses: fullsend-ai/fullsend/.github/workflows/reusable-dispatch.yml@v2.3.0") + + result, err := Init(context.Background(), InitConfig{ + Target: "acme/api", + MintProject: "proj", + MintRegion: "us-central1", + }, fc, nil, nopProgress) + + require.NoError(t, err) + assert.Equal(t, 1, result.PerRepoCount) + require.Len(t, result.Manifest.Repos, 1) + assert.Equal(t, "acme/api", result.Manifest.Repos[0].Repo) + assert.Equal(t, "https://mint.example.com", result.Manifest.Mint.URL) + assert.Equal(t, "v2.3.0", result.Manifest.Defaults.FullsendRef) +} + +func TestInit_SingleRepo_PerOrgEnrolled(t *testing.T) { + fc := forge.NewFakeClient() + + setOrgConfig(fc, "acme", ` +version: "1" +dispatch: + platform: github-actions + mode: oidc-mint + mint_url: https://mint-org.example.com +repos: + api: + enabled: true +`) + setWorkflowFile(fc, "acme", "api", + " uses: fullsend-ai/fullsend/.github/workflows/reusable-dispatch.yml@v2.1.0") + + result, err := Init(context.Background(), InitConfig{ + Target: "acme/api", + MintProject: "proj", + MintRegion: "us-central1", + InferenceProject: "inf", + }, fc, nil, nopProgress) + + require.NoError(t, err) + assert.Equal(t, 1, result.PerOrgCount) + assert.Equal(t, "https://mint-org.example.com", result.Manifest.Mint.URL) +} + +func TestInit_SingleRepo_RejectsAllFlag(t *testing.T) { + fc := forge.NewFakeClient() + + _, err := Init(context.Background(), InitConfig{ + Target: "acme/api", + All: true, + }, fc, nil, nopProgress) + + assert.Error(t, err) + assert.ErrorContains(t, err, "--all flag cannot be used with a single repo target") +} + +func TestInit_SingleRepo_RejectsReposFlag(t *testing.T) { + fc := forge.NewFakeClient() + + _, err := Init(context.Background(), InitConfig{ + Target: "acme/api", + Repos: []string{"acme/other"}, + }, fc, nil, nopProgress) + + assert.Error(t, err) + assert.ErrorContains(t, err, "--repos flag cannot be used with a single repo target") +} + +func TestInit_SingleRepo_NotInstalled(t *testing.T) { + fc := forge.NewFakeClient() + + result, err := Init(context.Background(), InitConfig{ + Target: "acme/api", + MintProject: "proj", + MintRegion: "us-central1", + InferenceProject: "inf", + CLIVersion: "2.5.0", + }, fc, nil, nopProgress) + + require.NoError(t, err) + assert.Equal(t, 1, result.NewCount) + assert.Equal(t, "v2.5.0", result.Manifest.Defaults.FullsendRef) +} + +// --- Defaults computation tests --- + +func TestInit_DefaultsComputation_MostCommonRef(t *testing.T) { + fc := newFakeWithOrgRepos("acme", []forge.Repository{ + {Name: "r1", FullName: "acme/r1"}, + {Name: "r2", FullName: "acme/r2"}, + {Name: "r3", FullName: "acme/r3"}, + }) + + // r1 and r2 have v2.3.0, r3 has v2.1.0 + for _, name := range []string{"r1", "r2"} { + setRepoVars(fc, "acme", name, map[string]string{ + forge.PerRepoGuardVar: "true", + "FULLSEND_MINT_URL": "https://mint.example.com", + }) + setWorkflowFile(fc, "acme", name, + " uses: fullsend-ai/fullsend/.github/workflows/reusable-dispatch.yml@v2.3.0") + } + setRepoVars(fc, "acme", "r3", map[string]string{ + forge.PerRepoGuardVar: "true", + "FULLSEND_MINT_URL": "https://mint.example.com", + }) + setWorkflowFile(fc, "acme", "r3", + " uses: fullsend-ai/fullsend/.github/workflows/reusable-dispatch.yml@v2.1.0") + + result, err := Init(context.Background(), InitConfig{ + Target: "acme", + All: true, + MintProject: "proj", + MintRegion: "us-central1", + }, fc, nil, nopProgress) + + require.NoError(t, err) + // v2.3.0 is most common, should be the default. + assert.Equal(t, "v2.3.0", result.Manifest.Defaults.FullsendRef) + + // r3 should have an override entry since it differs from default. + var r3Entry *RepoEntry + for i := range result.Manifest.Repos { + if result.Manifest.Repos[i].Repo == "acme/r3" { + r3Entry = &result.Manifest.Repos[i] + break + } + } + require.NotNil(t, r3Entry) + assert.True(t, r3Entry.FullsendRef.Set) + assert.Equal(t, "v2.1.0", r3Entry.FullsendRef.Value) +} + +func TestInit_PerRepoOverrides_DifferentRegion(t *testing.T) { + fc := newFakeWithOrgRepos("acme", []forge.Repository{ + {Name: "r1", FullName: "acme/r1"}, + {Name: "r2", FullName: "acme/r2"}, + }) + + setRepoVars(fc, "acme", "r1", map[string]string{ + forge.PerRepoGuardVar: "true", + "FULLSEND_MINT_URL": "https://mint.example.com", + "FULLSEND_GCP_REGION": "us-central1", + }) + setWorkflowFile(fc, "acme", "r1", + " uses: fullsend-ai/fullsend/.github/workflows/reusable-dispatch.yml@v2.0.0") + + setRepoVars(fc, "acme", "r2", map[string]string{ + forge.PerRepoGuardVar: "true", + "FULLSEND_MINT_URL": "https://mint.example.com", + "FULLSEND_GCP_REGION": "us-east1", + }) + setWorkflowFile(fc, "acme", "r2", + " uses: fullsend-ai/fullsend/.github/workflows/reusable-dispatch.yml@v2.0.0") + + result, err := Init(context.Background(), InitConfig{ + Target: "acme", + All: true, + MintProject: "proj", + MintRegion: "us-central1", + }, fc, nil, nopProgress) + + require.NoError(t, err) + + // The minority region should appear as an override. + hasOverride := false + for _, entry := range result.Manifest.Repos { + if entry.InferenceRegion.Set { + hasOverride = true + break + } + } + assert.True(t, hasOverride, "repo with minority region should have an override") +} + +// --- Interactive selection tests --- + +func TestInit_InteractiveSelection(t *testing.T) { + fc := newFakeWithOrgRepos("acme", []forge.Repository{ + {Name: "api", FullName: "acme/api"}, + {Name: "web", FullName: "acme/web"}, + {Name: "lib", FullName: "acme/lib"}, + }) + + setRepoVars(fc, "acme", "api", map[string]string{ + forge.PerRepoGuardVar: "true", + "FULLSEND_MINT_URL": "https://mint.example.com", + }) + setWorkflowFile(fc, "acme", "api", + " uses: fullsend-ai/fullsend/.github/workflows/reusable-dispatch.yml@v2.0.0") + + var receivedCandidates []RepoCandidate + selectFn := func(candidates []RepoCandidate) ([]string, error) { + receivedCandidates = candidates + return []string{"acme/api", "acme/web"}, nil + } + + result, err := Init(context.Background(), InitConfig{ + Target: "acme", + MintProject: "proj", + MintRegion: "r", + InferenceProject: "inf", + }, fc, selectFn, nopProgress) + + require.NoError(t, err) + require.Len(t, result.Manifest.Repos, 2) + + // Verify candidates include status labels. + require.Len(t, receivedCandidates, 3) + statusMap := make(map[string]string) + for _, c := range receivedCandidates { + statusMap[c.Owner+"/"+c.Repo] = c.Status + } + assert.Equal(t, "per-repo", statusMap["acme/api"]) + assert.Equal(t, "new", statusMap["acme/web"]) + assert.Equal(t, "new", statusMap["acme/lib"]) +} + +func TestInit_NilCallback_RequiresFlag(t *testing.T) { + fc := newFakeWithOrgRepos("acme", []forge.Repository{ + {Name: "api", FullName: "acme/api"}, + }) + + _, err := Init(context.Background(), InitConfig{ + Target: "acme", + }, fc, nil, nopProgress) + + assert.Error(t, err) + assert.ErrorContains(t, err, "org target requires --all or --repos flag") +} + +// --- TODO generation tests --- + +func TestInit_TODOs_NoMintProject(t *testing.T) { + fc := forge.NewFakeClient() + + result, err := Init(context.Background(), InitConfig{ + Target: "acme/api", + MintRegion: "us-central1", + CLIVersion: "1.0.0", + }, fc, nil, nopProgress) + + require.NoError(t, err) + assert.Contains(t, result.TODOs, "mint.project: provide via --mint-project flag") + assert.Contains(t, result.TODOs, "defaults.inference_project: provide via --inference-project flag") +} + +func TestInit_TODOs_NoMintURL_Greenfield(t *testing.T) { + fc := forge.NewFakeClient() + + result, err := Init(context.Background(), InitConfig{ + Target: "acme/api", + MintProject: "proj", + MintRegion: "us-central1", + CLIVersion: "1.0.0", + }, fc, nil, nopProgress) + + require.NoError(t, err) + assert.Contains(t, result.TODOs, "mint.url: set the Cloud Run endpoint URL") +} + +func TestInit_TODOs_MultipleMintURLs(t *testing.T) { + fc := newFakeWithOrgRepos("acme", []forge.Repository{ + {Name: "r1", FullName: "acme/r1"}, + {Name: "r2", FullName: "acme/r2"}, + {Name: "r3", FullName: "acme/r3"}, + }) + + setRepoVars(fc, "acme", "r1", map[string]string{ + forge.PerRepoGuardVar: "true", + "FULLSEND_MINT_URL": "https://mint-a.example.com", + }) + setRepoVars(fc, "acme", "r2", map[string]string{ + forge.PerRepoGuardVar: "true", + "FULLSEND_MINT_URL": "https://mint-a.example.com", + }) + setRepoVars(fc, "acme", "r3", map[string]string{ + forge.PerRepoGuardVar: "true", + "FULLSEND_MINT_URL": "https://mint-b.example.com", + }) + + result, err := Init(context.Background(), InitConfig{ + Target: "acme", + All: true, + MintProject: "proj", + MintRegion: "r", + }, fc, nil, nopProgress) + + require.NoError(t, err) + // Most common URL should be used. + assert.Equal(t, "https://mint-a.example.com", result.Manifest.Mint.URL) + assert.Contains(t, result.TODOs, "mint.url: multiple mint URLs discovered; using most common — verify correctness") +} + +// --- buildManifest tests --- + +func TestBuildManifest_SimpleEntries(t *testing.T) { + repos := []DiscoveredRepo{ + {Owner: "acme", Repo: "api", Source: "new"}, + {Owner: "acme", Repo: "web", Source: "new"}, + } + m, todos := buildManifest(repos, InitConfig{ + MintProject: "proj", + MintRegion: "us-central1", + InferenceProject: "inf", + CLIVersion: "2.0.0", + }) + + require.Len(t, m.Repos, 2) + assert.Equal(t, "acme/api", m.Repos[0].Repo) + assert.Equal(t, "acme/web", m.Repos[1].Repo) + // All new repos, no overrides. + for _, entry := range m.Repos { + assert.False(t, entry.FullsendRef.Set) + assert.False(t, entry.InferenceRegion.Set) + } + // Greenfield: no mint URL discovered → TODO generated. + assert.Contains(t, todos, "mint.url: set the Cloud Run endpoint URL") +} + +func TestBuildManifest_MixedOverrides(t *testing.T) { + repos := []DiscoveredRepo{ + {Owner: "acme", Repo: "r1", Source: "per-repo", FullsendRef: "v2.3.0", InferenceRegion: "us-central1", MintURL: "https://mint.example.com"}, + {Owner: "acme", Repo: "r2", Source: "per-repo", FullsendRef: "v2.3.0", InferenceRegion: "us-central1", MintURL: "https://mint.example.com"}, + {Owner: "acme", Repo: "r3", Source: "per-repo", FullsendRef: "v2.1.0", InferenceRegion: "us-east1", MintURL: "https://mint.example.com"}, + } + m, _ := buildManifest(repos, InitConfig{ + MintProject: "proj", + MintRegion: "us-central1", + InferenceProject: "inf", + }) + + // Defaults should be the mode values. + assert.Equal(t, "v2.3.0", m.Defaults.FullsendRef) + assert.Equal(t, "us-central1", m.Defaults.InferenceRegion) + + // r3 should have overrides. + r3 := m.Repos[2] + assert.True(t, r3.FullsendRef.Set) + assert.Equal(t, "v2.1.0", r3.FullsendRef.Value) + assert.True(t, r3.InferenceRegion.Set) + assert.Equal(t, "us-east1", r3.InferenceRegion.Value) + + // r1 and r2 should not have overrides. + assert.False(t, m.Repos[0].FullsendRef.Set) + assert.False(t, m.Repos[1].FullsendRef.Set) +} + +// --- computeMode tests --- + +func TestComputeMode(t *testing.T) { + tests := []struct { + name string + repos []DiscoveredRepo + want string + }{ + { + name: "single value", + repos: []DiscoveredRepo{ + {MintURL: "https://a.com"}, + {MintURL: "https://a.com"}, + }, + want: "https://a.com", + }, + { + name: "majority wins", + repos: []DiscoveredRepo{ + {MintURL: "https://a.com"}, + {MintURL: "https://a.com"}, + {MintURL: "https://b.com"}, + }, + want: "https://a.com", + }, + { + name: "empty values ignored", + repos: []DiscoveredRepo{ + {MintURL: ""}, + {MintURL: "https://a.com"}, + {MintURL: ""}, + }, + want: "https://a.com", + }, + { + name: "all empty", + repos: []DiscoveredRepo{{MintURL: ""}, {MintURL: ""}}, + want: "", + }, + { + name: "no repos", + repos: nil, + want: "", + }, + { + name: "tie broken alphabetically", + repos: []DiscoveredRepo{ + {MintURL: "https://b.com"}, + {MintURL: "https://a.com"}, + }, + want: "https://a.com", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := computeMode(tt.repos, func(d DiscoveredRepo) string { return d.MintURL }) + assert.Equal(t, tt.want, got) + }) + } +} + +// --- countDistinct tests --- + +func TestCountDistinct(t *testing.T) { + repos := []DiscoveredRepo{ + {MintURL: "https://a.com"}, + {MintURL: "https://a.com"}, + {MintURL: "https://b.com"}, + {MintURL: ""}, + } + got := countDistinct(repos, func(d DiscoveredRepo) string { return d.MintURL }) + assert.Equal(t, 2, got) +} + +// --- MarshalWithHeader tests --- + +func TestMarshalWithHeader(t *testing.T) { + m := &Manifest{ + Version: 1, + Mint: MintConfig{ + URL: "https://mint.example.com", + Project: "proj", + Region: "us-central1", + }, + Repos: []RepoEntry{ + {Repo: "acme/api"}, + }, + } + + data, err := MarshalWithHeader(m) + require.NoError(t, err) + + s := string(data) + assert.Contains(t, s, "# Generated by fullsend repos init on") + assert.Contains(t, s, "# Review and adjust before running fullsend repos install.") + assert.Contains(t, s, "version: 1") + assert.Contains(t, s, "acme/api") +} + +// --- Round-trip: Init → Marshal → parse --- + +func TestInit_RoundTrip(t *testing.T) { + fc := newFakeWithOrgRepos("acme", []forge.Repository{ + {Name: "api", FullName: "acme/api"}, + {Name: "web", FullName: "acme/web"}, + }) + + setRepoVars(fc, "acme", "api", map[string]string{ + forge.PerRepoGuardVar: "true", + "FULLSEND_MINT_URL": "https://mint.example.com", + "FULLSEND_GCP_REGION": "us-central1", + }) + setWorkflowFile(fc, "acme", "api", + " uses: fullsend-ai/fullsend/.github/workflows/reusable-dispatch.yml@v2.3.0") + + result, err := Init(context.Background(), InitConfig{ + Target: "acme", + All: true, + MintProject: "proj", + MintRegion: "us-central1", + InferenceProject: "inf", + }, fc, nil, nopProgress) + require.NoError(t, err) + + // Marshal and re-parse. + data, err := result.Manifest.Marshal() + require.NoError(t, err) + + var parsed Manifest + require.NoError(t, yaml.Unmarshal(data, &parsed)) + + assert.Equal(t, 1, parsed.Version) + assert.Equal(t, "https://mint.example.com", parsed.Mint.URL) + assert.Equal(t, "proj", parsed.Mint.Project) + assert.Len(t, parsed.Repos, 2) +} + +// --- Error handling tests --- + +func TestInit_ListOrgReposError(t *testing.T) { + fc := forge.NewFakeClient() + fc.Errors["ListOrgRepos"] = assert.AnError + + _, err := Init(context.Background(), InitConfig{ + Target: "acme", + All: true, + }, fc, nil, nopProgress) + + assert.Error(t, err) + assert.ErrorContains(t, err, "listing repos for org") +} + +func TestInit_ListRepoVariablesError(t *testing.T) { + fc := forge.NewFakeClient() + fc.Errors["ListRepoVariables"] = assert.AnError + + _, err := Init(context.Background(), InitConfig{ + Target: "acme/api", + }, fc, nil, nopProgress) + + assert.Error(t, err) + assert.ErrorContains(t, err, "listing variables") +} + +// --- GetFileContent error handling tests --- + +func TestInit_OrgConfigParseError_SingleRepo_Warns(t *testing.T) { + fc := forge.NewFakeClient() + fc.FileContents["acme/"+forge.ConfigRepoName+"/config.yaml"] = []byte("not: valid: yaml: [") + + // Malformed org config should warn, not fail, for single-repo init. + var warnings []string + progress := func(_, _, msg string) { + warnings = append(warnings, msg) + } + + result, err := Init(context.Background(), InitConfig{ + Target: "acme/api", + MintProject: "proj", + MintRegion: "us-central1", + CLIVersion: "1.0.0", + }, fc, nil, progress) + + require.NoError(t, err) + assert.Equal(t, 1, result.NewCount) + + hasWarning := false + for _, w := range warnings { + if len(w) > 8 && w[:8] == "warning:" { + hasWarning = true + break + } + } + assert.True(t, hasWarning, "expected a warning about org config parse failure") +} + +func TestInit_OrgConfigFetchError_SingleRepo(t *testing.T) { + fc := forge.NewFakeClient() + fc.Errors["GetFileContent"] = assert.AnError + + _, err := Init(context.Background(), InitConfig{ + Target: "acme/api", + }, fc, nil, nopProgress) + + assert.Error(t, err) + assert.ErrorContains(t, err, "fetching org config") +} + +func TestInit_OrgConfigFetchError_Org(t *testing.T) { + fc := newFakeWithOrgRepos("acme", []forge.Repository{ + {Name: "api", FullName: "acme/api"}, + }) + fc.Errors["GetFileContent"] = assert.AnError + + _, err := Init(context.Background(), InitConfig{ + Target: "acme", + All: true, + }, fc, nil, nopProgress) + + assert.Error(t, err) + assert.ErrorContains(t, err, "fetching org config") +} + +// --- Config repo exclusion tests --- + +func TestInit_ConfigRepoExcluded(t *testing.T) { + fc := newFakeWithOrgRepos("acme", []forge.Repository{ + {Name: "api", FullName: "acme/api"}, + {Name: forge.ConfigRepoName, FullName: "acme/" + forge.ConfigRepoName}, + {Name: "web", FullName: "acme/web"}, + }) + + result, err := Init(context.Background(), InitConfig{ + Target: "acme", + All: true, + MintProject: "proj", + MintRegion: "us-central1", + InferenceProject: "inf", + CLIVersion: "1.0.0", + }, fc, nil, nopProgress) + + require.NoError(t, err) + assert.Equal(t, 2, result.NewCount) + require.Len(t, result.Manifest.Repos, 2) + for _, entry := range result.Manifest.Repos { + assert.NotEqual(t, "acme/"+forge.ConfigRepoName, entry.Repo) + } +} + +// --- Discovery error tracking tests --- + +func TestInit_DiscoveryErrors_Tracked(t *testing.T) { + fc := newFakeWithOrgRepos("acme", []forge.Repository{ + {Name: "api", FullName: "acme/api"}, + }) + fc.Errors["ListRepoVariables"] = assert.AnError + + result, err := Init(context.Background(), InitConfig{ + Target: "acme", + All: true, + MintProject: "proj", + MintRegion: "us-central1", + InferenceProject: "inf", + CLIVersion: "1.0.0", + }, fc, nil, nopProgress) + + require.NoError(t, err) + // Repo with error should be excluded from manifest. + assert.Empty(t, result.Manifest.Repos) + // Error should be tracked in result. + require.Len(t, result.Errors, 1) + assert.Contains(t, result.Errors[0], "acme/api") +} + +func TestDiscoverReposParallel_ErrorsExcluded(t *testing.T) { + fc := forge.NewFakeClient() + fc.Errors["ListRepoVariables"] = assert.AnError + + repos := []forge.Repository{ + {Name: "api", FullName: "acme/api"}, + {Name: "web", FullName: "acme/web"}, + } + + dr := discoverReposParallel(context.Background(), fc, "acme", repos, nil, 4, nopProgress) + + assert.Empty(t, dr.repos) + assert.Len(t, dr.errors, 2) +} + +// --- Repos flag whitespace tests --- + +func TestSelectInitRepos_ExplicitMode_TrimmedNames(t *testing.T) { + candidates := []RepoCandidate{ + {Owner: "acme", Repo: "api"}, + {Owner: "acme", Repo: "web"}, + } + selected, err := selectInitRepos(InitConfig{Repos: []string{"acme/api", "acme/web"}}, candidates, nil, nil) + require.NoError(t, err) + assert.Equal(t, []string{"acme/api", "acme/web"}, selected) +} + +// --- parseInitTarget tests --- + +func TestParseInitTarget(t *testing.T) { + tests := []struct { + input string + owner string + repo string + isRepo bool + }{ + {"acme", "acme", "", false}, + {"acme/api", "acme", "api", true}, + {"acme/my.repo-name", "acme", "my.repo-name", true}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + owner, repo, isRepo, err := parseInitTarget(tt.input) + require.NoError(t, err) + assert.Equal(t, tt.owner, owner) + assert.Equal(t, tt.repo, repo) + assert.Equal(t, tt.isRepo, isRepo) + }) + } +} + +func TestParseInitTarget_Errors(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"", "target cannot be empty"}, + {"acme/", "both owner and repo must be non-empty"}, + {"/api", "both owner and repo must be non-empty"}, + {"acme/api/extra", "expected org or owner/repo format"}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + _, _, _, err := parseInitTarget(tt.input) + assert.Error(t, err) + assert.ErrorContains(t, err, tt.want) + }) + } +} + +// --- countSources tests --- + +func TestCountSources(t *testing.T) { + repos := []DiscoveredRepo{ + {Source: "per-repo"}, + {Source: "per-repo"}, + {Source: "per-org"}, + {Source: "new"}, + {Source: "new"}, + {Source: "new"}, + } + result := &InitResult{} + countSources(repos, result) + assert.Equal(t, 2, result.PerRepoCount) + assert.Equal(t, 1, result.PerOrgCount) + assert.Equal(t, 3, result.NewCount) +} + +// --- discoverRepo tests --- + +func TestDiscoverRepo_PerRepo(t *testing.T) { + fc := forge.NewFakeClient() + setRepoVars(fc, "acme", "api", map[string]string{ + forge.PerRepoGuardVar: "true", + "FULLSEND_MINT_URL": "https://mint.example.com", + "FULLSEND_GCP_REGION": "us-west1", + }) + setWorkflowFile(fc, "acme", "api", + " uses: fullsend-ai/fullsend/.github/workflows/reusable-dispatch.yml@v2.3.0") + + d, err := discoverRepo(context.Background(), fc, "acme", "api", nil, nopProgress) + require.NoError(t, err) + assert.Equal(t, "per-repo", d.Source) + assert.Equal(t, "https://mint.example.com", d.MintURL) + assert.Equal(t, "us-west1", d.InferenceRegion) + assert.Equal(t, "v2.3.0", d.FullsendRef) +} + +func TestDiscoverRepo_PerOrg(t *testing.T) { + fc := forge.NewFakeClient() + setWorkflowFile(fc, "acme", "api", + " uses: fullsend-ai/fullsend/.github/workflows/reusable-dispatch.yml@v2.1.0") + + orgCfg := &config.OrgConfig{ + Dispatch: config.DispatchConfig{ + MintURL: "https://mint-org.example.com", + }, + Repos: map[string]config.RepoConfig{ + "api": {Enabled: true}, + }, + } + + d, err := discoverRepo(context.Background(), fc, "acme", "api", orgCfg, nopProgress) + require.NoError(t, err) + assert.Equal(t, "per-org", d.Source) + assert.Equal(t, "https://mint-org.example.com", d.MintURL) + assert.Equal(t, "v2.1.0", d.FullsendRef) +} + +func TestDiscoverRepo_PerOrgDisabled(t *testing.T) { + fc := forge.NewFakeClient() + + orgCfg := &config.OrgConfig{ + Repos: map[string]config.RepoConfig{ + "api": {Enabled: false}, + }, + } + + d, err := discoverRepo(context.Background(), fc, "acme", "api", orgCfg, nopProgress) + require.NoError(t, err) + assert.Equal(t, "new", d.Source) +} + +func TestDiscoverRepo_New(t *testing.T) { + fc := forge.NewFakeClient() + + d, err := discoverRepo(context.Background(), fc, "acme", "api", nil, nopProgress) + require.NoError(t, err) + assert.Equal(t, "new", d.Source) + assert.Empty(t, d.MintURL) + assert.Empty(t, d.FullsendRef) +} + +// --- readWorkflowRef tests --- + +func TestReadWorkflowRef_YmlExtension(t *testing.T) { + fc := forge.NewFakeClient() + setWorkflowFile(fc, "acme", "api", + " uses: fullsend-ai/fullsend/.github/workflows/reusable-dispatch.yml@v2.3.0") + + ref, err := readWorkflowRef(context.Background(), fc, "acme", "api") + require.NoError(t, err) + assert.Equal(t, "v2.3.0", ref) +} + +func TestReadWorkflowRef_YamlExtension(t *testing.T) { + fc := forge.NewFakeClient() + fc.FileContents["acme/api/.github/workflows/fullsend.yaml"] = []byte( + " uses: fullsend-ai/fullsend/.github/workflows/reusable-dispatch.yml@v1.0.0") + + ref, err := readWorkflowRef(context.Background(), fc, "acme", "api") + require.NoError(t, err) + assert.Equal(t, "v1.0.0", ref) +} + +func TestReadWorkflowRef_NoWorkflowFile(t *testing.T) { + fc := forge.NewFakeClient() + ref, err := readWorkflowRef(context.Background(), fc, "acme", "api") + require.NoError(t, err) + assert.Empty(t, ref) +} + +func TestReadWorkflowRef_NonNotFoundError(t *testing.T) { + fc := forge.NewFakeClient() + fc.Errors["GetFileContent"] = fmt.Errorf("network timeout") + + ref, err := readWorkflowRef(context.Background(), fc, "acme", "api") + assert.Error(t, err) + assert.Contains(t, err.Error(), "network timeout") + assert.Empty(t, ref) +} + +// --- CLIVersion fallback tests --- + +func TestInit_CLIVersionFallback(t *testing.T) { + fc := forge.NewFakeClient() + + result, err := Init(context.Background(), InitConfig{ + Target: "acme/api", + MintProject: "proj", + MintRegion: "us-central1", + InferenceProject: "inf", + CLIVersion: "3.0.0", + }, fc, nil, nopProgress) + + require.NoError(t, err) + assert.Equal(t, "v3.0.0", result.Manifest.Defaults.FullsendRef) +} + +func TestInit_CLIVersionDev_FallsBackToDefault(t *testing.T) { + fc := forge.NewFakeClient() + + result, err := Init(context.Background(), InitConfig{ + Target: "acme/api", + MintProject: "proj", + MintRegion: "us-central1", + InferenceProject: "inf", + CLIVersion: "dev", + }, fc, nil, nopProgress) + + require.NoError(t, err) + assert.Equal(t, config.DefaultUpstreamRef, result.Manifest.Defaults.FullsendRef) +} + +// --- Concurrency tests --- + +func TestInit_DefaultConcurrency(t *testing.T) { + fc := newFakeWithOrgRepos("acme", []forge.Repository{ + {Name: "api", FullName: "acme/api"}, + }) + + result, err := Init(context.Background(), InitConfig{ + Target: "acme", + All: true, + MintProject: "proj", + MintRegion: "r", + InferenceProject: "inf", + CLIVersion: "1.0.0", + MaxConcurrency: 0, // should default to 8 + }, fc, nil, nopProgress) + + require.NoError(t, err) + assert.Equal(t, 1, result.NewCount) +} + +func TestInit_ConcurrencyUpperBound(t *testing.T) { + fc := newFakeWithOrgRepos("acme", []forge.Repository{ + {Name: "api", FullName: "acme/api"}, + }) + + result, err := Init(context.Background(), InitConfig{ + Target: "acme", + All: true, + MintProject: "proj", + MintRegion: "r", + InferenceProject: "inf", + CLIVersion: "1.0.0", + MaxConcurrency: 200, // should clamp to 64 + }, fc, nil, nopProgress) + + require.NoError(t, err) + assert.Equal(t, 1, result.NewCount) +} + +// --- selectInitRepos tests --- + +func TestSelectInitRepos_AllMode(t *testing.T) { + candidates := []RepoCandidate{ + {Owner: "acme", Repo: "api"}, + {Owner: "acme", Repo: "web"}, + } + selected, err := selectInitRepos(InitConfig{All: true}, candidates, nil, nil) + require.NoError(t, err) + assert.Equal(t, []string{"acme/api", "acme/web"}, selected) +} + +func TestSelectInitRepos_ExplicitMode(t *testing.T) { + candidates := []RepoCandidate{ + {Owner: "acme", Repo: "api"}, + {Owner: "acme", Repo: "web"}, + } + selected, err := selectInitRepos(InitConfig{Repos: []string{"acme/api"}}, candidates, nil, nil) + require.NoError(t, err) + assert.Equal(t, []string{"acme/api"}, selected) +} + +func TestSelectInitRepos_ExplicitMode_Empty(t *testing.T) { + candidates := []RepoCandidate{ + {Owner: "acme", Repo: "api"}, + } + _, err := selectInitRepos(InitConfig{Repos: []string{}}, candidates, nil, nil) + assert.Error(t, err) + assert.ErrorContains(t, err, "--repos list is empty") +} + +func TestSelectInitRepos_ExplicitMode_InvalidRepo(t *testing.T) { + candidates := []RepoCandidate{ + {Owner: "acme", Repo: "api"}, + } + _, err := selectInitRepos(InitConfig{Repos: []string{"acme/missing"}}, candidates, nil, nil) + assert.Error(t, err) + assert.ErrorContains(t, err, "not found in org") +} + +func TestSelectInitRepos_ExplicitMode_DiscoveryError(t *testing.T) { + candidates := []RepoCandidate{ + {Owner: "acme", Repo: "api"}, + } + discoveryErrors := []string{"acme/broken: connection refused"} + _, err := selectInitRepos(InitConfig{Repos: []string{"acme/broken"}}, candidates, discoveryErrors, nil) + assert.Error(t, err) + assert.ErrorContains(t, err, "failed discovery") + assert.ErrorContains(t, err, "connection refused") +}