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
6 changes: 3 additions & 3 deletions docs/cli/repos.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,11 +110,11 @@ When repos are specified as positional arguments, only those repos are processed
| `--concurrency` | `4` | Max parallel operations (1-32) |
| `--roles` | `triage,coder,review,fix,retro,prioritize` | Agent roles to install |
| `--direct` | `false` | Push scaffold directly to default branch (skip PR) |
| `--inference-project` | | GCP project ID for inference (written as `FULLSEND_GCP_PROJECT_ID` secret; required when any inference flag is set) |
| `--inference-project-number` | | Numeric GCP project number for WIF provider computation (required when any inference flag is set) |
| `--inference-project` | | GCP project ID for inference (written as `FULLSEND_GCP_PROJECT_ID` secret) |
| `--inference-project-number` | | Numeric GCP project number for WIF provider computation (auto-derived from `--inference-project` when omitted) |
| `--forge` | | Forge type for new repos (`github` or `gitlab`). Required when adding repos not already in the manifest; falls back to `defaults.forge` if set. |
| `--force` | `false` | Allow scaffold ref downgrades |
| `--inference-region` | | Per-repo GCP inference region override (install-time only, not stored in the manifest) |
| `--inference-region` | | Per-repo GCP inference region override (default: global when `--inference-project` is set; install-time only, not stored in the manifest) |
| `--fullsend-ref` | | Per-repo fullsend workflow ref override |
| `--mint-url` | | Per-repo mint URL override |
| `--allowed-remote-resources` | | Per-repo allowed remote resources override |
Expand Down
2 changes: 1 addition & 1 deletion docs/guides/dev/cli-internals.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ fullsend
│ │ ├── --roles <list> # Agent roles (default: triage,coder,review,fix,retro,prioritize)
│ │ ├── --direct # Push scaffold to default branch (skip PR)
│ │ ├── --inference-project <id> # GCP project ID for inference (install-time only)
│ │ ├── --inference-project-number <num> # Numeric GCP project number for WIF (install-time only)
│ │ ├── --inference-project-number <num> # Numeric GCP project number for WIF (auto-derived; install-time only)
│ │ ├── --forge <type> # Forge type for new repos (github or gitlab)
│ │ ├── --inference-region <region> # Per-repo GCP inference region override
│ │ ├── --fullsend-ref <ref> # Per-repo fullsend workflow ref override
Expand Down
3 changes: 1 addition & 2 deletions docs/guides/getting-started/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,7 @@ For GitLab repos, re-run `repos install` with updated values to converge configu

```bash
fullsend repos install -f repos.yaml "$OWNER/$REPO" \
--inference-project "<GCP_PROJECT>" \
--inference-project-number "<GCP_PROJECT_NUMBER>"
--inference-project "<GCP_PROJECT>"
```

| Key | Storage Type | Description | Example value |
Expand Down
33 changes: 29 additions & 4 deletions internal/cli/repos.go
Original file line number Diff line number Diff line change
Expand Up @@ -426,7 +426,8 @@ type reposInstallConfig struct {
allowedRemoteResources []string

// Test overrides
testClient forge.Client
testClient forge.Client
testProjectNumberFn func(ctx context.Context, projectID string) (string, error)
}

