Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions docs/cli/repos.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,55 @@ Manage per-repo installations across multiple orgs via a declarative `repos.yaml

| Command | Description |
|---------|-------------|
| `fullsend repos init <org\|owner/repo>` | 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 <org> --all --mint-project <PROJECT> --inference-project <PROJECT>
```

Single-repo mode:

```bash
fullsend repos init <owner/repo> --mint-project <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.
Expand Down
9 changes: 9 additions & 0 deletions docs/guides/dev/cli-internals.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,15 @@ fullsend
│ ├── uninstall <org> # Remove fullsend GitHub configuration
│ └── sync-scaffold <org> # Update workflow templates
├── repos # Manage per-repo installations via manifest
│ ├── init <org|owner/repo> # Generate repos.yaml from discovered installs
│ │ ├── --output, -o <path> # Output path (default: repos.yaml, - for stdout)
│ │ ├── --repos <list> # Comma-separated repos to include
│ │ ├── --all # Include all eligible repos
│ │ ├── --mint-project <id> # GCP project for the mint
│ │ ├── --mint-region <region> # GCP region for the mint (default: us-central1)
│ │ ├── --inference-project <id> # Default GCP project for inference
│ │ ├── --force # Overwrite output file if it exists
│ │ └── --concurrency <int> # Max parallel API calls (default: 8)
│ └── status # Compare manifest against actual repo state
├── agent # Manage agent registrations in config
│ ├── add <url-or-path> # Register an agent (URL auto-pinned)
Expand Down
1 change: 1 addition & 0 deletions docs/guides/getting-started/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ For organizations that separate GCP and GitHub responsibilities across teams, fu
| GCP Admin (Mint) | `fullsend mint unenroll <org\|owner/repo>` | 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 <org\|owner/repo>` | 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 <url-or-path>` | Register an agent in config (URL auto-pinned to commit SHA) |
Expand Down
142 changes: 141 additions & 1 deletion internal/cli/repos.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Comment thread
ggallen marked this conversation as resolved.
}

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 <org|owner/repo>",
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)
Comment thread
ggallen marked this conversation as resolved.
printerOut := os.Stdout
if cfg.output == "-" {
printerOut = os.Stderr
}
printer := ui.New(printerOut)
printer.Banner(Version())
ctx := cmd.Context()

Comment thread
ggallen marked this conversation as resolved.
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) {
Comment thread
ggallen marked this conversation as resolved.
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)
Comment thread
ggallen marked this conversation as resolved.
} 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)
}
Comment thread
ggallen marked this conversation as resolved.
Comment thread
ggallen marked this conversation as resolved.
Comment thread
ggallen marked this conversation as resolved.
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 {
Comment thread
ggallen marked this conversation as resolved.
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
Expand Down
81 changes: 78 additions & 3 deletions internal/cli/repos_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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
}
}
Expand Down
1 change: 1 addition & 0 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ func newRootCmd() *cobra.Command {
cmd.AddCommand(newPostReviewCmd())
cmd.AddCommand(newPostCommentCmd())
cmd.AddCommand(newReconcileStatusCmd())
cmd.AddCommand(newReposCmd())
return cmd
}

Expand Down
Loading
Loading