func newReposInstallCmd() *cobra.Command {
Expand Down Expand Up @@ -466,9 +467,9 @@ GCP infrastructure (WIF, mint) must be provisioned separately via
cmd.Flags().BoolVar(&opts.direct, "direct", false, "push scaffold directly to default branch (skip PR)")
cmd.Flags().BoolVar(&opts.force, "force", false, "allow scaffold ref downgrades")
cmd.Flags().StringVar(&opts.forge, "forge", "", "forge type for repos not yet in the manifest (github or gitlab)")
cmd.Flags().StringVar(&opts.inferenceProject, "inference-project", "", "GCP project ID for inference; requires all three --inference-* flags")
cmd.Flags().StringVar(&opts.inferenceProjectNumber, "inference-project-number", "", "numeric GCP project number; requires all three --inference-* flags")
cmd.Flags().StringVar(&opts.inferenceRegion, "inference-region", "", "GCP region for inference; requires all three --inference-* flags")
cmd.Flags().StringVar(&opts.inferenceProject, "inference-project", "", "GCP project ID for inference")
cmd.Flags().StringVar(&opts.inferenceProjectNumber, "inference-project-number", "", "numeric GCP project number (auto-derived from --inference-project when omitted)")
cmd.Flags().StringVar(&opts.inferenceRegion, "inference-region", "", "GCP region for inference (default: global)")
cmd.Flags().StringVar(&opts.fullsendRef, "fullsend-ref", "", "per-repo fullsend workflow ref override")
cmd.Flags().StringVar(&opts.mintURL, "mint-url", "", "per-repo mint URL override")
cmd.Flags().StringSliceVar(&opts.allowedRemoteResources, "allowed-remote-resources", nil, "per-repo allowed remote resources override")
Expand Down Expand Up @@ -502,6 +503,30 @@ func runReposInstall(ctx context.Context, opts *reposInstallConfig) error {

printer := ui.New(os.Stdout)

// Default --inference-region to "global" (matching admin install)
// when --inference-project is set but --inference-region is not.
if opts.inferenceProject != "" && opts.inferenceRegion == "" {
opts.inferenceRegion = "global"
}

// Derive --inference-project-number from --inference-project via
// the GCP Resource Manager API when not explicitly provided.
if opts.inferenceProject != "" && opts.inferenceProjectNumber == "" {
var projectNumber string
var lookupErr error
if opts.testProjectNumberFn != nil {
projectNumber, lookupErr = opts.testProjectNumberFn(ctx, opts.inferenceProject)
} else {
gcpClient := gcf.NewLiveGCFClient(opts.inferenceProject)
projectNumber, lookupErr = gcpClient.GetProjectNumber(ctx, opts.inferenceProject)
}
if lookupErr != nil {
return fmt.Errorf("deriving project number from %q: %w (use --inference-project-number to specify it manually)", opts.inferenceProject, lookupErr)
}
opts.inferenceProjectNumber = projectNumber
printer.StepDone(fmt.Sprintf("Derived project number %s from project %s", projectNumber, opts.inferenceProject))
}

printer.StepStart("Loading manifest")
manifest, err := repos.LoadManifest(ctx, opts.manifest)
if err != nil {
Expand Down
101 changes: 101 additions & 0 deletions internal/cli/repos_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1397,6 +1397,107 @@ func TestRunReposInstall_InvalidInferenceProjectNumber(t *testing.T) {
assert.Contains(t, err.Error(), "--inference-project-number must be numeric")
}

Comment thread
ggallen marked this conversation as resolved.
func TestRunReposInstall_DerivesProjectNumber(t *testing.T) {
manifestPath := writeTestManifest(t, testManifestYAML)
fc := newInstallFakeClient("acme/api")

opts := &reposInstallConfig{
manifest: manifestPath,
concurrency: 4,
roles: []string{"triage"},
direct: true,
inferenceProject: "inf-proj",
// No inferenceProjectNumber — should be auto-derived.
// No inferenceRegion — should default to "global".
testClient: fc,
testProjectNumberFn: func(_ context.Context, projectID string) (string, error) {
if projectID != "inf-proj" {
t.Errorf("expected project ID inf-proj, got %s", projectID)
}
return "987654321", nil
},
}
err := runReposInstall(context.Background(), opts)
require.NoError(t, err)
Comment thread
ggallen marked this conversation as resolved.

// Verify derived values. runReposInstall sets these on opts before
// constructing BatchInstallConfig (which copies them verbatim), so
// asserting here confirms the derivation logic. The require.NoError
// above also provides indirect coverage: BatchInstall's all-or-nothing
// validation would fail if the values were missing or empty.
assert.Equal(t, "987654321", opts.inferenceProjectNumber,
"project number should be auto-derived from testProjectNumberFn")
assert.Equal(t, "global", opts.inferenceRegion,
"inference region should default to global")
}

func TestRunReposInstall_ExplicitProjectNumberSkipsLookup(t *testing.T) {
manifestPath := writeTestManifest(t, testManifestYAML)
fc := newInstallFakeClient("acme/api")

lookupCalled := false
err := runReposInstall(context.Background(), &reposInstallConfig{
manifest: manifestPath,
concurrency: 4,
roles: []string{"triage"},
direct: true,
inferenceProject: "inf-proj",
inferenceProjectNumber: "111222333",
inferenceRegion: "us-central1",
testClient: fc,
testProjectNumberFn: func(_ context.Context, _ string) (string, error) {
lookupCalled = true
return "999", nil
},
})
require.NoError(t, err)
assert.False(t, lookupCalled,
"project number lookup should be skipped when --inference-project-number is explicit")
}

func TestRunReposInstall_DefaultsInferenceRegion(t *testing.T) {
manifestPath := writeTestManifest(t, testManifestYAML)
fc := newInstallFakeClient("acme/api")

opts := &reposInstallConfig{
manifest: manifestPath,
concurrency: 4,
roles: []string{"triage"},
direct: true,
inferenceProject: "inf-proj",
// inferenceRegion left empty — should default to "global".
testClient: fc,
testProjectNumberFn: func(_ context.Context, _ string) (string, error) {
return "123456789", nil
},
}
err := runReposInstall(context.Background(), opts)
require.NoError(t, err)
assert.Equal(t, "global", opts.inferenceRegion,
"inference region should default to global when --inference-project is set")
}

func TestRunReposInstall_ProjectNumberLookupError(t *testing.T) {
manifestPath := writeTestManifest(t, testManifestYAML)
fc := newInstallFakeClient("acme/api")

err := runReposInstall(context.Background(), &reposInstallConfig{
manifest: manifestPath,
concurrency: 4,
roles: []string{"triage"},
direct: true,
inferenceProject: "inf-proj",
testClient: fc,
testProjectNumberFn: func(_ context.Context, _ string) (string, error) {
return "", errors.New("API unavailable")
},
})
require.Error(t, err)
assert.Contains(t, err.Error(), "deriving project number")
assert.Contains(t, err.Error(), "API unavailable")
assert.Contains(t, err.Error(), "--inference-project-number")
}

func TestRunReposInstall_PerRepoOverrideFlags_Applied(t *testing.T) {
manifestPath := writeTestManifest(t, testManifestYAML)
fc := newInstallFakeClient("acme/api", "acme/web")
Expand Down
10 changes: 6 additions & 4 deletions internal/repos/batch_install.go
Original file line number Diff line number Diff line change
Expand Up @@ -257,10 +257,12 @@ func BatchInstall(ctx context.Context, cfg BatchInstallConfig,
return result, nil
}

// Inference flags are all-or-nothing: if any one of
// --inference-project, --inference-project-number, or
// --inference-region is set, all three are required. Fail fast
// before per-repo validation.
// Inference flags validation: all three must be present when any
// is set. The CLI layer defaults --inference-region to "global"
// and auto-derives --inference-project-number from the project
// ID, so users only need to pass --inference-project. This
// validation acts as a safety net for callers that bypass the
// CLI (e.g. tests calling BatchInstall directly).
inferenceFlags := []struct{ name, val string }{
{"--inference-project", cfg.InferenceProject},
{"--inference-project-number", cfg.InferenceProjectNumber},
Expand Down
Loading